fix release v2 review issues
This commit is contained in:
1 parent
ef2ed6c602
commit
ca856f9f54
29 files changed
+508
-131
No files matched your search
@@ -3,7 +3,8 @@ import { test } from "node:test";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { checkFeatureContracts } from "./contracts.mts";
|
||||
import { planFeatureGeneration } from "./generate-feature.mts";
|
||||
import { formatFeatureChanges, planFeatureGeneration } from "./generate-feature.mts";
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Registers shared contract tests in this suite.
|
||||
import "./check-feature.contract.mts";
|
||||
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
@@ -12,26 +13,30 @@ test("the checked-in feature contracts are consistent", () => {
|
||||
assert.deepEqual(checkFeatureContracts(root), []);
|
||||
});
|
||||
|
||||
test("paired generation plans every contract without writing", () => {
|
||||
const changes = planFeatureGeneration(root, {
|
||||
integration: {
|
||||
kind: "exampleService",
|
||||
name: "Example Service",
|
||||
slug: "example-service",
|
||||
description: "Example native integration.",
|
||||
category: "miscellaneous",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl: "https://example.com/icon.svg",
|
||||
},
|
||||
widget: {
|
||||
kind: "exampleStatus",
|
||||
name: "Example Status",
|
||||
slug: "example-status",
|
||||
description: "Example native widget.",
|
||||
icon: "IconBox",
|
||||
supportedIntegrations: ["exampleService"],
|
||||
},
|
||||
});
|
||||
test("paired generation plans every formatter-clean contract without writing", async () => {
|
||||
const description =
|
||||
"A deliberately long feature description that verifies generated source stays formatter-clean when contributor input exceeds the configured print width.";
|
||||
const changes = await formatFeatureChanges(
|
||||
planFeatureGeneration(root, {
|
||||
integration: {
|
||||
kind: "exampleService",
|
||||
name: 'Example "Home" Service',
|
||||
slug: "example-service",
|
||||
description,
|
||||
category: "miscellaneous",
|
||||
secretKinds: ["apiKey"],
|
||||
iconUrl: "https://example.com/icon.svg",
|
||||
},
|
||||
widget: {
|
||||
kind: "exampleStatus",
|
||||
name: 'Example "Home" Status',
|
||||
slug: "example-status",
|
||||
description,
|
||||
icon: "IconBox",
|
||||
supportedIntegrations: ["exampleService"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(changes.length, 20);
|
||||
assert.equal(new Set(changes.map((change) => change.path)).size, changes.length);
|
||||
@@ -41,6 +46,10 @@ test("paired generation plans every contract without writing", () => {
|
||||
assert.ok(changes.some((change) => change.path === "apps/docs/docs/widgets/example-status/index.mdx"));
|
||||
const integrationTest = changes.find((change) => change.path.endsWith("example-service-integration.spec.ts"));
|
||||
assert.match(integrationTest?.content ?? "", /simulateResponseContractAsync/);
|
||||
assert.match(
|
||||
integrationTest?.content ?? "",
|
||||
/expect\(\n results\.every\(\(result\) => result\.passed\),\n results,\n \)\.toBe\(true\)/,
|
||||
);
|
||||
const widgetDefinition = changes.find((change) => change.path.endsWith("example-status/index.ts"));
|
||||
assert.match(widgetDefinition?.content ?? "", /nativeFeatureCapabilities\.exampleStatus\.integrations/);
|
||||
const capabilityDescriptor = changes.find(
|
||||
@@ -52,6 +61,7 @@ test("paired generation plans every contract without writing", () => {
|
||||
const parsed = JSON.parse(translations.content) as { integration: object; widget: object };
|
||||
assert.ok("exampleService" in parsed.integration);
|
||||
assert.ok("exampleStatus" in parsed.widget);
|
||||
assert.deepEqual(await formatFeatureChanges(changes), changes);
|
||||
});
|
||||
|
||||
test("generation stops before overwriting an existing feature", () => {
|
||||
@@ -71,3 +81,22 @@ test("generation stops before overwriting an existing feature", () => {
|
||||
/already exists/,
|
||||
);
|
||||
});
|
||||
|
||||
test("generation rejects slugs that cannot form TypeScript identifiers", () => {
|
||||
for (const slug of ["2fa-status", "example-2fa"]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
planFeatureGeneration(root, {
|
||||
widget: {
|
||||
kind: "twoFactorStatus",
|
||||
name: "2FA Status",
|
||||
slug,
|
||||
description: "Must not generate an invalid component name.",
|
||||
icon: "IconBox",
|
||||
supportedIntegrations: [],
|
||||
},
|
||||
}),
|
||||
/must start with a lowercase letter and use kebab-case/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { format } from "oxfmt";
|
||||
|
||||
export interface IntegrationFeature {
|
||||
kind: string;
|
||||
@@ -112,8 +113,8 @@ const validate = (request: FeatureRequest) => {
|
||||
if (!feature) continue;
|
||||
if (!/^[a-z][A-Za-z0-9]*$/.test(feature.kind))
|
||||
throw new Error(`Generator stopped: ${feature.kind} must be lower camel case.`);
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(feature.slug))
|
||||
throw new Error(`Generator stopped: ${feature.slug} must be kebab-case.`);
|
||||
if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(feature.slug))
|
||||
throw new Error(`Generator stopped: ${feature.slug} must start with a lowercase letter and use kebab-case.`);
|
||||
}
|
||||
if (request.widget && !/^Icon[A-Z][A-Za-z0-9]+$/.test(request.widget.icon)) {
|
||||
throw new Error(`Generator stopped: ${request.widget.icon} must be a Tabler icon export such as IconBox.`);
|
||||
@@ -173,12 +174,12 @@ describe("${className}", () => {
|
||||
test("exposes its public integration data", () => {
|
||||
const integration = new ${className}({
|
||||
id: "test",
|
||||
name: "${feature.name}",
|
||||
name: ${JSON.stringify(feature.name)},
|
||||
url: "http://localhost",
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [],
|
||||
});
|
||||
expect(integration.publicIntegration.name).toBe("${feature.name}");
|
||||
expect(integration.publicIntegration.name).toBe(${JSON.stringify(feature.name)});
|
||||
});
|
||||
|
||||
test("parses documented response fixtures", async () => {
|
||||
@@ -187,7 +188,10 @@ describe("${className}", () => {
|
||||
{ name: "invalid response", payload: null, rejects: true },
|
||||
]);
|
||||
|
||||
expect(results.every((result) => result.passed), results).toBe(true);
|
||||
expect(
|
||||
results.every((result) => result.passed),
|
||||
results,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
`;
|
||||
@@ -294,14 +298,14 @@ const planWidget = (root: string, feature: WidgetFeature, changes: PlannedChange
|
||||
changes,
|
||||
root,
|
||||
`${widgetPath}/component.tsx`,
|
||||
`"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n <Center h="100%">\n <Text>${feature.name}</Text>\n </Center>\n );\n}\n`,
|
||||
`"use client";\n\nimport { Center, Text } from "@mantine/core";\n\nimport type { WidgetComponentProps } from "../definition";\n\nexport default function ${pascalCase(feature.slug)}Widget(_props: WidgetComponentProps<"${feature.kind}">) {\n return (\n <Center h="100%">\n <Text>{${JSON.stringify(feature.name)}}</Text>\n </Center>\n );\n}\n`,
|
||||
"create",
|
||||
);
|
||||
addChange(
|
||||
changes,
|
||||
root,
|
||||
`${widgetPath}/definition.spec.ts`,
|
||||
`import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe("${feature.name} widget definition", () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`,
|
||||
`import { describe, expect, test } from "vitest";\n\nimport { componentLoader, definition } from ".";\n\ndescribe(${JSON.stringify(`${feature.name} widget definition`)}, () => {\n test("registers its kind and loader", () => {\n expect(definition.kind).toBe("${feature.kind}");\n expect(componentLoader).toBeTypeOf("function");\n });\n});\n`,
|
||||
"create",
|
||||
);
|
||||
|
||||
@@ -444,8 +448,25 @@ export const planFeatureGeneration = (root: string, request: FeatureRequest): Pl
|
||||
return changes;
|
||||
};
|
||||
|
||||
export const generateFeature = (root: string, request: FeatureRequest) => {
|
||||
const changes = planFeatureGeneration(root, request);
|
||||
const isFormatterManaged = (path: string) =>
|
||||
/\.(?:json|mdx|ts|tsx)$/.test(path) && path !== "packages/translation/src/lang/en.json";
|
||||
|
||||
export const formatFeatureChanges = async (changes: PlannedChange[]) =>
|
||||
await Promise.all(
|
||||
changes.map(async (change) => {
|
||||
if (!isFormatterManaged(change.path)) return change;
|
||||
const result = await format(change.path, change.content, { endOfLine: "lf", printWidth: 120 });
|
||||
if (result.errors.length > 0) {
|
||||
throw new Error(
|
||||
`Generator stopped: oxfmt could not format ${change.path}: ${result.errors.map(({ message }) => message).join("; ")}`,
|
||||
);
|
||||
}
|
||||
return { ...change, content: result.code };
|
||||
}),
|
||||
);
|
||||
|
||||
export const generateFeature = async (root: string, request: FeatureRequest) => {
|
||||
const changes = await formatFeatureChanges(planFeatureGeneration(root, request));
|
||||
for (const change of changes) {
|
||||
mkdirSync(dirname(join(root, change.path)), { recursive: true });
|
||||
writeFileSync(join(root, change.path), change.content);
|
||||
|
||||
Reference in new issue
Block a user