380 lines
10 KiB
TypeScript
380 lines
10 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import {
|
|
cp,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const fixtureRoot = path.resolve(
|
|
".tmp/browser-file-storage-runtime-removal",
|
|
);
|
|
const pnpmCli = requireEnvironment("npm_execpath");
|
|
const runtimePaths = [
|
|
"src/application/ports/browser-file-storage",
|
|
"src/application/ports/browser-transfer",
|
|
"src/adapters/browser-file-storage",
|
|
"src/adapters/browser-files",
|
|
"src/adapters/browser-transfer",
|
|
"src/adapters/cache-storage",
|
|
"src/adapters/storage/indexeddb",
|
|
"src/adapters/storage/opfs",
|
|
"tests/browser-capabilities",
|
|
"tests/fixtures/browser-file-storage-boundaries",
|
|
] as const;
|
|
const runtimeSourceRoots = runtimePaths.filter((entry) =>
|
|
entry.startsWith("src/"),
|
|
);
|
|
const copyTargets = [
|
|
"src",
|
|
"tests",
|
|
"recipes",
|
|
"scripts",
|
|
"config",
|
|
"schemas",
|
|
"public",
|
|
".gitea",
|
|
".storybook",
|
|
"index.html",
|
|
"package.json",
|
|
"tsconfig.base.json",
|
|
"tsconfig.json",
|
|
"tsconfig.app.json",
|
|
"tsconfig.node.json",
|
|
"tsconfig.test.json",
|
|
"tsconfig.recipes.json",
|
|
"vite.config.ts",
|
|
"vitest.config.ts",
|
|
"playwright.config.ts",
|
|
"playwright.capabilities.config.ts",
|
|
"playwright.dev.config.ts",
|
|
"playwright.storybook.config.ts",
|
|
"playwright.visual.config.ts",
|
|
"eslint.config.ts",
|
|
".dependency-cruiser.json",
|
|
".nvmrc",
|
|
] as const;
|
|
|
|
function requireEnvironment(name: string): string {
|
|
const value = process.env[name];
|
|
if (!value) {
|
|
throw new Error(`${name} is required for runtime removal verification`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function runPnpm(script: string): boolean {
|
|
return (
|
|
spawnSync(process.execPath, [pnpmCli, script], {
|
|
cwd: fixtureRoot,
|
|
stdio: "inherit",
|
|
}).status === 0
|
|
);
|
|
}
|
|
|
|
async function sourceFiles(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
return (
|
|
await Promise.all(
|
|
entries.map(async (entry): Promise<string[]> => {
|
|
const target = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return await sourceFiles(target);
|
|
}
|
|
return /\.(?:[cm]?ts|tsx)$/u.test(entry.name)
|
|
? [path.resolve(target)]
|
|
: [];
|
|
}),
|
|
)
|
|
).flat();
|
|
}
|
|
|
|
function staticImportSpecifiers(source: string): string[] {
|
|
return [
|
|
...source.matchAll(
|
|
/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu,
|
|
),
|
|
]
|
|
.map((match) => match[1])
|
|
.filter((specifier): specifier is string =>
|
|
typeof specifier === "string",
|
|
);
|
|
}
|
|
|
|
function isWithin(target: string, root: string): boolean {
|
|
const relative = path.relative(root, target);
|
|
return (
|
|
relative === "" ||
|
|
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
);
|
|
}
|
|
|
|
function resolvedImport(
|
|
importer: string,
|
|
specifier: string,
|
|
sourceSet: ReadonlySet<string>,
|
|
): string | null {
|
|
if (!specifier.startsWith(".")) return null;
|
|
const base = path.resolve(path.dirname(importer), specifier);
|
|
const candidates = [
|
|
base,
|
|
`${base}.ts`,
|
|
`${base}.tsx`,
|
|
`${base}.mts`,
|
|
`${base}.cts`,
|
|
path.join(base, "index.ts"),
|
|
path.join(base, "index.tsx"),
|
|
];
|
|
return candidates.find((candidate) => sourceSet.has(candidate)) ?? base;
|
|
}
|
|
|
|
async function runtimeImportGraph(root: string): Promise<Readonly<{
|
|
dependentTests: readonly string[];
|
|
importingFiles: readonly string[];
|
|
}>> {
|
|
const files = await sourceFiles(root);
|
|
const sourceSet = new Set(files);
|
|
const runtimeRoots = runtimeSourceRoots.map((entry) =>
|
|
path.resolve(root, entry),
|
|
);
|
|
const imports = new Map<string, readonly string[]>();
|
|
for (const file of files) {
|
|
const source = await readFile(file, "utf8");
|
|
imports.set(
|
|
file,
|
|
staticImportSpecifiers(source)
|
|
.map((specifier) =>
|
|
resolvedImport(file, specifier, sourceSet),
|
|
)
|
|
.filter((target): target is string => target !== null),
|
|
);
|
|
}
|
|
|
|
const memo = new Map<string, boolean>();
|
|
const reachesRuntime = (
|
|
file: string,
|
|
visiting = new Set<string>(),
|
|
): boolean => {
|
|
if (runtimeRoots.some((root) => isWithin(file, root))) return true;
|
|
const known = memo.get(file);
|
|
if (known !== undefined) return known;
|
|
if (visiting.has(file)) return false;
|
|
visiting.add(file);
|
|
const reaches = (imports.get(file) ?? []).some(
|
|
(dependency) =>
|
|
runtimeRoots.some((runtimeRoot) =>
|
|
isWithin(dependency, runtimeRoot),
|
|
) ||
|
|
(sourceSet.has(dependency) &&
|
|
reachesRuntime(dependency, visiting)),
|
|
);
|
|
visiting.delete(file);
|
|
memo.set(file, reaches);
|
|
return reaches;
|
|
};
|
|
|
|
const testsRoot = path.resolve(root, "tests");
|
|
const dependentTests = files.filter(
|
|
(file) => isWithin(file, testsRoot) && reachesRuntime(file),
|
|
);
|
|
const importingFiles = files.filter(
|
|
(file) =>
|
|
!runtimeRoots.some((runtimeRoot) =>
|
|
isWithin(file, runtimeRoot),
|
|
) &&
|
|
(imports.get(file) ?? []).some((dependency) =>
|
|
runtimeRoots.some((runtimeRoot) =>
|
|
isWithin(dependency, runtimeRoot),
|
|
),
|
|
),
|
|
);
|
|
return Object.freeze({
|
|
dependentTests: Object.freeze(dependentTests),
|
|
importingFiles: Object.freeze(importingFiles),
|
|
});
|
|
}
|
|
|
|
async function assertNoRuntimeImports(root: string): Promise<void> {
|
|
const graph = await runtimeImportGraph(root);
|
|
if (graph.importingFiles.length > 0) {
|
|
throw new Error(
|
|
`Removed browser file/storage runtime is still imported by: ${graph.importingFiles
|
|
.map((file) => path.relative(root, file))
|
|
.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function removeRuntimeDependentTests(
|
|
root: string,
|
|
): Promise<number> {
|
|
const graph = await runtimeImportGraph(root);
|
|
await Promise.all(
|
|
graph.dependentTests.map(async (file) => {
|
|
if (isWithin(file, path.resolve(root, "tests"))) {
|
|
await rm(file, { force: true });
|
|
}
|
|
}),
|
|
);
|
|
return graph.dependentTests.length;
|
|
}
|
|
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
await mkdir(fixtureRoot, { recursive: true });
|
|
for (const target of copyTargets) {
|
|
await cp(target, path.join(fixtureRoot, target), { recursive: true });
|
|
}
|
|
await symlink(
|
|
path.resolve("node_modules"),
|
|
path.join(fixtureRoot, "node_modules"),
|
|
"dir",
|
|
);
|
|
|
|
const removedRuntimeTests =
|
|
await removeRuntimeDependentTests(fixtureRoot);
|
|
for (const runtimePath of runtimePaths) {
|
|
await rm(path.join(fixtureRoot, runtimePath), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
}
|
|
|
|
const catalogPath = path.join(
|
|
fixtureRoot,
|
|
"config/recipes/frontend-capability-recipes.json",
|
|
);
|
|
const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as {
|
|
recipes: Array<Record<string, unknown>>;
|
|
};
|
|
const browserFileRuntimeRecipeIds = new Set([
|
|
"offline-indexeddb",
|
|
"service-worker-pwa",
|
|
"file-transfer",
|
|
]);
|
|
let removedRuntimeEntries = 0;
|
|
for (const recipe of catalog.recipes) {
|
|
if (
|
|
typeof recipe.id !== "string" ||
|
|
!browserFileRuntimeRecipeIds.has(recipe.id) ||
|
|
!Object.hasOwn(recipe, "referenceRuntime")
|
|
) {
|
|
continue;
|
|
}
|
|
delete recipe.referenceRuntime;
|
|
removedRuntimeEntries += 1;
|
|
}
|
|
if (removedRuntimeEntries !== 3) {
|
|
throw new Error(
|
|
`Expected three reference runtime catalog entries, removed ${removedRuntimeEntries}`,
|
|
);
|
|
}
|
|
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
|
|
const packagePath = path.join(fixtureRoot, "package.json");
|
|
const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as {
|
|
scripts: Record<string, string>;
|
|
};
|
|
for (const script of [
|
|
"test:browser-capabilities",
|
|
"verify:browser-capability-evidence",
|
|
"check:browser-file-storage-boundaries",
|
|
"test:browser-file-storage-removal",
|
|
]) {
|
|
delete packageDocument.scripts[script];
|
|
}
|
|
await writeFile(
|
|
packagePath,
|
|
`${JSON.stringify(packageDocument, null, 2)}\n`,
|
|
);
|
|
await rm(path.join(fixtureRoot, "playwright.capabilities.config.ts"), {
|
|
force: true,
|
|
});
|
|
await rm(
|
|
path.join(fixtureRoot, "scripts/check-browser-file-storage-boundaries.ts"),
|
|
{ force: true },
|
|
);
|
|
await rm(
|
|
path.join(fixtureRoot, "scripts/verify-browser-capability-evidence.ts"),
|
|
{ force: true },
|
|
);
|
|
await rm(
|
|
path.join(fixtureRoot, "scripts/test-browser-file-storage-runtime-removal.ts"),
|
|
{ force: true },
|
|
);
|
|
|
|
const gatesPath = path.join(fixtureRoot, "config/ci/gates.json");
|
|
const gatesDocument = JSON.parse(
|
|
await readFile(gatesPath, "utf8"),
|
|
) as {
|
|
gates: Record<
|
|
string,
|
|
{
|
|
steps: Array<{ script: string }>;
|
|
evidence: string[];
|
|
}
|
|
>;
|
|
};
|
|
for (const gate of Object.values(gatesDocument.gates)) {
|
|
gate.steps = gate.steps.filter(
|
|
({ script }) =>
|
|
![
|
|
"test:browser-capabilities",
|
|
"verify:browser-capability-evidence",
|
|
"check:browser-file-storage-boundaries",
|
|
"test:browser-file-storage-removal",
|
|
].includes(script),
|
|
);
|
|
gate.evidence = gate.evidence.filter(
|
|
(evidence) =>
|
|
!evidence.includes("browser-capabilities") &&
|
|
!evidence.includes("browser-file-storage-runtime-removal"),
|
|
);
|
|
}
|
|
await writeFile(
|
|
gatesPath,
|
|
`${JSON.stringify(gatesDocument, null, 2)}\n`,
|
|
);
|
|
await assertNoRuntimeImports(fixtureRoot);
|
|
|
|
const checks: Array<readonly [string, boolean]> = [
|
|
["typecheck", runPnpm("check:types")],
|
|
["lint", runPnpm("lint")],
|
|
["architecture", runPnpm("check:architecture")],
|
|
["test", runPnpm("test:all")],
|
|
["build", runPnpm("build")],
|
|
["optional-catalog", runPnpm("check:optional-recipes:source")],
|
|
["ci-contract", runPnpm("check:ci")],
|
|
];
|
|
const passed = checks.every(([, result]) => result);
|
|
await mkdir("artifacts/tests", { recursive: true });
|
|
await writeFile(
|
|
"artifacts/tests/browser-file-storage-runtime-removal.xml",
|
|
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
`<testsuite name="browser-file-storage-runtime-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
|
|
checks
|
|
.map(
|
|
([name, result]) =>
|
|
`<testcase name="${name}">${result ? "" : "<failure />"}</testcase>`,
|
|
)
|
|
.join("") +
|
|
`</testsuite>\n`,
|
|
);
|
|
await rm(fixtureRoot, { recursive: true, force: true });
|
|
|
|
if (!passed) {
|
|
process.stderr.write(
|
|
`Browser file/storage runtime removal failed: ${checks
|
|
.filter(([, result]) => !result)
|
|
.map(([name]) => name)
|
|
.join(", ")}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(
|
|
`Browser file/storage runtime removal: PASS (${checks.length} base checks, ${removedRuntimeTests} runtime-dependent tests removed by import graph)\n`,
|
|
);
|