@specific.dev/spectest 0.91.1 → 0.92.0
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/components/k3s.d.ts +2 -0
- package/dist/components/k3s.js +27 -2
- package/dist/daemon.js +9 -172
- package/dist/harness/eval-typecheck.d.ts +9 -0
- package/dist/harness/eval-typecheck.js +93 -0
- 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/package.json +1 -1
- package/src/components/k3s-apply.test.ts +90 -0
- package/src/components/k3s.ts +21 -2
- package/src/daemon.ts +13 -212
- package/src/harness/eval-typecheck.test.ts +163 -0
- package/src/harness/eval-typecheck.ts +100 -0
- package/src/harness/typecheck.ts +237 -0
- package/src/harness/wrapper-rules.ts +12 -7
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { KubernetesObjectApi } from "@kubernetes/client-node";
|
|
3
|
+
import { ApiException } from "@kubernetes/client-node/dist/gen/apis/exception.js";
|
|
4
|
+
import { isTransientApplyError, k3s } from "./k3s";
|
|
5
|
+
|
|
6
|
+
const missingNamespace = {
|
|
7
|
+
apiVersion: "v1", kind: "Status", status: "Failure", code: 404, reason: "NotFound",
|
|
8
|
+
message: 'namespaces "agent-sandbox-system" not found',
|
|
9
|
+
details: { name: "agent-sandbox-system", kind: "namespaces" },
|
|
10
|
+
};
|
|
11
|
+
const exception = (code: number, body: unknown) => new ApiException(code, "Unknown API Status Code!", body, {});
|
|
12
|
+
|
|
13
|
+
for (const format of ["object", "JSON string"] as const) {
|
|
14
|
+
const encode = (body: unknown) => format === "object" ? body : JSON.stringify(body);
|
|
15
|
+
test(`missing-namespace ApiException with ${format} body is transient`, () => {
|
|
16
|
+
const error = exception(404, encode(missingNamespace));
|
|
17
|
+
// This is the production client's escaped representation, not a hand-made Error.
|
|
18
|
+
expect(/namespaces? "[^"]+" not found/i.test(error.message)).toBe(false);
|
|
19
|
+
expect(isTransientApplyError(error)).toBe(true);
|
|
20
|
+
expect(isTransientApplyError(exception(404, encode({ ...missingNamespace, message: "Not Found" })))).toBe(true);
|
|
21
|
+
// The Status message also works when structured details are absent.
|
|
22
|
+
expect(isTransientApplyError(exception(404, encode({ ...missingNamespace, details: undefined })))).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test(`apply converges after a missing namespace with ${format} response body`, async () => {
|
|
26
|
+
const error = exception(404, encode(missingNamespace));
|
|
27
|
+
let attempts = 0;
|
|
28
|
+
const patch = spyOn(KubernetesObjectApi.prototype, "patch").mockImplementation(async doc => {
|
|
29
|
+
if (++attempts === 1) throw error;
|
|
30
|
+
return doc as never;
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const helpers = await makeHelpers();
|
|
34
|
+
const applied = await helpers.apply(JSON.stringify(policy), { timeoutMs: 2000 });
|
|
35
|
+
expect(attempts).toBe(2);
|
|
36
|
+
expect(applied[0].unwrap()).toEqual(policy);
|
|
37
|
+
} finally { patch.mockRestore(); }
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test(`unrelated 404, forbidden and invalid Status bodies remain terminal (${format})`, () => {
|
|
41
|
+
for (const body of [
|
|
42
|
+
{ ...missingNamespace, message: 'deployments.apps "controller" not found', details: { name: "controller", kind: "deployments", group: "apps" } },
|
|
43
|
+
{ ...missingNamespace, message: 'namespaces.example.dev "widget" not found', details: { name: "widget", kind: "namespaces", group: "example.dev" } },
|
|
44
|
+
{ code: 404, reason: "NotFound", message: "Not Found" },
|
|
45
|
+
{ code: 403, reason: "Forbidden", message: 'namespaces "agent-sandbox-system" is forbidden', details: missingNamespace.details },
|
|
46
|
+
{ code: 422, reason: "Invalid", message: 'NetworkPolicy "policy" is invalid', details: { causes: [{ message: missingNamespace.message }] } },
|
|
47
|
+
]) expect(isTransientApplyError(exception(body.code, encode(body)))).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("plain client errors and transient HTTP statuses retain their retry behavior", () => {
|
|
52
|
+
for (const code of [429, 500, 503, 504]) {
|
|
53
|
+
expect(isTransientApplyError(exception(code, { message: "Try again" }))).toBe(true);
|
|
54
|
+
}
|
|
55
|
+
for (const message of [missingNamespace.message, 'no matches for kind "Widget"', "Failed to fetch resource metadata", "failed calling webhook", "HTTP-Code: 503\nMessage: unavailable"]) {
|
|
56
|
+
expect(isTransientApplyError(new Error(message))).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
for (const body of ["not JSON", "{", null, [], 42]) {
|
|
59
|
+
expect(isTransientApplyError(exception(404, body))).toBe(false);
|
|
60
|
+
}
|
|
61
|
+
expect(isTransientApplyError(null)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const policy = {
|
|
65
|
+
apiVersion: "networking.k8s.io/v1", kind: "NetworkPolicy",
|
|
66
|
+
metadata: { name: "policy", namespace: "agent-sandbox-system" },
|
|
67
|
+
spec: { podSelector: {}, policyTypes: ["Ingress"] },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function makeHelpers() {
|
|
71
|
+
return k3s().helpers({ name: "cluster", exec: async () => ({ exitCode: 0, stderr: "", stdout: JSON.stringify({
|
|
72
|
+
apiVersion: "v1", kind: "Config", clusters: [{ name: "test", cluster: { server: "https://cluster.internal:6443" } }],
|
|
73
|
+
users: [{ name: "test", user: {} }], contexts: [{ name: "test", context: { cluster: "test", user: "test" } }],
|
|
74
|
+
"current-context": "test",
|
|
75
|
+
}) }) } as never);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("apply fails immediately on an unrelated 404 and still times out on a missing namespace", async () => {
|
|
79
|
+
const helpers = await makeHelpers();
|
|
80
|
+
const patch = spyOn(KubernetesObjectApi.prototype, "patch");
|
|
81
|
+
try {
|
|
82
|
+
patch.mockRejectedValue(exception(404, { reason: "NotFound", message: 'deployments.apps "controller" not found' }));
|
|
83
|
+
await expect(helpers.apply(JSON.stringify(policy))).rejects.toThrow("applying NetworkPolicy/policy");
|
|
84
|
+
expect(patch).toHaveBeenCalledTimes(1);
|
|
85
|
+
patch.mockClear();
|
|
86
|
+
patch.mockRejectedValue(exception(404, missingNamespace));
|
|
87
|
+
await expect(helpers.apply(JSON.stringify(policy), { timeoutMs: 0 })).rejects.toThrow("apply did not converge");
|
|
88
|
+
expect(patch).toHaveBeenCalledTimes(1);
|
|
89
|
+
} finally { patch.mockRestore(); }
|
|
90
|
+
});
|
package/src/components/k3s.ts
CHANGED
|
@@ -1421,8 +1421,27 @@ function errorText(err: unknown): string {
|
|
|
1421
1421
|
return (err as Error)?.message ?? String(err);
|
|
1422
1422
|
}
|
|
1423
1423
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1424
|
+
/** ApiException formats its body with JSON.stringify, escaping Status.message.
|
|
1425
|
+
* Read the actual response instead of matching that presentation string. */
|
|
1426
|
+
function applyErrorStatus(err: unknown): V1Status | undefined {
|
|
1427
|
+
let body = (err as { body?: unknown } | null)?.body;
|
|
1428
|
+
if (typeof body === "string") {
|
|
1429
|
+
try { body = JSON.parse(body); } catch { return undefined; }
|
|
1430
|
+
}
|
|
1431
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body as V1Status : undefined;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** @internal Exported for regression tests; not part of the components API. */
|
|
1435
|
+
export function isTransientApplyError(err: unknown): boolean {
|
|
1436
|
+
const status = applyErrorStatus(err);
|
|
1437
|
+
const code = status?.code ?? (err as { code?: unknown } | null)?.code;
|
|
1438
|
+
if ([429, 500, 503, 504].includes(code as number)) return true;
|
|
1439
|
+
// A missing namespace can converge while another apply creates it. An
|
|
1440
|
+
// arbitrary resource 404 cannot: don't classify by HTTP status alone.
|
|
1441
|
+
if (code === 404 && status?.reason === "NotFound" &&
|
|
1442
|
+
status.details?.kind === "namespaces" && !status.details.group &&
|
|
1443
|
+
typeof status.details.name === "string" && status.details.name.length > 0) return true;
|
|
1444
|
+
const msg = typeof status?.message === "string" ? status.message : errorText(err);
|
|
1426
1445
|
return TRANSIENT_APPLY_PATTERNS.some((re) => re.test(msg));
|
|
1427
1446
|
}
|
|
1428
1447
|
|
package/src/daemon.ts
CHANGED
|
@@ -103,7 +103,9 @@ import {
|
|
|
103
103
|
sanitizeSegment,
|
|
104
104
|
} from "./harness/volume-paths.js";
|
|
105
105
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
106
|
-
import { fsUsageDiagnostics
|
|
106
|
+
import { fsUsageDiagnostics } from "./harness/wrapper-rules.js";
|
|
107
|
+
import { runTypecheck, type TypecheckReport } from "./harness/typecheck.js";
|
|
108
|
+
import { evaluateChecked } from "./harness/eval-typecheck.js";
|
|
107
109
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
108
110
|
import { cpus, tmpdir } from "node:os";
|
|
109
111
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
@@ -6246,7 +6248,12 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6246
6248
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
6247
6249
|
await fs.writeFile(filePath, prepared.code);
|
|
6248
6250
|
const moduleUrl = pathToFileURL(filePath).href;
|
|
6249
|
-
const mod =
|
|
6251
|
+
const mod = await evaluateChecked(
|
|
6252
|
+
{ file: filePath, appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
6253
|
+
projectEntry: loaded ? resolveEntry() : undefined },
|
|
6254
|
+
(text) => { chunks.push(text); },
|
|
6255
|
+
() => runInstrumented(fetchScope, () => import(moduleUrl)) as Promise<{ default?: unknown }>,
|
|
6256
|
+
);
|
|
6250
6257
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
6251
6258
|
} catch (err) {
|
|
6252
6259
|
const e = err as Error;
|
|
@@ -6327,46 +6334,11 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6327
6334
|
* blocking rules, which always use THIS copy rather than the project's own —
|
|
6328
6335
|
* can be driven outside a VM. */
|
|
6329
6336
|
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
6330
|
-
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
6331
|
-
const TYPECHECK_ERROR_CAP = 50;
|
|
6332
|
-
|
|
6333
|
-
interface TypecheckError {
|
|
6334
|
-
/** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
|
|
6335
|
-
file: string;
|
|
6336
|
-
line: number;
|
|
6337
|
-
column: number;
|
|
6338
|
-
/** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
|
|
6339
|
-
code: string;
|
|
6340
|
-
message: string;
|
|
6341
|
-
}
|
|
6342
|
-
|
|
6343
|
-
interface TypecheckReport {
|
|
6344
|
-
status: "ok" | "errors" | "skipped" | "failed";
|
|
6345
|
-
errors: TypecheckError[];
|
|
6346
|
-
totalErrors: number;
|
|
6347
|
-
durationMs: number;
|
|
6348
|
-
/** Why the check was skipped / how it failed. */
|
|
6349
|
-
detail?: string;
|
|
6350
|
-
/**
|
|
6351
|
-
* Findings that FAIL the run, as opposed to `errors`, which only advise.
|
|
6352
|
-
* A diagnostic qualifies only when it is true of the program that runs
|
|
6353
|
-
* rather than of the declared types alone — see `harness/wrapper-rules.ts`
|
|
6354
|
-
* for why no `tsc` code can be in here and why these can.
|
|
6355
|
-
*
|
|
6356
|
-
* The list is the rollout mechanism as well as the payload: an older SDK
|
|
6357
|
-
* never sends the field, `#[serde(default)]` leaves it empty on the server,
|
|
6358
|
-
* and the gate is inert for every project that has not moved to a version
|
|
6359
|
-
* carrying it. The version a project runs is already its own choice
|
|
6360
|
-
* (`sdk.rs` honours the declared lower bound), so the bump is the consent.
|
|
6361
|
-
*/
|
|
6362
|
-
blocking?: TypecheckError[];
|
|
6363
|
-
}
|
|
6364
|
-
|
|
6365
6337
|
let TYPECHECK: Promise<TypecheckReport> | null = null;
|
|
6366
6338
|
|
|
6367
6339
|
/** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
|
|
6368
6340
|
function startTypecheck(): void {
|
|
6369
|
-
TYPECHECK =
|
|
6341
|
+
TYPECHECK = runProjectTypecheck().catch((err) => ({
|
|
6370
6342
|
status: "failed" as const,
|
|
6371
6343
|
errors: [],
|
|
6372
6344
|
totalErrors: 0,
|
|
@@ -6377,149 +6349,9 @@ function startTypecheck(): void {
|
|
|
6377
6349
|
TYPECHECK.catch(() => {});
|
|
6378
6350
|
}
|
|
6379
6351
|
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
6384
|
-
function generatedTsconfig(): string {
|
|
6385
|
-
return JSON.stringify(
|
|
6386
|
-
{
|
|
6387
|
-
compilerOptions: {
|
|
6388
|
-
target: "esnext",
|
|
6389
|
-
module: "esnext",
|
|
6390
|
-
moduleResolution: "bundler",
|
|
6391
|
-
strict: true,
|
|
6392
|
-
noEmit: true,
|
|
6393
|
-
skipLibCheck: true,
|
|
6394
|
-
esModuleInterop: true,
|
|
6395
|
-
resolveJsonModule: true,
|
|
6396
|
-
jsx: "react-jsx",
|
|
6397
|
-
types: ["bun"],
|
|
6398
|
-
typeRoots: [
|
|
6399
|
-
path.join(APP_DIR, "node_modules", "@types"),
|
|
6400
|
-
path.join(TYPECHECK_DIR, "node_modules", "@types"),
|
|
6401
|
-
],
|
|
6402
|
-
},
|
|
6403
|
-
include: ["**/*.ts", "**/*.tsx"],
|
|
6404
|
-
exclude: ["node_modules"],
|
|
6405
|
-
},
|
|
6406
|
-
null,
|
|
6407
|
-
2,
|
|
6408
|
-
);
|
|
6409
|
-
}
|
|
6410
|
-
|
|
6411
|
-
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
6412
|
-
* with indented elaboration lines folded into the preceding error. */
|
|
6413
|
-
function parseTscOutput(output: string): {
|
|
6414
|
-
errors: TypecheckError[];
|
|
6415
|
-
total: number;
|
|
6416
|
-
suppressed: number;
|
|
6417
|
-
} {
|
|
6418
|
-
const errors: TypecheckError[] = [];
|
|
6419
|
-
let total = 0;
|
|
6420
|
-
let suppressed = 0;
|
|
6421
|
-
let last: TypecheckError | null = null;
|
|
6422
|
-
for (const line of output.split("\n")) {
|
|
6423
|
-
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
6424
|
-
if (!m) {
|
|
6425
|
-
// Elaboration lines are indented; fold them into the last kept error.
|
|
6426
|
-
if (last && /^\s+\S/.test(line)) last.message += `\n${line.trimEnd()}`;
|
|
6427
|
-
continue;
|
|
6428
|
-
}
|
|
6429
|
-
const [, rawFile, ln, col, code, message] = m;
|
|
6430
|
-
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
6431
|
-
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
6432
|
-
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
6433
|
-
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
6434
|
-
// user's to read.
|
|
6435
|
-
const abs = path.resolve(APP_DIR, rawFile!);
|
|
6436
|
-
const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
6437
|
-
if (!inApp) {
|
|
6438
|
-
suppressed += 1;
|
|
6439
|
-
last = null;
|
|
6440
|
-
continue;
|
|
6441
|
-
}
|
|
6442
|
-
total += 1;
|
|
6443
|
-
const err: TypecheckError = {
|
|
6444
|
-
file: path.relative(APP_DIR, abs),
|
|
6445
|
-
line: Number(ln),
|
|
6446
|
-
column: Number(col),
|
|
6447
|
-
code: code!,
|
|
6448
|
-
message: message!,
|
|
6449
|
-
};
|
|
6450
|
-
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
6451
|
-
errors.push(err);
|
|
6452
|
-
last = err;
|
|
6453
|
-
} else {
|
|
6454
|
-
last = null;
|
|
6455
|
-
}
|
|
6456
|
-
}
|
|
6457
|
-
return { errors, total, suppressed };
|
|
6458
|
-
}
|
|
6459
|
-
|
|
6460
|
-
async function runTypecheck(): Promise<TypecheckReport> {
|
|
6461
|
-
const started = Date.now();
|
|
6462
|
-
// The project's own typescript wins over the baked copy.
|
|
6463
|
-
const tsc = [
|
|
6464
|
-
path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
6465
|
-
path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
6466
|
-
].find((p) => existsSync(p));
|
|
6467
|
-
if (!tsc) {
|
|
6468
|
-
return {
|
|
6469
|
-
status: "skipped",
|
|
6470
|
-
errors: [],
|
|
6471
|
-
totalErrors: 0,
|
|
6472
|
-
durationMs: 0,
|
|
6473
|
-
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
6474
|
-
};
|
|
6475
|
-
}
|
|
6476
|
-
let config = path.join(APP_DIR, "tsconfig.json");
|
|
6477
|
-
if (!existsSync(config)) {
|
|
6478
|
-
config = path.join(APP_DIR, ".spectest-tsconfig.json");
|
|
6479
|
-
await fs.writeFile(config, generatedTsconfig());
|
|
6480
|
-
}
|
|
6481
|
-
const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
6482
|
-
const durationMs = Date.now() - started;
|
|
6483
|
-
if (res.code === 124) {
|
|
6484
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
6485
|
-
}
|
|
6486
|
-
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
6487
|
-
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
6488
|
-
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
6489
|
-
file,
|
|
6490
|
-
line,
|
|
6491
|
-
column,
|
|
6492
|
-
code,
|
|
6493
|
-
message,
|
|
6494
|
-
});
|
|
6495
|
-
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
6496
|
-
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
6497
|
-
if (errors.length === 0 && wrapper.length === 0) {
|
|
6498
|
-
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
6499
|
-
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
6500
|
-
// crash (config not found, OOM) — surface the latter.
|
|
6501
|
-
if (res.code !== 0 && suppressed === 0) {
|
|
6502
|
-
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
6503
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
6504
|
-
}
|
|
6505
|
-
if (suppressed > 0) {
|
|
6506
|
-
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
6507
|
-
}
|
|
6508
|
-
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
6509
|
-
}
|
|
6510
|
-
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
6511
|
-
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
6512
|
-
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
6513
|
-
// `errors` is capped and the entries that stopped the run must never be the
|
|
6514
|
-
// ones the cap drops.
|
|
6515
|
-
const all = [...blocking, ...advisory, ...errors];
|
|
6516
|
-
return {
|
|
6517
|
-
status: "errors",
|
|
6518
|
-
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
6519
|
-
totalErrors: total + wrapper.length,
|
|
6520
|
-
durationMs,
|
|
6521
|
-
...(blocking.length > 0 ? { blocking } : {}),
|
|
6522
|
-
};
|
|
6352
|
+
async function runProjectTypecheck(): Promise<TypecheckReport> {
|
|
6353
|
+
return runTypecheck({ appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
6354
|
+
projectEntry: loaded ? resolveEntry() : undefined, extraDiagnostics: rawFsReport });
|
|
6523
6355
|
}
|
|
6524
6356
|
|
|
6525
6357
|
/**
|
|
@@ -6545,37 +6377,6 @@ async function rawFsReport(): Promise<WrapperDiagnostic[]> {
|
|
|
6545
6377
|
}
|
|
6546
6378
|
}
|
|
6547
6379
|
|
|
6548
|
-
/**
|
|
6549
|
-
* The blocking rules, run against the BAKED compiler whatever the project
|
|
6550
|
-
* pins. Best-effort in every direction: an install that predates the API, a
|
|
6551
|
-
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
6552
|
-
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
6553
|
-
* definite finding can stop one.
|
|
6554
|
-
*/
|
|
6555
|
-
async function runWrapperRulesReport(config: string): Promise<WrapperDiagnostic[]> {
|
|
6556
|
-
const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
|
|
6557
|
-
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
|
|
6558
|
-
try {
|
|
6559
|
-
const run = await runWrapperRules({
|
|
6560
|
-
typescriptDir,
|
|
6561
|
-
configFile: config,
|
|
6562
|
-
appDir: APP_DIR,
|
|
6563
|
-
readFile: (f) => fs.readFile(f, "utf8"),
|
|
6564
|
-
relative: path.relative,
|
|
6565
|
-
join: path.join,
|
|
6566
|
-
dirname: path.dirname,
|
|
6567
|
-
});
|
|
6568
|
-
if (run.status !== "ok") {
|
|
6569
|
-
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
6570
|
-
return [];
|
|
6571
|
-
}
|
|
6572
|
-
return run.diagnostics;
|
|
6573
|
-
} catch (err) {
|
|
6574
|
-
console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
|
|
6575
|
-
return [];
|
|
6576
|
-
}
|
|
6577
|
-
}
|
|
6578
|
-
|
|
6579
6380
|
// ────────────────────────────────────────────────────────────────────────
|
|
6580
6381
|
// HTTP server
|
|
6581
6382
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,163 @@
|
|
|
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("eval receives the default-exported project's service/fake types and globals", async () => {
|
|
54
|
+
await fs.writeFile(file, `${query}
|
|
55
|
+
const ready: boolean = rows[0].analyzed.unwrap();
|
|
56
|
+
const count: number = ctx.fakes.sample.count().unwrap();
|
|
57
|
+
expect(rows[0].analyzed).toBe(true);
|
|
58
|
+
expectRaw(count, "count").toBe(3);
|
|
59
|
+
assert(ready);
|
|
60
|
+
state.saved = ready;
|
|
61
|
+
export default ready;
|
|
62
|
+
`);
|
|
63
|
+
const report = await checkEval(opts());
|
|
64
|
+
expect(report).toMatchObject({ status: "ok", errors: [], totalErrors: 0 });
|
|
65
|
+
expect(await fs.readdir(path.dirname(file))).toEqual(["snippet.ts"]);
|
|
66
|
+
}, 30_000);
|
|
67
|
+
|
|
68
|
+
test("compiler catches the Listen comparison and reports original snippet lines", async () => {
|
|
69
|
+
await fs.writeFile(file, `${query}\nconst done = rows[0].analyzed === true;\nexport default done;`);
|
|
70
|
+
const report = await checkEval(opts());
|
|
71
|
+
expect(report.errors).toContainEqual(expect.objectContaining({ file: "eval.ts", line: 2, code: "TS2367" }));
|
|
72
|
+
}, 30_000);
|
|
73
|
+
|
|
74
|
+
test("an untyped SQL row and nonexistent service remain diagnostics, not any", async () => {
|
|
75
|
+
await fs.writeFile(file, 'const rows = await ctx.svc.supabase.sql`SELECT true AS analyzed`;\nexport default rows[0].analyzed;\nctx.svc.missing;');
|
|
76
|
+
const report = await checkEval(opts());
|
|
77
|
+
expect(report.errors.filter((e) => e.code === "TS2339").map((e) => e.line)).toEqual([2, 3]);
|
|
78
|
+
}, 30_000);
|
|
79
|
+
|
|
80
|
+
test.skipIf(!hasWrapperChecker)("blocking wrapper finding prevents all snippet side effects", async () => {
|
|
81
|
+
await fs.writeFile(file, `${query}\nexport default rows[0].analyzed === true;`);
|
|
82
|
+
let executed = false;
|
|
83
|
+
const logs: string[] = [];
|
|
84
|
+
await expect(evaluateChecked(opts(), (s) => logs.push(s), async () => { executed = true; })).rejects.toThrow("snippet did not run");
|
|
85
|
+
expect(executed).toBe(false);
|
|
86
|
+
expect(logs.join("")).toContain("SPECTEST2002");
|
|
87
|
+
expect(logs.join("")).toContain(".unwrap()");
|
|
88
|
+
}, 30_000);
|
|
89
|
+
|
|
90
|
+
test("ordinary errors are advisory and unrelated project errors do not enter eval output", async () => {
|
|
91
|
+
await fs.appendFile(projectEntry, '\nconst unrelated: number = "wrong";');
|
|
92
|
+
await fs.writeFile(file, 'const x: number = "wrong";\nexport default x;');
|
|
93
|
+
const logs: string[] = [];
|
|
94
|
+
expect(await evaluateChecked(opts(), (s) => logs.push(s), async () => "executed")).toBe("executed");
|
|
95
|
+
expect(logs.join("")).toContain("(advisory)");
|
|
96
|
+
expect(logs.join("")).toContain("eval.ts:1:");
|
|
97
|
+
expect(logs.join("")).not.toContain("index.ts");
|
|
98
|
+
}, 30_000);
|
|
99
|
+
|
|
100
|
+
test.skipIf(!hasWrapperChecker)("unwrapped comparisons execute and nullable presence checks remain allowed", async () => {
|
|
101
|
+
await fs.writeFile(file, `${query}\nexport default rows[0].analyzed.unwrap() === true;`);
|
|
102
|
+
expect(await evaluateChecked(opts(), () => {}, async () => "executed")).toBe("executed");
|
|
103
|
+
await fs.writeFile(file, 'const rows = await ctx.svc.supabase.sql<{ label: string | null }>`SELECT null AS label`;\nexport default rows[0].label === null;');
|
|
104
|
+
expect((await checkEval(opts())).blocking).toBeUndefined();
|
|
105
|
+
}, 30_000);
|
|
106
|
+
|
|
107
|
+
test("eval without a loaded project still checks basic globals", async () => {
|
|
108
|
+
await fs.writeFile(file, 'assert(true); state.value = 1; export default ctx.testName;');
|
|
109
|
+
expect(await checkEval({ ...opts(), projectEntry: undefined })).toMatchObject({ status: "ok", totalErrors: 0 });
|
|
110
|
+
}, 30_000);
|
|
111
|
+
|
|
112
|
+
test("project paths are respected even when its include excludes the eval directory", async () => {
|
|
113
|
+
await fs.writeFile(path.join(appDir, "spectest/tsconfig.json"), JSON.stringify({
|
|
114
|
+
compilerOptions: { paths: { "@fixture": ["./fixture.ts"] } }, include: ["tests/**/*.ts"],
|
|
115
|
+
}));
|
|
116
|
+
await fs.writeFile(path.join(appDir, "spectest/fixture.ts"), 'export const count = 3;');
|
|
117
|
+
await fs.writeFile(file, 'import { count } from "@fixture";\nconst x: number = count;\nexport default x;');
|
|
118
|
+
expect(await checkEval(opts())).toMatchObject({ status: "ok", totalErrors: 0 });
|
|
119
|
+
}, 30_000);
|
|
120
|
+
|
|
121
|
+
test("unavailable compiler is reported, evaluation continues, and scratch files are removed", async () => {
|
|
122
|
+
await fs.writeFile(file, "export default 1;");
|
|
123
|
+
const logs: string[] = [];
|
|
124
|
+
expect(await evaluateChecked({ ...opts(), typecheckDir: path.join(appDir, "missing") }, (s) => logs.push(s), async () => 1)).toBe(1);
|
|
125
|
+
expect(logs.join("")).toContain("[typecheck] skipped:");
|
|
126
|
+
expect(await fs.readdir(path.dirname(file))).toEqual(["snippet.ts"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("shared managed-test checker still reports project diagnostics", async () => {
|
|
130
|
+
await fs.appendFile(projectEntry, '\nconst wrong: number = "bad";');
|
|
131
|
+
const report = await runTypecheck({ appDir, typecheckDir, run });
|
|
132
|
+
expect(report.errors).toContainEqual(expect.objectContaining({ file: "spectest/index.ts", code: "TS2322" }));
|
|
133
|
+
}, 30_000);
|
|
134
|
+
|
|
135
|
+
test.skipIf(!hasWrapperChecker)("managed tests and evals classify the same comparison identically", async () => {
|
|
136
|
+
const body = `${query}\nreturn rows[0].analyzed === true;`;
|
|
137
|
+
await fs.writeFile(file, `export default await (async () => {\n${body}\n})();`);
|
|
138
|
+
const evalReport = await checkEval(opts());
|
|
139
|
+
await fs.mkdir(path.join(appDir, "spectest/tests"));
|
|
140
|
+
await fs.writeFile(path.join(appDir, "spectest/tests/comparison.ts"),
|
|
141
|
+
`import { env } from "../index";\nenv.test("comparison", async (ctx) => {\n${body}\n});`);
|
|
142
|
+
const suiteReport = await runTypecheck({ appDir, projectEntry, typecheckDir, run });
|
|
143
|
+
const findings = (report: typeof evalReport) => report.errors
|
|
144
|
+
.filter((e) => e.code === "TS2367" || e.code === "SPECTEST2002")
|
|
145
|
+
.map(({ code, message }) => ({ code, message }));
|
|
146
|
+
expect(findings(evalReport)).toEqual(findings(suiteReport));
|
|
147
|
+
expect(evalReport.blocking?.map((e) => e.code)).toEqual(["SPECTEST2002"]);
|
|
148
|
+
expect(suiteReport.blocking?.map((e) => e.code)).toEqual(["SPECTEST2002"]);
|
|
149
|
+
}, 60_000);
|
|
150
|
+
|
|
151
|
+
test("eval and tests honor the same project strictness", async () => {
|
|
152
|
+
await fs.writeFile(path.join(appDir, "spectest/tsconfig.json"), JSON.stringify({
|
|
153
|
+
compilerOptions: { strict: false, skipLibCheck: true, target: "esnext", module: "esnext", moduleResolution: "bundler" },
|
|
154
|
+
}));
|
|
155
|
+
await fs.writeFile(file, 'const value: number = null; export default value;');
|
|
156
|
+
await fs.writeFile(path.join(appDir, "spectest/null.ts"), 'const value: number = null; export default value;');
|
|
157
|
+
const evalReport = await checkEval(opts());
|
|
158
|
+
const suiteReport = await runTypecheck({ appDir, projectEntry, typecheckDir, run });
|
|
159
|
+
expect(evalReport.errors.some((d) => d.code === "TS2322")).toBe(false);
|
|
160
|
+
expect(suiteReport.errors.some((d) => d.code === "TS2322")).toBe(false);
|
|
161
|
+
expect(evalReport.blocking).toBeUndefined();
|
|
162
|
+
expect(suiteReport.blocking).toBeUndefined();
|
|
163
|
+
}, 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
|
+
}
|