208 lines
8.5 KiB
TypeScript
208 lines
8.5 KiB
TypeScript
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
import { readArrayRegistry, readObjectRegistry } from "./contracts.mts";
|
|
|
|
export interface FeatureCheckCommand {
|
|
label: string;
|
|
executable: string;
|
|
args: string[];
|
|
}
|
|
|
|
export interface FeatureCheckPlan {
|
|
kind: string;
|
|
types: ("integration" | "widget")[];
|
|
files: string[];
|
|
commands: FeatureCheckCommand[];
|
|
optionalValidations: string[];
|
|
}
|
|
|
|
const walkFiles = (directory: string): string[] => {
|
|
if (!existsSync(directory)) return [];
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = join(directory, entry.name);
|
|
return entry.isDirectory() ? walkFiles(path) : [path];
|
|
});
|
|
};
|
|
|
|
const directString = (value: string | undefined) => value?.match(/^"([^"]+)"/)?.[1];
|
|
|
|
const resolveSourceImport = (root: string, importer: string, importPath: string) => {
|
|
const base = relative(root, resolve(root, dirname(importer), importPath));
|
|
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts"), join(base, "index.tsx")]) {
|
|
if (existsSync(join(root, candidate))) return candidate;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const integrationImplementation = (root: string, kind: string) => {
|
|
const creatorPath = "packages/integrations/src/base/creator.ts";
|
|
const creatorSource = readFileSync(join(root, creatorPath), "utf8");
|
|
const creator = readObjectRegistry(root, creatorPath, "integrationCreators")
|
|
.get(kind)
|
|
?.match(/[A-Za-z_$][\w$]*/)?.[0];
|
|
if (!creator) return null;
|
|
const escapedCreator = creator.replaceAll("$", "\\$");
|
|
const importPath = creatorSource.match(
|
|
new RegExp(`import \\{[^}]*\\b${escapedCreator}\\b[^}]*\\} from "([^"]+)"`),
|
|
)?.[1];
|
|
return importPath ? resolveSourceImport(root, creatorPath, importPath) : null;
|
|
};
|
|
|
|
const widgetImplementation = (root: string, kind: string) => {
|
|
const value = readObjectRegistry(root, "packages/widgets/src/manifest.ts", "moduleLoaders").get(kind);
|
|
const importPath = value?.match(/import\("([^"]+)"\)/)?.[1];
|
|
return importPath ? resolveSourceImport(root, "packages/widgets/src/manifest.ts", importPath) : null;
|
|
};
|
|
|
|
const specsNear = (root: string, implementation: string | null) => {
|
|
if (!implementation) return [];
|
|
return walkFiles(dirname(join(root, implementation)))
|
|
.filter((path) => /\.spec\.tsx?$/.test(path))
|
|
.map((path) => relative(root, path))
|
|
.toSorted();
|
|
};
|
|
|
|
const packageCommand = (label: string, packages: string[]): FeatureCheckCommand => ({
|
|
label,
|
|
executable: "pnpm",
|
|
args: [...packages.flatMap((name) => ["--filter", name]), "typecheck"],
|
|
});
|
|
|
|
export const createFeatureCheckPlan = (root: string, kind: string): FeatureCheckPlan => {
|
|
const integrationKinds = new Set(
|
|
readObjectRegistry(root, "packages/definitions/src/integration.ts", "integrationDefs").keys(),
|
|
);
|
|
const widgetKinds = new Set(readArrayRegistry(root, "packages/definitions/src/widget.ts", "widgetKinds"));
|
|
const types: FeatureCheckPlan["types"] = [];
|
|
if (integrationKinds.has(kind)) types.push("integration");
|
|
if (widgetKinds.has(kind)) types.push("widget");
|
|
if (types.length === 0) {
|
|
throw new Error(`Unknown feature kind ${JSON.stringify(kind)}. Use a key from integrationDefs or widgetKinds.`);
|
|
}
|
|
|
|
const files = new Set<string>(["packages/translation/src/lang/en.json"]);
|
|
const tests = new Set<string>();
|
|
const packages = new Set(["@homarr/definitions", "@homarr/translation"]);
|
|
|
|
if (types.includes("integration")) {
|
|
const implementation = integrationImplementation(root, kind);
|
|
if (implementation) files.add(implementation);
|
|
specsNear(root, implementation).forEach((path) => tests.add(path));
|
|
tests.add("packages/integrations/src/base/response-contract.spec.ts");
|
|
packages.add("@homarr/integrations");
|
|
for (const path of [
|
|
"packages/definitions/src/integration.ts",
|
|
"packages/definitions/src/docs/integration-doc-slugs.ts",
|
|
"packages/integrations/src/base/creator.ts",
|
|
"packages/integrations/src/index.ts",
|
|
])
|
|
files.add(path);
|
|
const slug = directString(
|
|
readObjectRegistry(root, "packages/definitions/src/docs/integration-doc-slugs.ts", "integrationDocSlugs").get(
|
|
kind,
|
|
),
|
|
);
|
|
if (slug) {
|
|
packages.add("@homarr/docs");
|
|
files.add(`apps/docs/docs/integrations/${slug}/index.ts`);
|
|
files.add(`apps/docs/docs/integrations/${slug}/index.mdx`);
|
|
}
|
|
}
|
|
|
|
if (types.includes("widget")) {
|
|
const implementation = widgetImplementation(root, kind);
|
|
if (implementation) {
|
|
const directory = dirname(join(root, implementation));
|
|
walkFiles(directory)
|
|
.filter((path) => /\.(?:ts|tsx|css)$/.test(path))
|
|
.map((path) => relative(root, path))
|
|
.forEach((path) => files.add(path));
|
|
}
|
|
specsNear(root, implementation).forEach((path) => tests.add(path));
|
|
tests.add("packages/widgets/src/manifest.spec.ts");
|
|
tests.add("packages/definitions/src/test/widget-integration-map.spec.ts");
|
|
packages.add("@homarr/widgets");
|
|
for (const path of [
|
|
"packages/definitions/src/widget.ts",
|
|
"packages/definitions/src/widget-integration-map.ts",
|
|
"packages/definitions/src/docs/widget-doc-slugs.ts",
|
|
"packages/widgets/src/manifest.ts",
|
|
"packages/widgets/src/catalog.ts",
|
|
"packages/widgets/src/index.tsx",
|
|
])
|
|
files.add(path);
|
|
const slug = directString(
|
|
readObjectRegistry(root, "packages/definitions/src/docs/widget-doc-slugs.ts", "widgetDocSlugs").get(kind),
|
|
);
|
|
if (slug) {
|
|
packages.add("@homarr/docs");
|
|
const metadataTs = `apps/docs/docs/widgets/${slug}/index.ts`;
|
|
files.add(existsSync(join(root, metadataTs)) ? metadataTs : `${metadataTs}x`);
|
|
files.add(`apps/docs/docs/widgets/${slug}/index.mdx`);
|
|
}
|
|
}
|
|
|
|
const sortedTests = [...tests].filter((path) => existsSync(join(root, path))).toSorted();
|
|
sortedTests.forEach((path) => files.add(path));
|
|
const sortedFiles = [...files].filter((path) => existsSync(join(root, path))).toSorted();
|
|
const lintFiles = sortedFiles.filter((path) => /\.(?:ts|tsx|mts)$/.test(path));
|
|
const sortedPackages = [...packages].toSorted();
|
|
const commands: FeatureCheckCommand[] = [
|
|
{
|
|
label: "Descriptor, docs, translation, and export contracts",
|
|
executable: "node",
|
|
args: ["--experimental-strip-types", "scripts/check-feature-contracts.mts"],
|
|
},
|
|
{ label: "Focused fixture and unit tests", executable: "pnpm", args: ["exec", "vitest", "run", ...sortedTests] },
|
|
packageCommand("Affected package typechecks", sortedPackages),
|
|
{ label: "Affected file lint", executable: "pnpm", args: ["exec", "oxlint", ...lintFiles] },
|
|
{ label: "Affected file formatting", executable: "pnpm", args: ["exec", "oxfmt", "--check", ...sortedFiles] },
|
|
];
|
|
|
|
return {
|
|
kind,
|
|
types,
|
|
files: sortedFiles,
|
|
commands,
|
|
optionalValidations: [
|
|
"LIVE SERVICE (optional, not run): exercise test-connection and feature requests against a disposable real service with non-production credentials.",
|
|
"CONTAINER/E2E (optional, not run): validate the supported service image and network path when the feature depends on container-specific behavior.",
|
|
...(types.includes("widget")
|
|
? [
|
|
"VISUAL (optional, not run): inspect shared states at /manage/tools/feature-workbench and the feature at compact and advanced sizes.",
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
};
|
|
|
|
const displayCommand = (command: FeatureCheckCommand) =>
|
|
[command.executable, ...command.args]
|
|
.map((part) => (/^[A-Za-z0-9_./:@=-]+$/.test(part) ? part : `'${part.replaceAll("'", "'\\''")}'`))
|
|
.join(" ");
|
|
|
|
export const formatFeatureCheckPlan = (plan: FeatureCheckPlan) => {
|
|
const lines = [`Feature check plan for ${plan.kind} (${plan.types.join(" + ")}):`, "", "Affected files:"];
|
|
lines.push(...plan.files.map((path) => `- ${path}`), "", "Checks:");
|
|
lines.push(...plan.commands.map((command, index) => `${index + 1}. ${command.label}\n ${displayCommand(command)}`));
|
|
lines.push("", "Optional validation:", ...plan.optionalValidations.map((validation) => `- ${validation}`));
|
|
return lines.join("\n");
|
|
};
|
|
|
|
export type FeatureCommandRunner = (command: FeatureCheckCommand) => number;
|
|
|
|
export const executeFeatureCheckPlan = (
|
|
plan: FeatureCheckPlan,
|
|
runner: FeatureCommandRunner = (command) =>
|
|
spawnSync(command.executable, command.args, { stdio: "inherit" }).status ?? 1,
|
|
) => {
|
|
for (const command of plan.commands) {
|
|
console.log(`\n[check:feature] ${command.label}`);
|
|
const status = runner(command);
|
|
if (status !== 0) return status;
|
|
}
|
|
return 0;
|
|
};
|