@specific.dev/spectest 0.91.2 → 0.92.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +36 -269
- package/dist/harness/certificate.d.ts +15 -0
- package/dist/harness/certificate.js +67 -0
- package/dist/harness/eval-typecheck.d.ts +9 -0
- package/dist/harness/eval-typecheck.js +93 -0
- package/dist/harness/service-graph.d.ts +3 -0
- package/dist/harness/service-graph.js +19 -9
- package/dist/harness/typecheck.d.ts +53 -0
- package/dist/harness/typecheck.js +184 -0
- package/dist/harness/wrapper-rules.d.ts +2 -0
- package/dist/harness/wrapper-rules.js +8 -6
- package/dist/index.d.ts +4 -5
- package/package.json +1 -1
- package/src/daemon.ts +40 -320
- package/src/harness/certificate.test.ts +31 -0
- package/src/harness/certificate.ts +73 -0
- package/src/harness/eval-typecheck.test.ts +191 -0
- package/src/harness/eval-typecheck.ts +100 -0
- package/src/harness/fetch.test.ts +28 -0
- package/src/harness/service-graph.test.ts +31 -1
- package/src/harness/service-graph.ts +23 -0
- package/src/harness/typecheck.ts +237 -0
- package/src/harness/wrapper-rules.ts +12 -7
- package/src/index.ts +10 -7
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { checkEval, evaluateChecked } from "./eval-typecheck.js";
|
|
8
|
+
import { runTypecheck } from "./typecheck.js";
|
|
9
|
+
|
|
10
|
+
const sdkDir = path.resolve(import.meta.dir, "../..");
|
|
11
|
+
// CI/dev can select the same native TypeScript installation as the VM.
|
|
12
|
+
const typecheckDir = process.env.SPECTEST_TYPECHECK_DIR ?? sdkDir;
|
|
13
|
+
const hasWrapperChecker = existsSync(path.join(typecheckDir, "node_modules/typescript/dist/api/async/api.js"));
|
|
14
|
+
let appDir: string;
|
|
15
|
+
let file: string;
|
|
16
|
+
let projectEntry: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
appDir = await fs.mkdtemp(path.join(tmpdir(), "spectest-eval-types-"));
|
|
20
|
+
await fs.mkdir(path.join(appDir, "node_modules/@specific.dev"), { recursive: true });
|
|
21
|
+
await fs.symlink(sdkDir, path.join(appDir, "node_modules/@specific.dev/spectest"));
|
|
22
|
+
await fs.symlink(path.join(sdkDir, "node_modules/@types"), path.join(appDir, "node_modules/@types"));
|
|
23
|
+
await fs.mkdir(path.join(appDir, "spectest"));
|
|
24
|
+
await fs.mkdir(path.join(appDir, ".spectest-eval"));
|
|
25
|
+
file = path.join(appDir, ".spectest-eval/snippet.ts");
|
|
26
|
+
projectEntry = path.join(appDir, "spectest/index.ts");
|
|
27
|
+
await fs.writeFile(projectEntry, `
|
|
28
|
+
import { defineEnvironment, defineFake } from "@specific.dev/spectest";
|
|
29
|
+
import { supabase } from "@specific.dev/spectest/components";
|
|
30
|
+
export const env = defineEnvironment({
|
|
31
|
+
name: "eval-typecheck",
|
|
32
|
+
services: { supabase: supabase().group },
|
|
33
|
+
fakes: { sample: defineFake({ hostnames: ["example.test"], state: () => ({}),
|
|
34
|
+
handler: () => new Response(), helpers: () => ({ count: () => 3 }) }) },
|
|
35
|
+
});
|
|
36
|
+
export default env.project([]);
|
|
37
|
+
`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => { await fs.rm(appDir, { recursive: true, force: true }); });
|
|
41
|
+
|
|
42
|
+
function run(command: string, args: string[], timeout: number): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
execFile(command, args, { cwd: appDir, timeout, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
45
|
+
resolve({ code: error ? (typeof error.code === "number" ? error.code : 1) : 0, stdout, stderr });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const opts = () => ({ appDir, file, projectEntry, typecheckDir, run });
|
|
51
|
+
const query = 'const rows = await ctx.svc.supabase.sql<{ analyzed: boolean }>`SELECT true AS analyzed`;';
|
|
52
|
+
|
|
53
|
+
test("annotated services keep helper types while literals without helpers stay absent", async () => {
|
|
54
|
+
await fs.writeFile(projectEntry, `
|
|
55
|
+
import { defineEnvironment, type ServiceDefinition } from "@specific.dev/spectest";
|
|
56
|
+
interface Helpers { value(): number }
|
|
57
|
+
const annotated: ServiceDefinition<Helpers> = {
|
|
58
|
+
image: "example:latest", helpers: async () => ({ value: () => 42 }),
|
|
59
|
+
};
|
|
60
|
+
const checked = { image: "example:latest", helpers: () => ({ value: () => 42 }) } satisfies ServiceDefinition<Helpers>;
|
|
61
|
+
const env = defineEnvironment({ name: "helpers", services: {
|
|
62
|
+
annotated, checked, plain: { image: "example:latest" },
|
|
63
|
+
absent: { image: "example:latest", helpers: undefined },
|
|
64
|
+
} });
|
|
65
|
+
export default env.project([]);
|
|
66
|
+
`);
|
|
67
|
+
await fs.writeFile(file, `
|
|
68
|
+
const a: number = ctx.svc.annotated.value();
|
|
69
|
+
const b: number = ctx.svc.checked.value();
|
|
70
|
+
// @ts-expect-error plain services have no handle
|
|
71
|
+
ctx.svc.plain;
|
|
72
|
+
// @ts-expect-error explicitly absent helpers have no handle either
|
|
73
|
+
ctx.svc.absent;
|
|
74
|
+
// @ts-expect-error helper return type must not widen to any
|
|
75
|
+
const wrong: string = ctx.svc.annotated.value();
|
|
76
|
+
export default a + b;
|
|
77
|
+
`);
|
|
78
|
+
expect(await checkEval(opts())).toMatchObject({ status: "ok", errors: [], totalErrors: 0 });
|
|
79
|
+
}, 30_000);
|
|
80
|
+
|
|
81
|
+
test("eval receives the default-exported project's service/fake types and globals", async () => {
|
|
82
|
+
await fs.writeFile(file, `${query}
|
|
83
|
+
const ready: boolean = rows[0].analyzed.unwrap();
|
|
84
|
+
const count: number = ctx.fakes.sample.count().unwrap();
|
|
85
|
+
expect(rows[0].analyzed).toBe(true);
|
|
86
|
+
expectRaw(count, "count").toBe(3);
|
|
87
|
+
assert(ready);
|
|
88
|
+
state.saved = ready;
|
|
89
|
+
export default ready;
|
|
90
|
+
`);
|
|
91
|
+
const report = await checkEval(opts());
|
|
92
|
+
expect(report).toMatchObject({ status: "ok", errors: [], totalErrors: 0 });
|
|
93
|
+
expect(await fs.readdir(path.dirname(file))).toEqual(["snippet.ts"]);
|
|
94
|
+
}, 30_000);
|
|
95
|
+
|
|
96
|
+
test("compiler catches the Listen comparison and reports original snippet lines", async () => {
|
|
97
|
+
await fs.writeFile(file, `${query}\nconst done = rows[0].analyzed === true;\nexport default done;`);
|
|
98
|
+
const report = await checkEval(opts());
|
|
99
|
+
expect(report.errors).toContainEqual(expect.objectContaining({ file: "eval.ts", line: 2, code: "TS2367" }));
|
|
100
|
+
}, 30_000);
|
|
101
|
+
|
|
102
|
+
test("an untyped SQL row and nonexistent service remain diagnostics, not any", async () => {
|
|
103
|
+
await fs.writeFile(file, 'const rows = await ctx.svc.supabase.sql`SELECT true AS analyzed`;\nexport default rows[0].analyzed;\nctx.svc.missing;');
|
|
104
|
+
const report = await checkEval(opts());
|
|
105
|
+
expect(report.errors.filter((e) => e.code === "TS2339").map((e) => e.line)).toEqual([2, 3]);
|
|
106
|
+
}, 30_000);
|
|
107
|
+
|
|
108
|
+
test.skipIf(!hasWrapperChecker)("blocking wrapper finding prevents all snippet side effects", async () => {
|
|
109
|
+
await fs.writeFile(file, `${query}\nexport default rows[0].analyzed === true;`);
|
|
110
|
+
let executed = false;
|
|
111
|
+
const logs: string[] = [];
|
|
112
|
+
await expect(evaluateChecked(opts(), (s) => logs.push(s), async () => { executed = true; })).rejects.toThrow("snippet did not run");
|
|
113
|
+
expect(executed).toBe(false);
|
|
114
|
+
expect(logs.join("")).toContain("SPECTEST2002");
|
|
115
|
+
expect(logs.join("")).toContain(".unwrap()");
|
|
116
|
+
}, 30_000);
|
|
117
|
+
|
|
118
|
+
test("ordinary errors are advisory and unrelated project errors do not enter eval output", async () => {
|
|
119
|
+
await fs.appendFile(projectEntry, '\nconst unrelated: number = "wrong";');
|
|
120
|
+
await fs.writeFile(file, 'const x: number = "wrong";\nexport default x;');
|
|
121
|
+
const logs: string[] = [];
|
|
122
|
+
expect(await evaluateChecked(opts(), (s) => logs.push(s), async () => "executed")).toBe("executed");
|
|
123
|
+
expect(logs.join("")).toContain("(advisory)");
|
|
124
|
+
expect(logs.join("")).toContain("eval.ts:1:");
|
|
125
|
+
expect(logs.join("")).not.toContain("index.ts");
|
|
126
|
+
}, 30_000);
|
|
127
|
+
|
|
128
|
+
test.skipIf(!hasWrapperChecker)("unwrapped comparisons execute and nullable presence checks remain allowed", async () => {
|
|
129
|
+
await fs.writeFile(file, `${query}\nexport default rows[0].analyzed.unwrap() === true;`);
|
|
130
|
+
expect(await evaluateChecked(opts(), () => {}, async () => "executed")).toBe("executed");
|
|
131
|
+
await fs.writeFile(file, 'const rows = await ctx.svc.supabase.sql<{ label: string | null }>`SELECT null AS label`;\nexport default rows[0].label === null;');
|
|
132
|
+
expect((await checkEval(opts())).blocking).toBeUndefined();
|
|
133
|
+
}, 30_000);
|
|
134
|
+
|
|
135
|
+
test("eval without a loaded project still checks basic globals", async () => {
|
|
136
|
+
await fs.writeFile(file, 'assert(true); state.value = 1; export default ctx.testName;');
|
|
137
|
+
expect(await checkEval({ ...opts(), projectEntry: undefined })).toMatchObject({ status: "ok", totalErrors: 0 });
|
|
138
|
+
}, 30_000);
|
|
139
|
+
|
|
140
|
+
test("project paths are respected even when its include excludes the eval directory", async () => {
|
|
141
|
+
await fs.writeFile(path.join(appDir, "spectest/tsconfig.json"), JSON.stringify({
|
|
142
|
+
compilerOptions: { paths: { "@fixture": ["./fixture.ts"] } }, include: ["tests/**/*.ts"],
|
|
143
|
+
}));
|
|
144
|
+
await fs.writeFile(path.join(appDir, "spectest/fixture.ts"), 'export const count = 3;');
|
|
145
|
+
await fs.writeFile(file, 'import { count } from "@fixture";\nconst x: number = count;\nexport default x;');
|
|
146
|
+
expect(await checkEval(opts())).toMatchObject({ status: "ok", totalErrors: 0 });
|
|
147
|
+
}, 30_000);
|
|
148
|
+
|
|
149
|
+
test("unavailable compiler is reported, evaluation continues, and scratch files are removed", async () => {
|
|
150
|
+
await fs.writeFile(file, "export default 1;");
|
|
151
|
+
const logs: string[] = [];
|
|
152
|
+
expect(await evaluateChecked({ ...opts(), typecheckDir: path.join(appDir, "missing") }, (s) => logs.push(s), async () => 1)).toBe(1);
|
|
153
|
+
expect(logs.join("")).toContain("[typecheck] skipped:");
|
|
154
|
+
expect(await fs.readdir(path.dirname(file))).toEqual(["snippet.ts"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("shared managed-test checker still reports project diagnostics", async () => {
|
|
158
|
+
await fs.appendFile(projectEntry, '\nconst wrong: number = "bad";');
|
|
159
|
+
const report = await runTypecheck({ appDir, typecheckDir, run });
|
|
160
|
+
expect(report.errors).toContainEqual(expect.objectContaining({ file: "spectest/index.ts", code: "TS2322" }));
|
|
161
|
+
}, 30_000);
|
|
162
|
+
|
|
163
|
+
test.skipIf(!hasWrapperChecker)("managed tests and evals classify the same comparison identically", async () => {
|
|
164
|
+
const body = `${query}\nreturn rows[0].analyzed === true;`;
|
|
165
|
+
await fs.writeFile(file, `export default await (async () => {\n${body}\n})();`);
|
|
166
|
+
const evalReport = await checkEval(opts());
|
|
167
|
+
await fs.mkdir(path.join(appDir, "spectest/tests"));
|
|
168
|
+
await fs.writeFile(path.join(appDir, "spectest/tests/comparison.ts"),
|
|
169
|
+
`import { env } from "../index";\nenv.test("comparison", async (ctx) => {\n${body}\n});`);
|
|
170
|
+
const suiteReport = await runTypecheck({ appDir, projectEntry, typecheckDir, run });
|
|
171
|
+
const findings = (report: typeof evalReport) => report.errors
|
|
172
|
+
.filter((e) => e.code === "TS2367" || e.code === "SPECTEST2002")
|
|
173
|
+
.map(({ code, message }) => ({ code, message }));
|
|
174
|
+
expect(findings(evalReport)).toEqual(findings(suiteReport));
|
|
175
|
+
expect(evalReport.blocking?.map((e) => e.code)).toEqual(["SPECTEST2002"]);
|
|
176
|
+
expect(suiteReport.blocking?.map((e) => e.code)).toEqual(["SPECTEST2002"]);
|
|
177
|
+
}, 60_000);
|
|
178
|
+
|
|
179
|
+
test("eval and tests honor the same project strictness", async () => {
|
|
180
|
+
await fs.writeFile(path.join(appDir, "spectest/tsconfig.json"), JSON.stringify({
|
|
181
|
+
compilerOptions: { strict: false, skipLibCheck: true, target: "esnext", module: "esnext", moduleResolution: "bundler" },
|
|
182
|
+
}));
|
|
183
|
+
await fs.writeFile(file, 'const value: number = null; export default value;');
|
|
184
|
+
await fs.writeFile(path.join(appDir, "spectest/null.ts"), 'const value: number = null; export default value;');
|
|
185
|
+
const evalReport = await checkEval(opts());
|
|
186
|
+
const suiteReport = await runTypecheck({ appDir, projectEntry, typecheckDir, run });
|
|
187
|
+
expect(evalReport.errors.some((d) => d.code === "TS2322")).toBe(false);
|
|
188
|
+
expect(suiteReport.errors.some((d) => d.code === "TS2322")).toBe(false);
|
|
189
|
+
expect(evalReport.blocking).toBeUndefined();
|
|
190
|
+
expect(suiteReport.blocking).toBeUndefined();
|
|
191
|
+
}, 30_000);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { generatedTsconfig, projectTypecheckConfig, runTypecheck, type TypecheckOptions, type TypecheckReport } from "./typecheck.js";
|
|
4
|
+
|
|
5
|
+
interface EvalTypecheckOptions extends Pick<TypecheckOptions, "appDir" | "typecheckDir" | "run"> {
|
|
6
|
+
file: string;
|
|
7
|
+
projectEntry?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Describe the globals installed by evalCode, without importing/executing the project. */
|
|
11
|
+
function globals(projectEntry?: string): string {
|
|
12
|
+
return `import type * as SDK from "@specific.dev/spectest";
|
|
13
|
+
${projectEntry ? `import type project from ${JSON.stringify(projectEntry)};` : ""}
|
|
14
|
+
type EvalContext = ${projectEntry
|
|
15
|
+
? "typeof project extends SDK.Project<infer S, infer F> ? SDK.TestContext<undefined, S, F> : SDK.TestContext<undefined>"
|
|
16
|
+
: "SDK.TestContext<undefined>"};
|
|
17
|
+
declare global {
|
|
18
|
+
var ctx: EvalContext;
|
|
19
|
+
var expect: typeof SDK.expect;
|
|
20
|
+
var expectRaw: typeof SDK.expectRaw;
|
|
21
|
+
var assert: typeof SDK.assert;
|
|
22
|
+
var state: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function checkEval(opts: EvalTypecheckOptions): Promise<TypecheckReport> {
|
|
28
|
+
const started = Date.now();
|
|
29
|
+
let scratch: string | undefined;
|
|
30
|
+
try {
|
|
31
|
+
scratch = await fs.mkdtemp(path.join(path.dirname(opts.file), "check-"));
|
|
32
|
+
const declarations = path.join(scratch, "globals.d.ts");
|
|
33
|
+
await fs.writeFile(declarations, globals(opts.projectEntry));
|
|
34
|
+
const projectConfig = projectTypecheckConfig(opts);
|
|
35
|
+
const defaults = JSON.parse(generatedTsconfig(opts.appDir, opts.typecheckDir));
|
|
36
|
+
const config = {
|
|
37
|
+
...(projectConfig ? { extends: projectConfig } : defaults),
|
|
38
|
+
compilerOptions: {
|
|
39
|
+
...(!projectConfig ? defaults.compilerOptions : {}),
|
|
40
|
+
// Bun runs eval as ESM TypeScript, regardless of the app's build output.
|
|
41
|
+
noEmit: true,
|
|
42
|
+
skipLibCheck: true,
|
|
43
|
+
target: "esnext",
|
|
44
|
+
module: "esnext",
|
|
45
|
+
moduleResolution: "bundler",
|
|
46
|
+
moduleDetection: "force",
|
|
47
|
+
allowImportingTsExtensions: true,
|
|
48
|
+
composite: false,
|
|
49
|
+
incremental: false,
|
|
50
|
+
rootDir: opts.appDir,
|
|
51
|
+
},
|
|
52
|
+
// Project includes/excludes may omit hidden files or include the entire
|
|
53
|
+
// application. Check this snippet, with imports resolved normally.
|
|
54
|
+
files: [opts.file, declarations],
|
|
55
|
+
include: [],
|
|
56
|
+
exclude: [],
|
|
57
|
+
};
|
|
58
|
+
const configFile = path.join(scratch, "tsconfig.json");
|
|
59
|
+
await fs.writeFile(configFile, JSON.stringify(config));
|
|
60
|
+
const report = await runTypecheck({ ...opts, configFile, diagnosticFile: opts.file });
|
|
61
|
+
// Stable, snippet-relative locations; don't expose random temporary paths.
|
|
62
|
+
const normalize = (d: TypecheckReport["errors"][number]) => ({ ...d, file: "eval.ts" });
|
|
63
|
+
return {
|
|
64
|
+
...report,
|
|
65
|
+
errors: report.errors.map(normalize),
|
|
66
|
+
...(report.blocking ? { blocking: report.blocking.map(normalize) } : {}),
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Match managed tests: unavailable/broken checkers report their failure,
|
|
70
|
+
// while only a definite blocking finding prevents execution.
|
|
71
|
+
return {
|
|
72
|
+
status: "failed", errors: [], totalErrors: 0, durationMs: Date.now() - started,
|
|
73
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
74
|
+
};
|
|
75
|
+
} finally {
|
|
76
|
+
if (scratch) await fs.rm(scratch, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The same advisory/blocking policy as a managed test, before module import. */
|
|
81
|
+
export async function evaluateChecked<T>(
|
|
82
|
+
opts: EvalTypecheckOptions,
|
|
83
|
+
log: (text: string) => void,
|
|
84
|
+
evaluate: () => Promise<T>,
|
|
85
|
+
): Promise<T> {
|
|
86
|
+
const report = await checkEval(opts);
|
|
87
|
+
if (report.status === "failed" || report.status === "skipped") {
|
|
88
|
+
log(`[typecheck] ${report.status}: ${report.detail ?? "unavailable"}\n`);
|
|
89
|
+
} else if (report.totalErrors > 0) {
|
|
90
|
+
log(`[typecheck] ${report.totalErrors} diagnostic(s)${report.blocking?.length ? "" : " (advisory)"}\n`);
|
|
91
|
+
for (const d of report.errors) log(`${d.file}:${d.line}:${d.column}: ${d.code}: ${d.message}\n`);
|
|
92
|
+
if (report.totalErrors > report.errors.length) {
|
|
93
|
+
log(`[typecheck] ${report.totalErrors - report.errors.length} more diagnostic(s)\n`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (report.blocking?.length) {
|
|
97
|
+
throw new Error(`Typecheck: ${report.blocking.length} blocking error(s) in eval — the snippet did not run. Fix the diagnostics above before retrying.`);
|
|
98
|
+
}
|
|
99
|
+
return evaluate();
|
|
100
|
+
}
|
|
@@ -3,6 +3,7 @@ import { expect as assertValue } from "../index.js";
|
|
|
3
3
|
import { readRaw, readTag, type SpectestFetch, type WrappedResponse } from "../inspect.js";
|
|
4
4
|
import { recordFake, startRecording, stopRecording, type HttpEvent } from "../recorder.js";
|
|
5
5
|
import { installFetchWrapper, isTransportError } from "./fetch.js";
|
|
6
|
+
import { startServices } from "./service-graph.js";
|
|
6
7
|
import {
|
|
7
8
|
bindInstrumentationScope,
|
|
8
9
|
createInstrumentationScope,
|
|
@@ -356,4 +357,31 @@ describe("fetch execution scopes", () => {
|
|
|
356
357
|
setup.active = false;
|
|
357
358
|
}
|
|
358
359
|
});
|
|
360
|
+
|
|
361
|
+
test("parallel service setup scopes keep wrapping after a sibling fails", async () => {
|
|
362
|
+
const gate = deferred();
|
|
363
|
+
const slowStarted = deferred();
|
|
364
|
+
const setup = startServices([{ name: "bad" }, { name: "slow" }], async ({ name }) => {
|
|
365
|
+
const hookScope = createInstrumentationScope(false);
|
|
366
|
+
try {
|
|
367
|
+
await runInstrumented(hookScope, async () => {
|
|
368
|
+
if (name === "bad") { await slowStarted.promise; throw new Error("failed hook"); }
|
|
369
|
+
slowStarted.resolve();
|
|
370
|
+
await gate.promise;
|
|
371
|
+
const res = await fetch(url("/service-setup")) as unknown as WrappedResponse;
|
|
372
|
+
expect(res.status.unwrap()).toBe(200);
|
|
373
|
+
expect((await res.json<{ value: number }>()).value.unwrap()).toBe(42);
|
|
374
|
+
await runUninstrumented(async () => {
|
|
375
|
+
expect((await fetch(url("/fake"))).status).toBe(200);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
} finally { hookScope.active = false; }
|
|
379
|
+
});
|
|
380
|
+
const outcome = setup.catch(error => error);
|
|
381
|
+
await slowStarted.promise;
|
|
382
|
+
expect((await fetch(url("/background"))).status).toBe(200);
|
|
383
|
+
gate.resolve();
|
|
384
|
+
expect((await outcome).message).toBe("failed hook");
|
|
385
|
+
expect(stopRecording()).toEqual([]);
|
|
386
|
+
});
|
|
359
387
|
});
|
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { topologicalOrder, validateServiceGraph } from "./service-graph";
|
|
3
|
+
import { startServices, topologicalOrder, validateServiceGraph } from "./service-graph";
|
|
4
4
|
|
|
5
5
|
const svc = (name: string, dependsOn?: string[]) => ({ name, dependsOn });
|
|
6
6
|
|
|
7
|
+
test("bootstrap waits for sibling hooks after a failure and never starts failed dependents", async () => {
|
|
8
|
+
let release!: () => void;
|
|
9
|
+
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
10
|
+
const started: string[] = [];
|
|
11
|
+
let completed = false;
|
|
12
|
+
let settled = false;
|
|
13
|
+
const failed = new Error("setup failed");
|
|
14
|
+
const result = startServices([svc("bad"), svc("slow"), svc("child", ["bad"])], async ({ name }) => {
|
|
15
|
+
started.push(name);
|
|
16
|
+
if (name === "bad") throw failed;
|
|
17
|
+
if (name === "slow") { await gate; completed = true; }
|
|
18
|
+
}).then(() => { settled = true; return undefined; }, error => { settled = true; return error; });
|
|
19
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
20
|
+
expect(started).toEqual(["bad", "slow"]);
|
|
21
|
+
expect(settled).toBe(false);
|
|
22
|
+
release();
|
|
23
|
+
const error = await result;
|
|
24
|
+
expect(completed).toBe(true);
|
|
25
|
+
expect(error instanceof AggregateError ? error.errors : [error]).toContain(failed);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("shared dependencies complete setup once before either dependent starts", async () => {
|
|
29
|
+
const started: string[] = [];
|
|
30
|
+
await startServices([svc("a", ["db"]), svc("b", ["db"]), svc("db")], async ({ name }) => {
|
|
31
|
+
await Promise.resolve();
|
|
32
|
+
started.push(name);
|
|
33
|
+
});
|
|
34
|
+
expect(started).toEqual(["db", "a", "b"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
7
37
|
describe("validateServiceGraph", () => {
|
|
8
38
|
test("returns a name→service map for a clean graph", () => {
|
|
9
39
|
const map = validateServiceGraph([svc("db"), svc("api", ["db"])]);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { waitForAll } from "./concurrency.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The `dependsOn` graph: validation, and the traversal order bring-up
|
|
3
5
|
* uses.
|
|
@@ -95,3 +97,24 @@ export function topologicalOrder<T extends GraphNode>(services: readonly T[]): T
|
|
|
95
97
|
for (const s of services) visit(s.name);
|
|
96
98
|
return out;
|
|
97
99
|
}
|
|
100
|
+
|
|
101
|
+
/** Start each service after its dependencies finish setup. Drain all branches
|
|
102
|
+
* before reporting failure so teardown cannot race containers or hooks. */
|
|
103
|
+
export async function startServices<T extends GraphNode>(
|
|
104
|
+
services: readonly T[],
|
|
105
|
+
startOne: (svc: T) => Promise<void>,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const byName = validateServiceGraph(services);
|
|
108
|
+
const started = new Map<string, Promise<void>>();
|
|
109
|
+
const start = (svc: T): Promise<void> => {
|
|
110
|
+
const existing = started.get(svc.name);
|
|
111
|
+
if (existing) return existing;
|
|
112
|
+
const pending = (async () => {
|
|
113
|
+
await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name)!)));
|
|
114
|
+
await startOne(svc);
|
|
115
|
+
})();
|
|
116
|
+
started.set(svc.name, pending);
|
|
117
|
+
return pending;
|
|
118
|
+
};
|
|
119
|
+
await waitForAll(services.map(start));
|
|
120
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runWrapperRules, type WrapperDiagnostic } from "./wrapper-rules.js";
|
|
5
|
+
|
|
6
|
+
export interface TypecheckOptions {
|
|
7
|
+
appDir: string;
|
|
8
|
+
typecheckDir: string;
|
|
9
|
+
projectEntry?: string;
|
|
10
|
+
run: (file: string, args: string[], timeoutMs: number) => Promise<{ code: number; stdout: string; stderr: string }>;
|
|
11
|
+
configFile?: string;
|
|
12
|
+
/** Eval reports only the submitted snippet, not unrelated imported project code. */
|
|
13
|
+
diagnosticFile?: string;
|
|
14
|
+
extraDiagnostics?: () => Promise<WrapperDiagnostic[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Both entry points honor the same project config before generating defaults. */
|
|
18
|
+
export function projectTypecheckConfig(opts: Pick<TypecheckOptions, "appDir" | "projectEntry">): string | undefined {
|
|
19
|
+
return [
|
|
20
|
+
path.join(opts.appDir, "tsconfig.json"),
|
|
21
|
+
...(opts.projectEntry ? [path.join(path.dirname(opts.projectEntry), "tsconfig.json")] : []),
|
|
22
|
+
].find(existsSync);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
26
|
+
const TYPECHECK_ERROR_CAP = 50;
|
|
27
|
+
|
|
28
|
+
export interface TypecheckError {
|
|
29
|
+
/** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
|
|
30
|
+
file: string;
|
|
31
|
+
line: number;
|
|
32
|
+
column: number;
|
|
33
|
+
/** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TypecheckReport {
|
|
39
|
+
status: "ok" | "errors" | "skipped" | "failed";
|
|
40
|
+
errors: TypecheckError[];
|
|
41
|
+
totalErrors: number;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
/** Why the check was skipped / how it failed. */
|
|
44
|
+
detail?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Findings that FAIL the run, as opposed to `errors`, which only advise.
|
|
47
|
+
* A diagnostic qualifies only when it is true of the program that runs
|
|
48
|
+
* rather than of the declared types alone — see `harness/wrapper-rules.ts`
|
|
49
|
+
* for why no `tsc` code can be in here and why these can.
|
|
50
|
+
*
|
|
51
|
+
* The list is the rollout mechanism as well as the payload: an older SDK
|
|
52
|
+
* never sends the field, `#[serde(default)]` leaves it empty on the server,
|
|
53
|
+
* and the gate is inert for every project that has not moved to a version
|
|
54
|
+
* carrying it. The version a project runs is already its own choice
|
|
55
|
+
* (`sdk.rs` honours the declared lower bound), so the bump is the consent.
|
|
56
|
+
*/
|
|
57
|
+
blocking?: TypecheckError[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The tsconfig used when the project doesn't ship its own. `lib` is left to
|
|
61
|
+
* the target default (which includes DOM — browser.evaluate callbacks
|
|
62
|
+
* reference `document`); `types` pulls Bun's ambient globals from the baked
|
|
63
|
+
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
64
|
+
export function generatedTsconfig(appDir: string, typecheckDir: string): string {
|
|
65
|
+
return JSON.stringify(
|
|
66
|
+
{
|
|
67
|
+
compilerOptions: {
|
|
68
|
+
target: "esnext",
|
|
69
|
+
module: "esnext",
|
|
70
|
+
moduleResolution: "bundler",
|
|
71
|
+
strict: true,
|
|
72
|
+
noEmit: true,
|
|
73
|
+
skipLibCheck: true,
|
|
74
|
+
esModuleInterop: true,
|
|
75
|
+
resolveJsonModule: true,
|
|
76
|
+
jsx: "react-jsx",
|
|
77
|
+
types: ["bun"],
|
|
78
|
+
typeRoots: [
|
|
79
|
+
path.join(appDir, "node_modules", "@types"),
|
|
80
|
+
path.join(typecheckDir, "node_modules", "@types"),
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
84
|
+
exclude: ["node_modules", ".spectest-eval"],
|
|
85
|
+
},
|
|
86
|
+
null,
|
|
87
|
+
2,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
92
|
+
* with indented elaboration lines folded into the preceding error. */
|
|
93
|
+
function parseTscOutput(output: string, appDir: string, diagnosticFile?: string): {
|
|
94
|
+
errors: TypecheckError[];
|
|
95
|
+
total: number;
|
|
96
|
+
suppressed: number;
|
|
97
|
+
} {
|
|
98
|
+
const errors: TypecheckError[] = [];
|
|
99
|
+
let total = 0;
|
|
100
|
+
let suppressed = 0;
|
|
101
|
+
let last: TypecheckError | null = null;
|
|
102
|
+
for (const line of output.split("\n")) {
|
|
103
|
+
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
104
|
+
if (!m) {
|
|
105
|
+
// Elaboration lines are indented; fold them into the last kept error.
|
|
106
|
+
if (last && /^\s+\S/.test(line)) last.message += `\n${line.trimEnd()}`;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const [, rawFile, ln, col, code, message] = m;
|
|
110
|
+
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
111
|
+
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
112
|
+
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
113
|
+
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
114
|
+
// user's to read.
|
|
115
|
+
const abs = path.resolve(appDir, rawFile!);
|
|
116
|
+
const inApp = abs.startsWith(appDir + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
117
|
+
if (!inApp || (diagnosticFile && abs !== diagnosticFile)) {
|
|
118
|
+
suppressed += 1;
|
|
119
|
+
last = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
total += 1;
|
|
123
|
+
const err: TypecheckError = {
|
|
124
|
+
file: path.relative(appDir, abs),
|
|
125
|
+
line: Number(ln),
|
|
126
|
+
column: Number(col),
|
|
127
|
+
code: code!,
|
|
128
|
+
message: message!,
|
|
129
|
+
};
|
|
130
|
+
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
131
|
+
errors.push(err);
|
|
132
|
+
last = err;
|
|
133
|
+
} else {
|
|
134
|
+
last = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { errors, total, suppressed };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function runTypecheck(opts: TypecheckOptions): Promise<TypecheckReport> {
|
|
141
|
+
const { appDir, typecheckDir, run } = opts;
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
// The project's own typescript wins over the baked copy.
|
|
144
|
+
const tsc = [
|
|
145
|
+
path.join(appDir, "node_modules", "typescript", "bin", "tsc"),
|
|
146
|
+
path.join(typecheckDir, "node_modules", "typescript", "bin", "tsc"),
|
|
147
|
+
].find((p) => existsSync(p));
|
|
148
|
+
if (!tsc) {
|
|
149
|
+
return {
|
|
150
|
+
status: "skipped",
|
|
151
|
+
errors: [],
|
|
152
|
+
totalErrors: 0,
|
|
153
|
+
durationMs: 0,
|
|
154
|
+
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
let config = opts.configFile ?? projectTypecheckConfig(opts);
|
|
158
|
+
if (!config) {
|
|
159
|
+
config = path.join(appDir, ".spectest-tsconfig.json");
|
|
160
|
+
await fs.writeFile(config, generatedTsconfig(appDir, typecheckDir));
|
|
161
|
+
}
|
|
162
|
+
const res = await run(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
163
|
+
const durationMs = Date.now() - started;
|
|
164
|
+
if (res.code === 124) {
|
|
165
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
166
|
+
}
|
|
167
|
+
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr, appDir, opts.diagnosticFile);
|
|
168
|
+
const wrapper = [...(await runWrapperRulesReport(config, opts)), ...(await opts.extraDiagnostics?.() ?? [])];
|
|
169
|
+
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
170
|
+
file,
|
|
171
|
+
line,
|
|
172
|
+
column,
|
|
173
|
+
code,
|
|
174
|
+
message,
|
|
175
|
+
});
|
|
176
|
+
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
177
|
+
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
178
|
+
if (errors.length === 0 && wrapper.length === 0) {
|
|
179
|
+
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
180
|
+
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
181
|
+
// crash (config not found, OOM) — surface the latter.
|
|
182
|
+
if (res.code !== 0 && suppressed === 0) {
|
|
183
|
+
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
184
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
185
|
+
}
|
|
186
|
+
if (suppressed > 0) {
|
|
187
|
+
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
188
|
+
}
|
|
189
|
+
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
190
|
+
}
|
|
191
|
+
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
192
|
+
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
193
|
+
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
194
|
+
// `errors` is capped and the entries that stopped the run must never be the
|
|
195
|
+
// ones the cap drops.
|
|
196
|
+
const all = [...blocking, ...advisory, ...errors];
|
|
197
|
+
return {
|
|
198
|
+
status: "errors",
|
|
199
|
+
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
200
|
+
totalErrors: total + wrapper.length,
|
|
201
|
+
durationMs,
|
|
202
|
+
...(blocking.length > 0 ? { blocking } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The blocking rules, run against the BAKED compiler whatever the project
|
|
208
|
+
* pins. Best-effort in every direction: an install that predates the API, a
|
|
209
|
+
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
210
|
+
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
211
|
+
* definite finding can stop one.
|
|
212
|
+
*/
|
|
213
|
+
async function runWrapperRulesReport(config: string, opts: TypecheckOptions): Promise<WrapperDiagnostic[]> {
|
|
214
|
+
const { appDir, typecheckDir } = opts;
|
|
215
|
+
const typescriptDir = path.join(typecheckDir, "node_modules", "typescript");
|
|
216
|
+
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
|
|
217
|
+
try {
|
|
218
|
+
const run = await runWrapperRules({
|
|
219
|
+
typescriptDir,
|
|
220
|
+
configFile: config,
|
|
221
|
+
appDir,
|
|
222
|
+
diagnosticFile: opts.diagnosticFile,
|
|
223
|
+
readFile: (f) => fs.readFile(f, "utf8"),
|
|
224
|
+
relative: path.relative,
|
|
225
|
+
join: path.join,
|
|
226
|
+
dirname: path.dirname,
|
|
227
|
+
});
|
|
228
|
+
if (run.status !== "ok") {
|
|
229
|
+
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return run.diagnostics;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|