Skip to content
File

Blob: tests/worker/structure.test.ts

typescript127 lines
1import { dirname, join, normalize } from "node:path/posix";
2import { describe, it } from "vitest";
3 
4const sourceModules = {
5 ...import.meta.glob("../../src/contracts/**/*.ts", { eager: true, query: "?raw", import: "default" }),
6 ...import.meta.glob("../../src/lib/**/*.ts", { eager: true, query: "?raw", import: "default" }),
7 ...import.meta.glob("../../src/worker/**/*.ts", { eager: true, query: "?raw", import: "default" }),
8 ...import.meta.glob("../../src/worker/**/*.tsx", { eager: true, query: "?raw", import: "default" }),
9} satisfies Record<string, string>;
10 
11const extensions = [".ts", ".tsx", ".mts", ".cts"];
12const importPattern = /(?:import|export)\s+[\s\S]*?from\s*['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g;
13 
14const normalizeGlobPath = (value: string): string => value.replace(/^\.\.\/\.\.\//u, "");
15 
16const resolveImport = (fromFile: string, specifier: string, fileSet: Set<string>): string | null => {
17 if (specifier.startsWith("node:")) {
18 return null;
19 }
20 
21 let normalizedSpecifier = specifier;
22 if (normalizedSpecifier.startsWith("@/")) {
23 normalizedSpecifier = `src/${normalizedSpecifier.slice(2)}`;
24 }
25 
26 const candidates: string[] = [];
27 if (normalizedSpecifier.startsWith("./") || normalizedSpecifier.startsWith("../")) {
28 const base = normalize(join(dirname(fromFile), normalizedSpecifier));
29 candidates.push(base, ...extensions.map((extension) => `${base}${extension}`));
30 candidates.push(...extensions.map((extension) => join(base, `index${extension}`)));
31 } else if (normalizedSpecifier.startsWith("src/")) {
32 const base = normalizedSpecifier;
33 candidates.push(base, ...extensions.map((extension) => `${base}${extension}`));
34 candidates.push(...extensions.map((extension) => join(base, `index${extension}`)));
35 } else {
36 return null;
37 }
38 
39 for (const candidate of candidates) {
40 const normalizedCandidate = normalize(candidate);
41 if (fileSet.has(normalizedCandidate)) {
42 return normalizedCandidate;
43 }
44 }
45 
46 return null;
47};
48 
49describe("worker structure", () => {
50 it("keeps scoped shared and worker modules free of import cycles", () => {
51 const files = Object.keys(sourceModules).map(normalizeGlobPath).sort();
52 const fileSet = new Set(files);
53 const graph = new Map<string, string[]>();
54 
55 for (const file of files) {
56 const text = sourceModules[`../../${file}`];
57 const dependencies = new Set<string>();
58 
59 importPattern.lastIndex = 0;
60 let match: RegExpExecArray | null;
61 while ((match = importPattern.exec(text))) {
62 const specifier = match[1] ?? match[2];
63 const resolvedImport = resolveImport(file, specifier, fileSet);
64 if (resolvedImport) {
65 dependencies.add(resolvedImport);
66 }
67 }
68 
69 graph.set(file, [...dependencies]);
70 }
71 
72 const visited = new Set<string>();
73 const stack: string[] = [];
74 const onStack = new Set<string>();
75 const cycles: string[][] = [];
76 
77 const visit = (node: string): void => {
78 visited.add(node);
79 stack.push(node);
80 onStack.add(node);
81 
82 for (const dependency of graph.get(node) ?? []) {
83 if (!visited.has(dependency)) {
84 visit(dependency);
85 } else if (onStack.has(dependency)) {
86 const cycleStart = stack.indexOf(dependency);
87 cycles.push([...stack.slice(cycleStart), dependency]);
88 }
89 }
90 
91 stack.pop();
92 onStack.delete(node);
93 };
94 
95 for (const file of files) {
96 if (!visited.has(file)) {
97 visit(file);
98 }
99 }
100 
101 const canonicalCycles = new Map<string, string[]>();
102 for (const cycle of cycles) {
103 const body = cycle.slice(0, -1);
104 let bestKey: string | null = null;
105 let bestCycle: string[] | null = null;
106 
107 for (let index = 0; index < body.length; index += 1) {
108 const rotatedCycle = [...body.slice(index), ...body.slice(0, index)];
109 const cycleKey = rotatedCycle.join(" -> ");
110 if (bestKey === null || cycleKey < bestKey) {
111 bestKey = cycleKey;
112 bestCycle = rotatedCycle;
113 }
114 }
115 
116 if (bestKey && bestCycle && !canonicalCycles.has(bestKey)) {
117 canonicalCycles.set(bestKey, bestCycle);
118 }
119 }
120 
121 if (canonicalCycles.size > 0) {
122 const cycleList = [...canonicalCycles.values()].map((cycle) => cycle.join(" -> ")).join("\n");
123 throw new Error(`Import cycles detected:\n${cycleList}`);
124 }
125 });
126});