File
Blob: tests/worker/structure.test.ts
| 1 | import { dirname, join, normalize } from "node:path/posix"; |
| 2 | import { describe, it } from "vitest"; |
| 3 | |
| 4 | const 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 | |
| 11 | const extensions = [".ts", ".tsx", ".mts", ".cts"]; |
| 12 | const importPattern = /(?:import|export)\s+[\s\S]*?from\s*['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g; |
| 13 | |
| 14 | const normalizeGlobPath = (value: string): string => value.replace(/^\.\.\/\.\.\//u, ""); |
| 15 | |
| 16 | const 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 | |
| 49 | describe("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 | }); |