@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
package/dist/daemon.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
1
2
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
3
|
import { waitForAll } from "./harness/concurrency.js";
|
|
3
4
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
@@ -39,13 +40,15 @@ import { isDesktopApp } from "./desktop.js";
|
|
|
39
40
|
// exact-then-longest-suffix rule and the one-label certificate rule are
|
|
40
41
|
// each easy to restate subtly differently.
|
|
41
42
|
import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
|
|
42
|
-
import {
|
|
43
|
+
import { startServices } from "./harness/service-graph.js";
|
|
43
44
|
import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
|
|
44
45
|
import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
45
46
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
46
47
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
47
48
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
48
|
-
import { fsUsageDiagnostics
|
|
49
|
+
import { fsUsageDiagnostics } from "./harness/wrapper-rules.js";
|
|
50
|
+
import { runTypecheck } from "./harness/typecheck.js";
|
|
51
|
+
import { evaluateChecked } from "./harness/eval-typecheck.js";
|
|
49
52
|
import { cpus } from "node:os";
|
|
50
53
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
51
54
|
import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
|
|
@@ -1451,29 +1454,6 @@ async function withContainerPostMortem(name, err) {
|
|
|
1451
1454
|
return err;
|
|
1452
1455
|
}
|
|
1453
1456
|
}
|
|
1454
|
-
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1455
|
-
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
1456
|
-
function validateServiceGraph(services) {
|
|
1457
|
-
return validateGraph(services);
|
|
1458
|
-
}
|
|
1459
|
-
async function startServices(services, startOne) {
|
|
1460
|
-
const byName = validateServiceGraph(services);
|
|
1461
|
-
const started = new Map();
|
|
1462
|
-
const start = (svc) => {
|
|
1463
|
-
const existing = started.get(svc.name);
|
|
1464
|
-
if (existing)
|
|
1465
|
-
return existing;
|
|
1466
|
-
const p = (async () => {
|
|
1467
|
-
await Promise.all((svc.dependsOn ?? [])
|
|
1468
|
-
.filter((d) => byName.has(d))
|
|
1469
|
-
.map((d) => start(byName.get(d))));
|
|
1470
|
-
await startOne(svc);
|
|
1471
|
-
})();
|
|
1472
|
-
started.set(svc.name, p);
|
|
1473
|
-
return p;
|
|
1474
|
-
};
|
|
1475
|
-
await Promise.all(services.map(start));
|
|
1476
|
-
}
|
|
1477
1457
|
// ────────────────────────────────────────────────────────────────────────
|
|
1478
1458
|
// Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
|
|
1479
1459
|
//
|
|
@@ -1623,67 +1603,6 @@ function buildIngress(project) {
|
|
|
1623
1603
|
});
|
|
1624
1604
|
}
|
|
1625
1605
|
}
|
|
1626
|
-
/**
|
|
1627
|
-
* Generate a leaf cert + key for one ingress route (fake or service
|
|
1628
|
-
* proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
|
|
1629
|
-
* hostname the route answers to, so a client connecting with TLS
|
|
1630
|
-
* verifies cleanly regardless of which hostname it used. Shells out
|
|
1631
|
-
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
1632
|
-
* bookworm ships 3.0.x).
|
|
1633
|
-
*
|
|
1634
|
-
* `label` is a short tag baked into the cert Subject CN and the temp
|
|
1635
|
-
* file names — only used for diagnostics, not for TLS verification.
|
|
1636
|
-
*/
|
|
1637
|
-
async function generateHostCert(label, hostnames) {
|
|
1638
|
-
const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
|
|
1639
|
-
const keyPath = path.join("/tmp", `${id}.key`);
|
|
1640
|
-
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
1641
|
-
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
1642
|
-
const args = [
|
|
1643
|
-
"req",
|
|
1644
|
-
"-newkey",
|
|
1645
|
-
"rsa:2048",
|
|
1646
|
-
"-nodes",
|
|
1647
|
-
"-keyout",
|
|
1648
|
-
keyPath,
|
|
1649
|
-
"-out",
|
|
1650
|
-
crtPath,
|
|
1651
|
-
"-x509",
|
|
1652
|
-
"-CA",
|
|
1653
|
-
CA_PATH,
|
|
1654
|
-
"-CAkey",
|
|
1655
|
-
CA_KEY_PATH,
|
|
1656
|
-
"-days",
|
|
1657
|
-
"3650",
|
|
1658
|
-
"-subj",
|
|
1659
|
-
`/CN=spectest-${label}`,
|
|
1660
|
-
"-addext",
|
|
1661
|
-
`subjectAltName=${sans}`,
|
|
1662
|
-
"-addext",
|
|
1663
|
-
"basicConstraints=CA:FALSE",
|
|
1664
|
-
"-addext",
|
|
1665
|
-
"extendedKeyUsage=serverAuth",
|
|
1666
|
-
"-addext",
|
|
1667
|
-
"keyUsage=digitalSignature,keyEncipherment",
|
|
1668
|
-
];
|
|
1669
|
-
const r = await shx("openssl", args, 30_000);
|
|
1670
|
-
if (r.code !== 0) {
|
|
1671
|
-
throw new Error(`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`);
|
|
1672
|
-
}
|
|
1673
|
-
try {
|
|
1674
|
-
const [cert, key] = await Promise.all([
|
|
1675
|
-
fs.readFile(crtPath, "utf8"),
|
|
1676
|
-
fs.readFile(keyPath, "utf8"),
|
|
1677
|
-
]);
|
|
1678
|
-
return { cert, key };
|
|
1679
|
-
}
|
|
1680
|
-
finally {
|
|
1681
|
-
await Promise.all([
|
|
1682
|
-
fs.unlink(keyPath).catch(() => { }),
|
|
1683
|
-
fs.unlink(crtPath).catch(() => { }),
|
|
1684
|
-
]);
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
1606
|
/** Resolve the spectest-net bridge gateway IP — the address containers
|
|
1688
1607
|
* use to reach the VM host. Asks dockerd via the docker CLI; cached for
|
|
1689
1608
|
* the daemon's life because the network is recreated only on reload. */
|
|
@@ -1717,7 +1636,10 @@ async function bridgeGatewayIp() {
|
|
|
1717
1636
|
* that don't pass through as primitives.
|
|
1718
1637
|
*/
|
|
1719
1638
|
const NATIVE_FETCH = globalThis.fetch.bind(globalThis);
|
|
1720
|
-
/**
|
|
1639
|
+
/** Mint ingress and container certificates with the environment CA. */
|
|
1640
|
+
function generateHostCert(label, hostnames) {
|
|
1641
|
+
return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
|
|
1642
|
+
}
|
|
1721
1643
|
async function startIngress() {
|
|
1722
1644
|
const hasIngress = FAKES.size > 0 ||
|
|
1723
1645
|
LOWERED.proxies.length > 0 ||
|
|
@@ -2940,10 +2862,14 @@ async function bootstrap(prepared) {
|
|
|
2940
2862
|
// hook's — into the boot log, so GET /boot-log can explain a slow or
|
|
2941
2863
|
// finished boot. Restored in the finally at the end.
|
|
2942
2864
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
2865
|
+
// One dispatcher for the whole bootstrap: service hooks run concurrently.
|
|
2866
|
+
// Only each hook's async scope opts into wrapped fetch responses.
|
|
2867
|
+
const restoreFetch = installFetchWrapper();
|
|
2943
2868
|
try {
|
|
2944
2869
|
return await bootstrapInner(prepared);
|
|
2945
2870
|
}
|
|
2946
2871
|
finally {
|
|
2872
|
+
restoreFetch();
|
|
2947
2873
|
restoreBootLog();
|
|
2948
2874
|
}
|
|
2949
2875
|
}
|
|
@@ -3109,22 +3035,28 @@ async function bootstrapInner(prepared, identity) {
|
|
|
3109
3035
|
await waitForAll([...builds.map(task => prep.get(task.name)), (async () => {
|
|
3110
3036
|
if (svc.setup) {
|
|
3111
3037
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3112
|
-
const
|
|
3038
|
+
const fetchScope = createInstrumentationScope(false);
|
|
3113
3039
|
try {
|
|
3114
|
-
await
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3040
|
+
await runInstrumented(fetchScope, async () => {
|
|
3041
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3042
|
+
await svc.setup({
|
|
3043
|
+
name: svc.name,
|
|
3044
|
+
helpers,
|
|
3045
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3046
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3047
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
|
|
3048
|
+
gateway: await bridgeGatewayIp(),
|
|
3049
|
+
caBundle: await ensureCaBundle(),
|
|
3050
|
+
} },
|
|
3051
|
+
});
|
|
3123
3052
|
});
|
|
3124
3053
|
}
|
|
3125
3054
|
catch (err) {
|
|
3126
3055
|
throw await withContainerPostMortem(svc.name, err);
|
|
3127
3056
|
}
|
|
3057
|
+
finally {
|
|
3058
|
+
fetchScope.active = false;
|
|
3059
|
+
}
|
|
3128
3060
|
}
|
|
3129
3061
|
})()]);
|
|
3130
3062
|
for (const task of builds)
|
|
@@ -4011,7 +3943,7 @@ async function spectestContext(scope = {}) {
|
|
|
4011
3943
|
readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
|
|
4012
3944
|
exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
|
|
4013
3945
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
4014
|
-
// the duration of a test / eval / project setup, so a context built
|
|
3946
|
+
// the duration of bootstrap / a test / eval / project setup, so a context built
|
|
4015
3947
|
// before it lands still gets the wrapped one.
|
|
4016
3948
|
fetch: ((input, init) => globalThis.fetch(input, init)),
|
|
4017
3949
|
svc: handles,
|
|
@@ -5209,7 +5141,8 @@ async function evalCode(code) {
|
|
|
5209
5141
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
5210
5142
|
await fs.writeFile(filePath, prepared.code);
|
|
5211
5143
|
const moduleUrl = pathToFileURL(filePath).href;
|
|
5212
|
-
const mod =
|
|
5144
|
+
const mod = await evaluateChecked({ file: filePath, appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
5145
|
+
projectEntry: loaded ? resolveEntry() : undefined }, (text) => { chunks.push(text); }, () => runInstrumented(fetchScope, () => import(moduleUrl)));
|
|
5213
5146
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
5214
5147
|
}
|
|
5215
5148
|
catch (err) {
|
|
@@ -5292,12 +5225,10 @@ async function evalCode(code) {
|
|
|
5292
5225
|
* blocking rules, which always use THIS copy rather than the project's own —
|
|
5293
5226
|
* can be driven outside a VM. */
|
|
5294
5227
|
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
5295
|
-
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
5296
|
-
const TYPECHECK_ERROR_CAP = 50;
|
|
5297
5228
|
let TYPECHECK = null;
|
|
5298
5229
|
/** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
|
|
5299
5230
|
function startTypecheck() {
|
|
5300
|
-
TYPECHECK =
|
|
5231
|
+
TYPECHECK = runProjectTypecheck().catch((err) => ({
|
|
5301
5232
|
status: "failed",
|
|
5302
5233
|
errors: [],
|
|
5303
5234
|
totalErrors: 0,
|
|
@@ -5307,141 +5238,9 @@ function startTypecheck() {
|
|
|
5307
5238
|
// Parked promises must never surface as unhandled rejections.
|
|
5308
5239
|
TYPECHECK.catch(() => { });
|
|
5309
5240
|
}
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
5314
|
-
function generatedTsconfig() {
|
|
5315
|
-
return JSON.stringify({
|
|
5316
|
-
compilerOptions: {
|
|
5317
|
-
target: "esnext",
|
|
5318
|
-
module: "esnext",
|
|
5319
|
-
moduleResolution: "bundler",
|
|
5320
|
-
strict: true,
|
|
5321
|
-
noEmit: true,
|
|
5322
|
-
skipLibCheck: true,
|
|
5323
|
-
esModuleInterop: true,
|
|
5324
|
-
resolveJsonModule: true,
|
|
5325
|
-
jsx: "react-jsx",
|
|
5326
|
-
types: ["bun"],
|
|
5327
|
-
typeRoots: [
|
|
5328
|
-
path.join(APP_DIR, "node_modules", "@types"),
|
|
5329
|
-
path.join(TYPECHECK_DIR, "node_modules", "@types"),
|
|
5330
|
-
],
|
|
5331
|
-
},
|
|
5332
|
-
include: ["**/*.ts", "**/*.tsx"],
|
|
5333
|
-
exclude: ["node_modules"],
|
|
5334
|
-
}, null, 2);
|
|
5335
|
-
}
|
|
5336
|
-
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
5337
|
-
* with indented elaboration lines folded into the preceding error. */
|
|
5338
|
-
function parseTscOutput(output) {
|
|
5339
|
-
const errors = [];
|
|
5340
|
-
let total = 0;
|
|
5341
|
-
let suppressed = 0;
|
|
5342
|
-
let last = null;
|
|
5343
|
-
for (const line of output.split("\n")) {
|
|
5344
|
-
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
5345
|
-
if (!m) {
|
|
5346
|
-
// Elaboration lines are indented; fold them into the last kept error.
|
|
5347
|
-
if (last && /^\s+\S/.test(line))
|
|
5348
|
-
last.message += `\n${line.trimEnd()}`;
|
|
5349
|
-
continue;
|
|
5350
|
-
}
|
|
5351
|
-
const [, rawFile, ln, col, code, message] = m;
|
|
5352
|
-
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
5353
|
-
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
5354
|
-
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
5355
|
-
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
5356
|
-
// user's to read.
|
|
5357
|
-
const abs = path.resolve(APP_DIR, rawFile);
|
|
5358
|
-
const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
5359
|
-
if (!inApp) {
|
|
5360
|
-
suppressed += 1;
|
|
5361
|
-
last = null;
|
|
5362
|
-
continue;
|
|
5363
|
-
}
|
|
5364
|
-
total += 1;
|
|
5365
|
-
const err = {
|
|
5366
|
-
file: path.relative(APP_DIR, abs),
|
|
5367
|
-
line: Number(ln),
|
|
5368
|
-
column: Number(col),
|
|
5369
|
-
code: code,
|
|
5370
|
-
message: message,
|
|
5371
|
-
};
|
|
5372
|
-
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
5373
|
-
errors.push(err);
|
|
5374
|
-
last = err;
|
|
5375
|
-
}
|
|
5376
|
-
else {
|
|
5377
|
-
last = null;
|
|
5378
|
-
}
|
|
5379
|
-
}
|
|
5380
|
-
return { errors, total, suppressed };
|
|
5381
|
-
}
|
|
5382
|
-
async function runTypecheck() {
|
|
5383
|
-
const started = Date.now();
|
|
5384
|
-
// The project's own typescript wins over the baked copy.
|
|
5385
|
-
const tsc = [
|
|
5386
|
-
path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
5387
|
-
path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
5388
|
-
].find((p) => existsSync(p));
|
|
5389
|
-
if (!tsc) {
|
|
5390
|
-
return {
|
|
5391
|
-
status: "skipped",
|
|
5392
|
-
errors: [],
|
|
5393
|
-
totalErrors: 0,
|
|
5394
|
-
durationMs: 0,
|
|
5395
|
-
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
5396
|
-
};
|
|
5397
|
-
}
|
|
5398
|
-
let config = path.join(APP_DIR, "tsconfig.json");
|
|
5399
|
-
if (!existsSync(config)) {
|
|
5400
|
-
config = path.join(APP_DIR, ".spectest-tsconfig.json");
|
|
5401
|
-
await fs.writeFile(config, generatedTsconfig());
|
|
5402
|
-
}
|
|
5403
|
-
const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
5404
|
-
const durationMs = Date.now() - started;
|
|
5405
|
-
if (res.code === 124) {
|
|
5406
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
5407
|
-
}
|
|
5408
|
-
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
5409
|
-
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
5410
|
-
const strip = ({ file, line, column, code, message }) => ({
|
|
5411
|
-
file,
|
|
5412
|
-
line,
|
|
5413
|
-
column,
|
|
5414
|
-
code,
|
|
5415
|
-
message,
|
|
5416
|
-
});
|
|
5417
|
-
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
5418
|
-
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
5419
|
-
if (errors.length === 0 && wrapper.length === 0) {
|
|
5420
|
-
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
5421
|
-
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
5422
|
-
// crash (config not found, OOM) — surface the latter.
|
|
5423
|
-
if (res.code !== 0 && suppressed === 0) {
|
|
5424
|
-
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
5425
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
5426
|
-
}
|
|
5427
|
-
if (suppressed > 0) {
|
|
5428
|
-
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
5429
|
-
}
|
|
5430
|
-
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
5431
|
-
}
|
|
5432
|
-
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
5433
|
-
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
5434
|
-
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
5435
|
-
// `errors` is capped and the entries that stopped the run must never be the
|
|
5436
|
-
// ones the cap drops.
|
|
5437
|
-
const all = [...blocking, ...advisory, ...errors];
|
|
5438
|
-
return {
|
|
5439
|
-
status: "errors",
|
|
5440
|
-
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
5441
|
-
totalErrors: total + wrapper.length,
|
|
5442
|
-
durationMs,
|
|
5443
|
-
...(blocking.length > 0 ? { blocking } : {}),
|
|
5444
|
-
};
|
|
5241
|
+
async function runProjectTypecheck() {
|
|
5242
|
+
return runTypecheck({ appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
5243
|
+
projectEntry: loaded ? resolveEntry() : undefined, extraDiagnostics: rawFsReport });
|
|
5445
5244
|
}
|
|
5446
5245
|
/**
|
|
5447
5246
|
* The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
|
|
@@ -5468,38 +5267,6 @@ async function rawFsReport() {
|
|
|
5468
5267
|
return [];
|
|
5469
5268
|
}
|
|
5470
5269
|
}
|
|
5471
|
-
/**
|
|
5472
|
-
* The blocking rules, run against the BAKED compiler whatever the project
|
|
5473
|
-
* pins. Best-effort in every direction: an install that predates the API, a
|
|
5474
|
-
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
5475
|
-
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
5476
|
-
* definite finding can stop one.
|
|
5477
|
-
*/
|
|
5478
|
-
async function runWrapperRulesReport(config) {
|
|
5479
|
-
const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
|
|
5480
|
-
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js")))
|
|
5481
|
-
return [];
|
|
5482
|
-
try {
|
|
5483
|
-
const run = await runWrapperRules({
|
|
5484
|
-
typescriptDir,
|
|
5485
|
-
configFile: config,
|
|
5486
|
-
appDir: APP_DIR,
|
|
5487
|
-
readFile: (f) => fs.readFile(f, "utf8"),
|
|
5488
|
-
relative: path.relative,
|
|
5489
|
-
join: path.join,
|
|
5490
|
-
dirname: path.dirname,
|
|
5491
|
-
});
|
|
5492
|
-
if (run.status !== "ok") {
|
|
5493
|
-
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
5494
|
-
return [];
|
|
5495
|
-
}
|
|
5496
|
-
return run.diagnostics;
|
|
5497
|
-
}
|
|
5498
|
-
catch (err) {
|
|
5499
|
-
console.warn(`[typecheck] wrapper rules threw: ${err?.message ?? err}`);
|
|
5500
|
-
return [];
|
|
5501
|
-
}
|
|
5502
|
-
}
|
|
5503
5270
|
/** Run `job` under a mutual-exclusion slot, refusing if one is held. */
|
|
5504
5271
|
async function exclusive(state, slot, busy, job) {
|
|
5505
5272
|
if (state[slot])
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a leaf cert + key for one ingress route (fake or service
|
|
3
|
+
* proxy), signed by the in-VM root CA. SANs cover every
|
|
4
|
+
* hostname the route answers to, so a client connecting with TLS
|
|
5
|
+
* verifies cleanly regardless of which hostname it used. Shells out
|
|
6
|
+
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
7
|
+
* bookworm ships 3.0.x).
|
|
8
|
+
*
|
|
9
|
+
* `label` is a diagnostic tag baked into the cert Subject CN and the temp
|
|
10
|
+
* file names — only used for diagnostics, not for TLS verification.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateHostCert(label: string, hostnames: string[], caPath: string, caKeyPath: string): Promise<{
|
|
13
|
+
cert: string;
|
|
14
|
+
key: string;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* Generate a leaf cert + key for one ingress route (fake or service
|
|
9
|
+
* proxy), signed by the in-VM root CA. SANs cover every
|
|
10
|
+
* hostname the route answers to, so a client connecting with TLS
|
|
11
|
+
* verifies cleanly regardless of which hostname it used. Shells out
|
|
12
|
+
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
13
|
+
* bookworm ships 3.0.x).
|
|
14
|
+
*
|
|
15
|
+
* `label` is a diagnostic tag baked into the cert Subject CN and the temp
|
|
16
|
+
* file names — only used for diagnostics, not for TLS verification.
|
|
17
|
+
*/
|
|
18
|
+
export async function generateHostCert(label, hostnames, caPath, caKeyPath) {
|
|
19
|
+
// CN is diagnostic only; SANs retain the full hostnames. Bound the label
|
|
20
|
+
// for both OpenSSL's 64-byte CN limit and filesystem component limits.
|
|
21
|
+
const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
|
|
22
|
+
const id = `${commonName}-${randomUUID()}`;
|
|
23
|
+
const keyPath = path.join("/tmp", `${id}.key`);
|
|
24
|
+
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
25
|
+
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
26
|
+
const args = [
|
|
27
|
+
"req",
|
|
28
|
+
"-newkey",
|
|
29
|
+
"rsa:2048",
|
|
30
|
+
"-nodes",
|
|
31
|
+
"-keyout",
|
|
32
|
+
keyPath,
|
|
33
|
+
"-out",
|
|
34
|
+
crtPath,
|
|
35
|
+
"-x509",
|
|
36
|
+
"-CA",
|
|
37
|
+
caPath,
|
|
38
|
+
"-CAkey",
|
|
39
|
+
caKeyPath,
|
|
40
|
+
"-days",
|
|
41
|
+
"3650",
|
|
42
|
+
"-subj",
|
|
43
|
+
`/CN=${commonName}`,
|
|
44
|
+
"-addext",
|
|
45
|
+
`subjectAltName=${sans}`,
|
|
46
|
+
"-addext",
|
|
47
|
+
"basicConstraints=CA:FALSE",
|
|
48
|
+
"-addext",
|
|
49
|
+
"extendedKeyUsage=serverAuth",
|
|
50
|
+
"-addext",
|
|
51
|
+
"keyUsage=digitalSignature,keyEncipherment",
|
|
52
|
+
];
|
|
53
|
+
try {
|
|
54
|
+
await execFileAsync("openssl", args, { timeout: 30_000 });
|
|
55
|
+
const [cert, key] = await Promise.all([
|
|
56
|
+
fs.readFile(crtPath, "utf8"),
|
|
57
|
+
fs.readFile(keyPath, "utf8"),
|
|
58
|
+
]);
|
|
59
|
+
return { cert, key };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await Promise.all([
|
|
63
|
+
fs.unlink(keyPath).catch(() => { }),
|
|
64
|
+
fs.unlink(crtPath).catch(() => { }),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type TypecheckOptions, type TypecheckReport } from "./typecheck.js";
|
|
2
|
+
interface EvalTypecheckOptions extends Pick<TypecheckOptions, "appDir" | "typecheckDir" | "run"> {
|
|
3
|
+
file: string;
|
|
4
|
+
projectEntry?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function checkEval(opts: EvalTypecheckOptions): Promise<TypecheckReport>;
|
|
7
|
+
/** The same advisory/blocking policy as a managed test, before module import. */
|
|
8
|
+
export declare function evaluateChecked<T>(opts: EvalTypecheckOptions, log: (text: string) => void, evaluate: () => Promise<T>): Promise<T>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { generatedTsconfig, projectTypecheckConfig, runTypecheck } from "./typecheck.js";
|
|
4
|
+
/** Describe the globals installed by evalCode, without importing/executing the project. */
|
|
5
|
+
function globals(projectEntry) {
|
|
6
|
+
return `import type * as SDK from "@specific.dev/spectest";
|
|
7
|
+
${projectEntry ? `import type project from ${JSON.stringify(projectEntry)};` : ""}
|
|
8
|
+
type EvalContext = ${projectEntry
|
|
9
|
+
? "typeof project extends SDK.Project<infer S, infer F> ? SDK.TestContext<undefined, S, F> : SDK.TestContext<undefined>"
|
|
10
|
+
: "SDK.TestContext<undefined>"};
|
|
11
|
+
declare global {
|
|
12
|
+
var ctx: EvalContext;
|
|
13
|
+
var expect: typeof SDK.expect;
|
|
14
|
+
var expectRaw: typeof SDK.expectRaw;
|
|
15
|
+
var assert: typeof SDK.assert;
|
|
16
|
+
var state: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
`;
|
|
19
|
+
}
|
|
20
|
+
export async function checkEval(opts) {
|
|
21
|
+
const started = Date.now();
|
|
22
|
+
let scratch;
|
|
23
|
+
try {
|
|
24
|
+
scratch = await fs.mkdtemp(path.join(path.dirname(opts.file), "check-"));
|
|
25
|
+
const declarations = path.join(scratch, "globals.d.ts");
|
|
26
|
+
await fs.writeFile(declarations, globals(opts.projectEntry));
|
|
27
|
+
const projectConfig = projectTypecheckConfig(opts);
|
|
28
|
+
const defaults = JSON.parse(generatedTsconfig(opts.appDir, opts.typecheckDir));
|
|
29
|
+
const config = {
|
|
30
|
+
...(projectConfig ? { extends: projectConfig } : defaults),
|
|
31
|
+
compilerOptions: {
|
|
32
|
+
...(!projectConfig ? defaults.compilerOptions : {}),
|
|
33
|
+
// Bun runs eval as ESM TypeScript, regardless of the app's build output.
|
|
34
|
+
noEmit: true,
|
|
35
|
+
skipLibCheck: true,
|
|
36
|
+
target: "esnext",
|
|
37
|
+
module: "esnext",
|
|
38
|
+
moduleResolution: "bundler",
|
|
39
|
+
moduleDetection: "force",
|
|
40
|
+
allowImportingTsExtensions: true,
|
|
41
|
+
composite: false,
|
|
42
|
+
incremental: false,
|
|
43
|
+
rootDir: opts.appDir,
|
|
44
|
+
},
|
|
45
|
+
// Project includes/excludes may omit hidden files or include the entire
|
|
46
|
+
// application. Check this snippet, with imports resolved normally.
|
|
47
|
+
files: [opts.file, declarations],
|
|
48
|
+
include: [],
|
|
49
|
+
exclude: [],
|
|
50
|
+
};
|
|
51
|
+
const configFile = path.join(scratch, "tsconfig.json");
|
|
52
|
+
await fs.writeFile(configFile, JSON.stringify(config));
|
|
53
|
+
const report = await runTypecheck({ ...opts, configFile, diagnosticFile: opts.file });
|
|
54
|
+
// Stable, snippet-relative locations; don't expose random temporary paths.
|
|
55
|
+
const normalize = (d) => ({ ...d, file: "eval.ts" });
|
|
56
|
+
return {
|
|
57
|
+
...report,
|
|
58
|
+
errors: report.errors.map(normalize),
|
|
59
|
+
...(report.blocking ? { blocking: report.blocking.map(normalize) } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// Match managed tests: unavailable/broken checkers report their failure,
|
|
64
|
+
// while only a definite blocking finding prevents execution.
|
|
65
|
+
return {
|
|
66
|
+
status: "failed", errors: [], totalErrors: 0, durationMs: Date.now() - started,
|
|
67
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
if (scratch)
|
|
72
|
+
await fs.rm(scratch, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The same advisory/blocking policy as a managed test, before module import. */
|
|
76
|
+
export async function evaluateChecked(opts, log, evaluate) {
|
|
77
|
+
const report = await checkEval(opts);
|
|
78
|
+
if (report.status === "failed" || report.status === "skipped") {
|
|
79
|
+
log(`[typecheck] ${report.status}: ${report.detail ?? "unavailable"}\n`);
|
|
80
|
+
}
|
|
81
|
+
else if (report.totalErrors > 0) {
|
|
82
|
+
log(`[typecheck] ${report.totalErrors} diagnostic(s)${report.blocking?.length ? "" : " (advisory)"}\n`);
|
|
83
|
+
for (const d of report.errors)
|
|
84
|
+
log(`${d.file}:${d.line}:${d.column}: ${d.code}: ${d.message}\n`);
|
|
85
|
+
if (report.totalErrors > report.errors.length) {
|
|
86
|
+
log(`[typecheck] ${report.totalErrors - report.errors.length} more diagnostic(s)\n`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (report.blocking?.length) {
|
|
90
|
+
throw new Error(`Typecheck: ${report.blocking.length} blocking error(s) in eval — the snippet did not run. Fix the diagnostics above before retrying.`);
|
|
91
|
+
}
|
|
92
|
+
return evaluate();
|
|
93
|
+
}
|
|
@@ -27,3 +27,6 @@ export declare function validateServiceGraph<T extends GraphNode>(services: read
|
|
|
27
27
|
* graph is walkable at all.
|
|
28
28
|
*/
|
|
29
29
|
export declare function topologicalOrder<T extends GraphNode>(services: readonly T[]): T[];
|
|
30
|
+
/** Start each service after its dependencies finish setup. Drain all branches
|
|
31
|
+
* before reporting failure so teardown cannot race containers or hooks. */
|
|
32
|
+
export declare function startServices<T extends GraphNode>(services: readonly T[], startOne: (svc: T) => Promise<void>): Promise<void>;
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* The `dependsOn` graph: validation, and the traversal order bring-up
|
|
3
|
-
* uses.
|
|
4
|
-
*
|
|
5
|
-
* Ported out of `daemon.ts`. Validation runs before anything starts, so
|
|
6
|
-
* the DAG runner can assume a clean graph — an unknown dependency or a
|
|
7
|
-
* cycle must be a clear error at load, not a container that never becomes
|
|
8
|
-
* ready and eventually times out with no explanation.
|
|
9
|
-
*/
|
|
1
|
+
import { waitForAll } from "./concurrency.js";
|
|
10
2
|
/**
|
|
11
3
|
* Check the graph and return the name→node map used to walk it.
|
|
12
4
|
*
|
|
@@ -90,3 +82,21 @@ export function topologicalOrder(services) {
|
|
|
90
82
|
visit(s.name);
|
|
91
83
|
return out;
|
|
92
84
|
}
|
|
85
|
+
/** Start each service after its dependencies finish setup. Drain all branches
|
|
86
|
+
* before reporting failure so teardown cannot race containers or hooks. */
|
|
87
|
+
export async function startServices(services, startOne) {
|
|
88
|
+
const byName = validateServiceGraph(services);
|
|
89
|
+
const started = new Map();
|
|
90
|
+
const start = (svc) => {
|
|
91
|
+
const existing = started.get(svc.name);
|
|
92
|
+
if (existing)
|
|
93
|
+
return existing;
|
|
94
|
+
const pending = (async () => {
|
|
95
|
+
await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name))));
|
|
96
|
+
await startOne(svc);
|
|
97
|
+
})();
|
|
98
|
+
started.set(svc.name, pending);
|
|
99
|
+
return pending;
|
|
100
|
+
};
|
|
101
|
+
await waitForAll(services.map(start));
|
|
102
|
+
}
|