@specific.dev/spectest 0.47.0 → 0.49.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/annotate.d.ts +101 -0
- package/dist/annotate.js +196 -0
- package/dist/browser.d.ts +1 -0
- package/dist/daemon.js +129 -41
- package/dist/index.d.ts +20 -2
- package/dist/index.js +7 -0
- package/dist/locator.d.ts +32 -0
- package/dist/locator.js +28 -0
- package/dist/project-files.d.ts +18 -0
- package/dist/project-files.js +97 -0
- package/dist/recorder.d.ts +28 -9
- package/package.json +1 -1
- package/src/annotate.ts +292 -0
- package/src/browser.ts +1 -0
- package/src/daemon.ts +159 -48
- package/src/index.ts +35 -2
- package/src/locator.ts +70 -0
- package/src/project-files.ts +103 -0
- package/src/recorder.ts +28 -9
package/src/daemon.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
sanitizeSegment,
|
|
58
58
|
} from "./harness/volume-paths.js";
|
|
59
59
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
60
|
+
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
60
61
|
import {
|
|
61
62
|
isTextualContentType,
|
|
62
63
|
looksBinary,
|
|
@@ -111,7 +112,9 @@ import {
|
|
|
111
112
|
} from "./harness/methods.js";
|
|
112
113
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
113
114
|
import { openTerminal } from "./terminal.js";
|
|
115
|
+
import { readAnnotation, type RenderAnnotation } from "./annotate.js";
|
|
114
116
|
import {
|
|
117
|
+
recordEmail,
|
|
115
118
|
recordEnv,
|
|
116
119
|
recordExec,
|
|
117
120
|
recordFake,
|
|
@@ -180,7 +183,6 @@ function namedServices(cfg: EnvironmentConfig): NamedService[] {
|
|
|
180
183
|
|
|
181
184
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
182
185
|
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
183
|
-
const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
184
186
|
|
|
185
187
|
// Stable hostname every service container resolves to the host (the
|
|
186
188
|
// `spectest-br0` gateway) — so apps that build or pull images at runtime
|
|
@@ -207,7 +209,9 @@ function hostCacheGateway(): string | null {
|
|
|
207
209
|
}
|
|
208
210
|
return _hostCacheGateway;
|
|
209
211
|
}
|
|
210
|
-
|
|
212
|
+
// WORKSPACE (/workspace) and APP_DIR (/opt/spectest/app) both live in
|
|
213
|
+
// project-files.ts, next to the rule that decides which copy of a project
|
|
214
|
+
// file is the current one.
|
|
211
215
|
// The bun the base snapshot installs (base.rs::BASE_SETUP_SH). The daemon
|
|
212
216
|
// runs under it, and eval's dependency install shells out to it.
|
|
213
217
|
const BUN_BIN = "/usr/local/bin/bun";
|
|
@@ -1328,11 +1332,23 @@ async function runContainer(
|
|
|
1328
1332
|
}
|
|
1329
1333
|
}
|
|
1330
1334
|
|
|
1331
|
-
|
|
1335
|
+
/** What a readiness probe saw. `detail` is what the plain boolean used to
|
|
1336
|
+
* throw away, and it is the difference between the two ways a probe fails:
|
|
1337
|
+
* it ran and said no (a service that isn't up yet, a file a build never
|
|
1338
|
+
* produced) versus it never got to run (a docker daemon or a guest too
|
|
1339
|
+
* starved to answer inside the probe's own timeout). The failure message
|
|
1340
|
+
* carries the last one, since a `sleep infinity` service has no container
|
|
1341
|
+
* logs to fall back on. */
|
|
1342
|
+
interface ProbeOutcome {
|
|
1343
|
+
ok: boolean;
|
|
1344
|
+
detail: string;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function probeTcp(host: string, port: number): Promise<ProbeOutcome> {
|
|
1332
1348
|
return new Promise((resolve) => {
|
|
1333
1349
|
const sock = net.createConnection({ host, port });
|
|
1334
1350
|
let settled = false;
|
|
1335
|
-
const finish = (
|
|
1351
|
+
const finish = (ok: boolean, detail: string) => {
|
|
1336
1352
|
if (settled) return;
|
|
1337
1353
|
settled = true;
|
|
1338
1354
|
try {
|
|
@@ -1340,12 +1356,16 @@ async function probeTcp(host: string, port: number): Promise<boolean> {
|
|
|
1340
1356
|
} catch {
|
|
1341
1357
|
/* ignore */
|
|
1342
1358
|
}
|
|
1343
|
-
resolve(
|
|
1359
|
+
resolve({ ok, detail });
|
|
1344
1360
|
};
|
|
1345
1361
|
sock.setTimeout(2000);
|
|
1346
|
-
sock.once("connect", () => finish(true));
|
|
1347
|
-
sock.once("error", () =>
|
|
1348
|
-
|
|
1362
|
+
sock.once("connect", () => finish(true, `connected to ${host}:${port}`));
|
|
1363
|
+
sock.once("error", (err: Error) =>
|
|
1364
|
+
finish(false, `connect to ${host}:${port} failed: ${err.message}`),
|
|
1365
|
+
);
|
|
1366
|
+
sock.once("timeout", () =>
|
|
1367
|
+
finish(false, `connect to ${host}:${port} got no reply within 2000ms`),
|
|
1368
|
+
);
|
|
1349
1369
|
});
|
|
1350
1370
|
}
|
|
1351
1371
|
|
|
@@ -1355,52 +1375,92 @@ async function probeHttp(
|
|
|
1355
1375
|
urlPath: string,
|
|
1356
1376
|
headers?: Record<string, string>,
|
|
1357
1377
|
expectStatus?: number,
|
|
1358
|
-
): Promise<
|
|
1378
|
+
): Promise<ProbeOutcome> {
|
|
1379
|
+
const url = `http://${host}:${port}${urlPath}`;
|
|
1359
1380
|
const ctrl = new AbortController();
|
|
1360
1381
|
const to = setTimeout(() => ctrl.abort(), 5000);
|
|
1361
1382
|
try {
|
|
1362
|
-
const res = await fetch(
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
});
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1383
|
+
const res = await fetch(url, { signal: ctrl.signal, headers });
|
|
1384
|
+
const ok = expectStatus !== undefined ? res.status === expectStatus : res.ok;
|
|
1385
|
+
const want = expectStatus !== undefined ? String(expectStatus) : "2xx";
|
|
1386
|
+
return { ok, detail: `GET ${url} → ${res.status} (wanted ${want})` };
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
const e = err as Error;
|
|
1389
|
+
const detail =
|
|
1390
|
+
e?.name === "AbortError"
|
|
1391
|
+
? `GET ${url} got no reply within 5000ms`
|
|
1392
|
+
: `GET ${url} failed: ${e?.message ?? String(err)}`;
|
|
1393
|
+
return { ok: false, detail };
|
|
1369
1394
|
} finally {
|
|
1370
1395
|
clearTimeout(to);
|
|
1371
1396
|
}
|
|
1372
1397
|
}
|
|
1373
1398
|
|
|
1374
|
-
async function probeExec(name: string, command: string): Promise<
|
|
1399
|
+
async function probeExec(name: string, command: string): Promise<ProbeOutcome> {
|
|
1375
1400
|
const r = await docker(["exec", name, "sh", "-c", command], 10_000);
|
|
1376
|
-
|
|
1401
|
+
if (r.code === 0) return { ok: true, detail: `\`${command}\` exited 0` };
|
|
1402
|
+
// 124 is shx's own kill (see `shx`): the exec never answered, which says
|
|
1403
|
+
// the docker daemon is wedged or starved rather than anything about the
|
|
1404
|
+
// command's verdict.
|
|
1405
|
+
const why =
|
|
1406
|
+
r.code === 124
|
|
1407
|
+
? `was killed after 10000ms with no reply`
|
|
1408
|
+
: `exited ${r.code}`;
|
|
1409
|
+
const output = firstLine(r.stderr) || firstLine(r.stdout);
|
|
1410
|
+
return {
|
|
1411
|
+
ok: false,
|
|
1412
|
+
detail: `\`${command}\` ${why}${output ? `: ${output}` : ""}`,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** First non-empty line, trimmed and capped — probe output goes into an
|
|
1417
|
+
* error message, not a log file. */
|
|
1418
|
+
function firstLine(s: string): string {
|
|
1419
|
+
const line = s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
1420
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
1377
1421
|
}
|
|
1378
1422
|
|
|
1379
1423
|
async function waitForReady(svc: NamedService): Promise<void> {
|
|
1380
1424
|
const check: ReadyCheck | undefined = svc.readyCheck;
|
|
1381
1425
|
if (!check) return;
|
|
1382
1426
|
const timeoutSecs = check.timeoutSecs ?? 60;
|
|
1427
|
+
let last: ProbeOutcome | undefined;
|
|
1383
1428
|
const probe = async (): Promise<boolean> => {
|
|
1384
|
-
if (check.type === "tcp")
|
|
1385
|
-
if (check.type === "http") {
|
|
1386
|
-
|
|
1429
|
+
if (check.type === "tcp") last = await probeTcp(svc.name, check.port);
|
|
1430
|
+
else if (check.type === "http") {
|
|
1431
|
+
last = await probeHttp(
|
|
1387
1432
|
svc.name,
|
|
1388
1433
|
check.port,
|
|
1389
1434
|
check.path ?? "/",
|
|
1390
1435
|
check.headers,
|
|
1391
1436
|
check.expectStatus,
|
|
1392
1437
|
);
|
|
1393
|
-
}
|
|
1394
|
-
return
|
|
1438
|
+
} else last = await probeExec(svc.name, check.command);
|
|
1439
|
+
return last.ok;
|
|
1395
1440
|
};
|
|
1396
1441
|
// Scheduling (the ramp, and not sleeping past the deadline) lives in
|
|
1397
1442
|
// `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
|
|
1398
|
-
const { ready } = await pollUntilReady(probe, {
|
|
1443
|
+
const { ready, attempts, elapsedMs } = await pollUntilReady(probe, {
|
|
1444
|
+
kind: check.type,
|
|
1445
|
+
timeoutSecs,
|
|
1446
|
+
});
|
|
1399
1447
|
if (ready) return;
|
|
1448
|
+
// The attempt count is half the diagnosis: a probe with a 10s timeout of
|
|
1449
|
+
// its own can only run a handful of times in 60s, so "6 attempts" says the
|
|
1450
|
+
// probes were hanging where "80 attempts" says they ran and kept saying no.
|
|
1451
|
+
let msg =
|
|
1452
|
+
`service ${svc.name} not ready within ${timeoutSecs}s ` +
|
|
1453
|
+
`(${attempts} ${check.type} probe${attempts === 1 ? "" : "s"} over ${
|
|
1454
|
+
Math.round(elapsedMs / 100) / 10
|
|
1455
|
+
}s).`;
|
|
1456
|
+
if (last) msg += `\nLast probe: ${last.detail}`;
|
|
1400
1457
|
const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1458
|
+
const output = `${logs.stdout}\n${logs.stderr}`.trim();
|
|
1459
|
+
// A service that logs nothing (`sleep infinity`, a quiet daemon) used to
|
|
1460
|
+
// get an empty "Recent container logs:" heading, which reads as if the
|
|
1461
|
+
// logs were the evidence and there simply weren't any.
|
|
1462
|
+
msg += output ? `\nRecent container logs:\n${output}` : `\n(the container logged nothing)`;
|
|
1463
|
+
throw new Error(msg);
|
|
1404
1464
|
}
|
|
1405
1465
|
|
|
1406
1466
|
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
@@ -2670,14 +2730,27 @@ function invokeFakeHelper(
|
|
|
2670
2730
|
const args = callArgs.map((a) => deepUnwrap(a));
|
|
2671
2731
|
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2672
2732
|
const recordResult = (value: unknown): unknown => {
|
|
2733
|
+
// A helper may box its return in a render annotation
|
|
2734
|
+
// (`annotate(v, "email", …)`). The box never reaches the test, and it
|
|
2735
|
+
// never replaces the step either: the call is recorded as the ordinary
|
|
2736
|
+
// `fake` event it is, and the annotation rides *under* it as a child
|
|
2737
|
+
// event (`parentSeq`), which the dashboard folds into the fake step's
|
|
2738
|
+
// detail panel. So the timeline reads the same as any other helper
|
|
2739
|
+
// call, with the richer view one click in.
|
|
2740
|
+
const annotated = readAnnotation(value);
|
|
2741
|
+
const raw = annotated ? annotated.value : value;
|
|
2742
|
+
const durationMs = Date.now() - t;
|
|
2673
2743
|
const seq = recordFake({
|
|
2674
2744
|
fake: fakeName,
|
|
2675
2745
|
member,
|
|
2676
2746
|
args: safeArgs,
|
|
2677
|
-
result: safeSerialize(
|
|
2678
|
-
durationMs
|
|
2747
|
+
result: safeSerialize(raw),
|
|
2748
|
+
durationMs,
|
|
2679
2749
|
}, resv);
|
|
2680
|
-
|
|
2750
|
+
if (annotated && seq !== undefined) {
|
|
2751
|
+
recordAnnotationChild(fakeName, member, annotated.annotation, durationMs, seq);
|
|
2752
|
+
}
|
|
2753
|
+
return wrap(raw, seq);
|
|
2681
2754
|
};
|
|
2682
2755
|
const recordError = (err: unknown): void => {
|
|
2683
2756
|
recordFake({
|
|
@@ -2705,6 +2778,42 @@ function invokeFakeHelper(
|
|
|
2705
2778
|
return recordResult(result);
|
|
2706
2779
|
}
|
|
2707
2780
|
|
|
2781
|
+
/** Record a fake-helper call's render annotation as a child of the call's
|
|
2782
|
+
* own `fake` event — the same `parentSeq` grouping `ctx.poll` uses for the
|
|
2783
|
+
* iteration it kept, so the annotated view folds into the fake step's
|
|
2784
|
+
* detail panel instead of taking a timeline row of its own.
|
|
2785
|
+
*
|
|
2786
|
+
* The child is the event kind that already knows how to draw this: an
|
|
2787
|
+
* `email` annotation records the very event the built-in `email()`
|
|
2788
|
+
* component's mailbox helpers record, so it renders with no new code. It
|
|
2789
|
+
* carries the fake's name and member as its service/op, and the parent's
|
|
2790
|
+
* duration, since it describes that same call.
|
|
2791
|
+
*
|
|
2792
|
+
* Only the success path is annotated: a helper that threw returned no value
|
|
2793
|
+
* to annotate, so it's a plain `fake` error event. */
|
|
2794
|
+
function recordAnnotationChild(
|
|
2795
|
+
fakeName: string,
|
|
2796
|
+
member: string,
|
|
2797
|
+
annotation: RenderAnnotation,
|
|
2798
|
+
durationMs: number,
|
|
2799
|
+
parentSeq: number,
|
|
2800
|
+
): void {
|
|
2801
|
+
switch (annotation.kind) {
|
|
2802
|
+
case "email":
|
|
2803
|
+
recordEmail({
|
|
2804
|
+
parentSeq,
|
|
2805
|
+
annotation: true,
|
|
2806
|
+
service: fakeName,
|
|
2807
|
+
op: member,
|
|
2808
|
+
message: annotation.message,
|
|
2809
|
+
messages: annotation.messages,
|
|
2810
|
+
count: annotation.count,
|
|
2811
|
+
durationMs,
|
|
2812
|
+
});
|
|
2813
|
+
break;
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2708
2817
|
function errMessage(err: unknown): string {
|
|
2709
2818
|
return (err as Error)?.message ?? String(err);
|
|
2710
2819
|
}
|
|
@@ -3634,8 +3743,9 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
|
|
|
3634
3743
|
const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
|
|
3635
3744
|
return {
|
|
3636
3745
|
projectRoot: WORKSPACE,
|
|
3637
|
-
readProjectFile: (p: string) =>
|
|
3638
|
-
|
|
3746
|
+
readProjectFile: (p: string) => fs.readFile(resolveProjectPath(p), "utf8"),
|
|
3747
|
+
readProjectFileBytes: async (p: string) =>
|
|
3748
|
+
new Uint8Array(await fs.readFile(resolveProjectPath(p))),
|
|
3639
3749
|
exec: (service, command, opts) =>
|
|
3640
3750
|
componentExec(service, command, opts, execTimeoutMs),
|
|
3641
3751
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
@@ -4117,41 +4227,42 @@ async function pollCall<T>(
|
|
|
4117
4227
|
let success = false;
|
|
4118
4228
|
let predicateError: unknown;
|
|
4119
4229
|
|
|
4120
|
-
// Record all iterations normally
|
|
4121
|
-
//
|
|
4122
|
-
//
|
|
4123
|
-
//
|
|
4230
|
+
// Record all iterations normally, but keep only the newest one: a new
|
|
4231
|
+
// attempt drops the events the previous attempt emitted, so the timeline
|
|
4232
|
+
// never fills with polling noise. Whichever attempt is last when the loop
|
|
4233
|
+
// ends — the winning one, or the final failed one — stays, and gets marked
|
|
4234
|
+
// as a child of the wait event so the UI can render it nested.
|
|
4235
|
+
//
|
|
4236
|
+
// The failed attempt is kept deliberately. A poll that times out reports
|
|
4237
|
+
// only "timed out after 120000ms (60 attempts)", which says nothing about
|
|
4238
|
+
// WHY: an ingress answering an instant 404 for two minutes and a backend
|
|
4239
|
+
// that never returns look identical in that message. The last attempt's
|
|
4240
|
+
// events carry the status, the body and the duration, which is the whole
|
|
4241
|
+
// difference between "the route was never programmed" and "the app hung".
|
|
4124
4242
|
const beforePollIdx = recorderEventCount();
|
|
4125
4243
|
let lastIterStartIdx = beforePollIdx;
|
|
4126
|
-
let keptIterStartIdx = beforePollIdx;
|
|
4127
4244
|
|
|
4128
4245
|
while (Date.now() - start < timeoutMs) {
|
|
4129
4246
|
attempts += 1;
|
|
4247
|
+
// Drop the *previous* attempt's events, not this one's — its events are
|
|
4248
|
+
// the ones worth keeping until a newer attempt replaces them.
|
|
4249
|
+
recorderTruncate(lastIterStartIdx);
|
|
4130
4250
|
lastIterStartIdx = recorderEventCount();
|
|
4131
4251
|
try {
|
|
4132
4252
|
const v = await fn();
|
|
4133
4253
|
if (v !== null && v !== undefined && v !== false) {
|
|
4134
4254
|
value = v as T;
|
|
4135
4255
|
success = true;
|
|
4136
|
-
keptIterStartIdx = lastIterStartIdx;
|
|
4137
4256
|
break;
|
|
4138
4257
|
}
|
|
4139
4258
|
} catch (err) {
|
|
4140
4259
|
predicateError = err;
|
|
4141
4260
|
break;
|
|
4142
4261
|
}
|
|
4143
|
-
// Failed iteration — drop the events it emitted.
|
|
4144
|
-
recorderTruncate(lastIterStartIdx);
|
|
4145
4262
|
if (Date.now() - start + intervalMs > timeoutMs) break;
|
|
4146
4263
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
4147
4264
|
}
|
|
4148
4265
|
|
|
4149
|
-
if (!success) {
|
|
4150
|
-
// Timeout or predicate error: drop every attempt's events. The
|
|
4151
|
-
// wait event we emit below is the only trace.
|
|
4152
|
-
recorderTruncate(beforePollIdx);
|
|
4153
|
-
}
|
|
4154
|
-
|
|
4155
4266
|
const errMsg =
|
|
4156
4267
|
predicateError !== undefined
|
|
4157
4268
|
? ((predicateError as Error)?.message ?? String(predicateError))
|
|
@@ -4166,11 +4277,11 @@ async function pollCall<T>(
|
|
|
4166
4277
|
...(errMsg !== undefined ? { error: errMsg } : {}),
|
|
4167
4278
|
}, resv);
|
|
4168
4279
|
|
|
4169
|
-
if (
|
|
4280
|
+
if (seq !== undefined) {
|
|
4170
4281
|
// Group the kept iteration's events under the wait so the UI can
|
|
4171
4282
|
// render them inside the wait card. The wait event itself is the
|
|
4172
4283
|
// very last entry; markChildren skips it via the seq match.
|
|
4173
|
-
recorderMarkChildren(
|
|
4284
|
+
recorderMarkChildren(lastIterStartIdx, seq);
|
|
4174
4285
|
}
|
|
4175
4286
|
|
|
4176
4287
|
if (predicateError !== undefined) throw predicateError;
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,21 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
|
34
34
|
// `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
|
|
35
35
|
export { field } from "./inspect.js";
|
|
36
36
|
|
|
37
|
+
// Render annotations for fake helpers: a helper returns
|
|
38
|
+
// `annotate(value, "email", { … })` when its value is an email, and the
|
|
39
|
+
// fake step it records gains a nested `email` event — the same one the
|
|
40
|
+
// built-in `email()` component records — so the step's panel can draw the
|
|
41
|
+
// mail, tabbing to the raw JSON value. The kind picks the options' type, and the test still gets
|
|
42
|
+
// the raw value, with the raw value's type — see `annotate.ts`.
|
|
43
|
+
export { annotate } from "./annotate.js";
|
|
44
|
+
export type {
|
|
45
|
+
Annotated,
|
|
46
|
+
AnnotationKind,
|
|
47
|
+
AnnotationOptions,
|
|
48
|
+
EmailAnnotation,
|
|
49
|
+
} from "./annotate.js";
|
|
50
|
+
import type { Annotated } from "./annotate.js";
|
|
51
|
+
|
|
37
52
|
// Instrumented client primitives — drop-in replacements for Bun's native
|
|
38
53
|
// clients that record each operation on the test event log and return their
|
|
39
54
|
// results inspect-wrapped, so `expect(...)` on a result links back to the op
|
|
@@ -58,6 +73,8 @@ export type {
|
|
|
58
73
|
FilterOptions,
|
|
59
74
|
ClickOptions,
|
|
60
75
|
BoundingBox,
|
|
76
|
+
FilePayload,
|
|
77
|
+
InputFiles,
|
|
61
78
|
} from "./locator.js";
|
|
62
79
|
import type { Locator } from "./locator.js";
|
|
63
80
|
import {
|
|
@@ -312,6 +329,10 @@ export interface SpectestContext<
|
|
|
312
329
|
/** Read a project file as UTF-8. Relative paths resolve against
|
|
313
330
|
* {@link projectRoot}; absolute paths are read as-is. */
|
|
314
331
|
readProjectFile(path: string): Promise<string>;
|
|
332
|
+
/** Read a project file as bytes — a fixture to post, hash, or compare
|
|
333
|
+
* against what the app under test received. Same path rules as
|
|
334
|
+
* {@link readProjectFile}. */
|
|
335
|
+
readProjectFileBytes(path: string): Promise<Uint8Array>;
|
|
315
336
|
/**
|
|
316
337
|
* Run a command inside a service container. Pass an **array** for exact
|
|
317
338
|
* argv with no shell (`["psql", "-f", "-"]`), or a **string** to run via
|
|
@@ -1627,6 +1648,10 @@ export interface FakeDefinition<
|
|
|
1627
1648
|
* Every call is tracked in the test timeline: it records a `fake` step
|
|
1628
1649
|
* and the return value is tagged so a later `expect(...)` on it nests
|
|
1629
1650
|
* under that step in the UI (same provenance as `fetch`/db results).
|
|
1651
|
+
* The step renders the return value as JSON; wrap it in {@link annotate}
|
|
1652
|
+
* to add a richer view (an email, today) that the step's panel leads with,
|
|
1653
|
+
* the JSON one tab away — the test still receives the raw value, unchanged
|
|
1654
|
+
* and unchanged in type.
|
|
1630
1655
|
*
|
|
1631
1656
|
* Receives the fake's `state` plus a {@link FakeContext} `ctx`, so a
|
|
1632
1657
|
* helper can provision/teardown runtime services just like the handler.
|
|
@@ -1697,12 +1722,20 @@ export type FakesMap = Record<string, FakeDefinition<any, any>>;
|
|
|
1697
1722
|
* `components/k3s.ts`, extended to cover synchronous returns. */
|
|
1698
1723
|
type WrappedHelpers<H> = {
|
|
1699
1724
|
[K in keyof H]: H[K] extends (...args: infer A) => Promise<infer R>
|
|
1700
|
-
? (...args: A) => Promise<Wrapped<R
|
|
1725
|
+
? (...args: A) => Promise<Wrapped<Unannotated<R>>>
|
|
1701
1726
|
: H[K] extends (...args: infer A) => infer R
|
|
1702
|
-
? (...args: A) => [R] extends [void] ? void : Wrapped<R
|
|
1727
|
+
? (...args: A) => [R] extends [void] ? void : Wrapped<Unannotated<R>>
|
|
1703
1728
|
: H[K];
|
|
1704
1729
|
};
|
|
1705
1730
|
|
|
1731
|
+
/** A helper's return type as the *test* sees it. {@link annotate} boxes the
|
|
1732
|
+
* value in an {@link Annotated} to pick how the step renders; the daemon
|
|
1733
|
+
* opens that box at the helper boundary, so the annotation never reaches
|
|
1734
|
+
* the caller — in the types either. Distributes over a union, so a helper
|
|
1735
|
+
* that annotates only when it has something (`return m && annotate(m, "email")`)
|
|
1736
|
+
* still reads as `T | undefined`. */
|
|
1737
|
+
type Unannotated<R> = R extends Annotated<infer U> ? U : R;
|
|
1738
|
+
|
|
1706
1739
|
/** Awaited return type of a fake's `helpers` factory (with each result
|
|
1707
1740
|
* inspect-wrapped, see {@link WrappedHelpers}), or `{ state: S }` (the
|
|
1708
1741
|
* default) when the user didn't ship one. */
|
package/src/locator.ts
CHANGED
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
// author-facing call is exactly one recorded browser event (with its rrweb
|
|
23
23
|
// drain), whatever playwright work it composes underneath.
|
|
24
24
|
|
|
25
|
+
import { Buffer } from "node:buffer";
|
|
25
26
|
import type { Page, Locator as PWLocator } from "playwright-core";
|
|
26
27
|
import type { RecordableFields } from "./browser.js";
|
|
27
28
|
import type { Wrapped } from "./inspect.js";
|
|
29
|
+
import { resolveExistingProjectPath } from "./project-files.js";
|
|
28
30
|
import { truncateUtf8 } from "./recorder.js";
|
|
29
31
|
|
|
30
32
|
/** Default deadline for a locator action/read's target to become actionable.
|
|
@@ -79,6 +81,21 @@ export interface ClickOptions {
|
|
|
79
81
|
modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift">;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/** A file built in the test rather than read from the repo — the argument
|
|
85
|
+
* shape playwright's `setInputFiles` takes, with `buffer` widened to a plain
|
|
86
|
+
* `Uint8Array`/string and `mimeType` optional. */
|
|
87
|
+
export interface FilePayload {
|
|
88
|
+
name: string;
|
|
89
|
+
/** Defaults to `application/octet-stream`. */
|
|
90
|
+
mimeType?: string;
|
|
91
|
+
/** Bytes, or text (encoded UTF-8). */
|
|
92
|
+
buffer: Uint8Array | string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What `setInputFiles` accepts: repo-relative (or absolute) paths, built
|
|
96
|
+
* files, or `[]` to clear the input. */
|
|
97
|
+
export type InputFiles = string | string[] | FilePayload | FilePayload[];
|
|
98
|
+
|
|
82
99
|
export interface TimeoutOption {
|
|
83
100
|
timeout?: number;
|
|
84
101
|
}
|
|
@@ -454,6 +471,34 @@ async function stampActionPoint(
|
|
|
454
471
|
}
|
|
455
472
|
}
|
|
456
473
|
|
|
474
|
+
/** Fold a {@link InputFiles} argument into the one playwright takes: repo
|
|
475
|
+
* paths resolved to their in-VM location (see project-files.ts), built files
|
|
476
|
+
* given a default mime type and a real `Buffer`. The names come back too —
|
|
477
|
+
* they are what the timeline step shows, and the bytes never go near it. */
|
|
478
|
+
function lowerInputFiles(files: InputFiles): {
|
|
479
|
+
arg: string[] | { name: string; mimeType: string; buffer: Buffer }[];
|
|
480
|
+
names: string[];
|
|
481
|
+
} {
|
|
482
|
+
const many = Array.isArray(files) ? files : [files];
|
|
483
|
+
// `[]` clears the input, and every() is true for it — so an empty call
|
|
484
|
+
// lands here and lowers to an empty path list, which is what clears.
|
|
485
|
+
if (many.every((f) => typeof f === "string")) {
|
|
486
|
+
const paths = (many as string[]).map((f) => resolveExistingProjectPath(f, "fixture"));
|
|
487
|
+
return { arg: paths, names: paths.map((p) => p.split("/").pop() ?? p) };
|
|
488
|
+
}
|
|
489
|
+
if (many.some((f) => typeof f === "string")) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
"setInputFiles: pass either repo paths or built files — not both in one call",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const payloads = (many as FilePayload[]).map((f) => ({
|
|
495
|
+
name: f.name,
|
|
496
|
+
mimeType: f.mimeType ?? "application/octet-stream",
|
|
497
|
+
buffer: typeof f.buffer === "string" ? Buffer.from(f.buffer, "utf8") : Buffer.from(f.buffer),
|
|
498
|
+
}));
|
|
499
|
+
return { arg: payloads, names: payloads.map((f) => f.name) };
|
|
500
|
+
}
|
|
501
|
+
|
|
457
502
|
// ────────────────────────────────────────────────────────────────────────
|
|
458
503
|
// The Locator interface
|
|
459
504
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -513,6 +558,25 @@ export interface Locator {
|
|
|
513
558
|
values: string | string[] | { label?: string; value?: string; index?: number },
|
|
514
559
|
opts?: TimeoutOption,
|
|
515
560
|
): Promise<Wrapped<string[]>>;
|
|
561
|
+
/**
|
|
562
|
+
* Give an `<input type="file">` its files — the upload primitive, since a
|
|
563
|
+
* browser refuses a script-set value on a file input. Targets the control a
|
|
564
|
+
* `<label>` points at, and does **not** require the input to be visible, so
|
|
565
|
+
* the hidden input behind a drop zone or a styled "Choose file" button is
|
|
566
|
+
* the thing to select.
|
|
567
|
+
*
|
|
568
|
+
* A string is a path in your repo, relative to `ctx.projectRoot` (absolute
|
|
569
|
+
* paths are taken as-is) — keep fixtures in `spectest/tests/fixtures/`, which
|
|
570
|
+
* is out of the environment cache key, so adding one still gives a warm
|
|
571
|
+
* start. Pass a {@link FilePayload} instead for a file the test builds, and
|
|
572
|
+
* `[]` to clear the input.
|
|
573
|
+
*
|
|
574
|
+
* ```ts
|
|
575
|
+
* await b.locator("#file").setInputFiles("spectest/tests/fixtures/invoice.pdf");
|
|
576
|
+
* await b.getByLabel("Avatar").setInputFiles({ name: "a.txt", buffer: "hi" });
|
|
577
|
+
* ```
|
|
578
|
+
*/
|
|
579
|
+
setInputFiles(files: InputFiles, opts?: TimeoutOption): Promise<void>;
|
|
516
580
|
hover(opts?: TimeoutOption): Promise<void>;
|
|
517
581
|
focus(opts?: TimeoutOption): Promise<void>;
|
|
518
582
|
blur(opts?: TimeoutOption): Promise<void>;
|
|
@@ -663,6 +727,12 @@ export function makeLocator(
|
|
|
663
727
|
act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
|
|
664
728
|
selectOption: (values, opts) =>
|
|
665
729
|
read("selectOption", (l) => l.selectOption(values as never, { timeout: opts?.timeout })),
|
|
730
|
+
setInputFiles: (files, opts) => {
|
|
731
|
+
const { arg, names } = lowerInputFiles(files);
|
|
732
|
+
return act("setInputFiles", { files: names }, (l) =>
|
|
733
|
+
l.setInputFiles(arg, { timeout: opts?.timeout }),
|
|
734
|
+
);
|
|
735
|
+
},
|
|
666
736
|
hover: (opts) => act("hover", {}, (l) => l.hover({ timeout: opts?.timeout })),
|
|
667
737
|
focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
|
|
668
738
|
blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Where a path in the user's repo lands inside the VM.
|
|
2
|
+
//
|
|
3
|
+
// The project exists in the guest TWICE, and the two copies are not refreshed
|
|
4
|
+
// at the same time (see `env.rs`):
|
|
5
|
+
//
|
|
6
|
+
// /workspace the whole repo. Written by a cold or delta
|
|
7
|
+
// start only — a warm start never re-uploads it.
|
|
8
|
+
// /opt/spectest/app/spectest the user's `spectest/` directory. Re-uploaded
|
|
9
|
+
// on EVERY start, warm ones included.
|
|
10
|
+
//
|
|
11
|
+
// A warm start happens only when the warm-template hash matches, so /workspace
|
|
12
|
+
// is correct for every file that is IN that hash. It can be behind for the two
|
|
13
|
+
// kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
|
|
14
|
+
// edit keeps the fast start) and anything a project lists in
|
|
15
|
+
// `spectest/.envignore`.
|
|
16
|
+
//
|
|
17
|
+
// Hence the rule below: a path under `spectest/` resolves against the app copy
|
|
18
|
+
// first, because that copy is always current — this is what lets a fixture live
|
|
19
|
+
// in `spectest/tests/fixtures/` and still be found after it was added. Any
|
|
20
|
+
// other path resolves against /workspace, which the hash keeps current.
|
|
21
|
+
//
|
|
22
|
+
// The remaining hole is a file that `.envignore` excludes AND that sits outside
|
|
23
|
+
// `spectest/`: /workspace holds whatever the cold start uploaded, so a later
|
|
24
|
+
// edit is invisible. Nothing can repair those bytes at read time, so we refuse
|
|
25
|
+
// to read them instead of returning stale content. The control plane writes the
|
|
26
|
+
// exact path list it excluded (env.rs) into ENV_IGNORED_FILE.
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
/** The extracted repo — `ctx.projectRoot`. */
|
|
32
|
+
export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
33
|
+
/** The app dir; the user's `spectest/` sits directly under it. */
|
|
34
|
+
export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
35
|
+
|
|
36
|
+
/** Paths (repo-relative) the warm-template hash skipped because of
|
|
37
|
+
* `spectest/.envignore`. Written by the control plane at env start; absent
|
|
38
|
+
* when the project ships no `.envignore`. */
|
|
39
|
+
const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
|
|
40
|
+
|
|
41
|
+
let _envIgnored: Set<string> | undefined;
|
|
42
|
+
function envIgnored(): Set<string> {
|
|
43
|
+
if (_envIgnored) return _envIgnored;
|
|
44
|
+
let paths: string[] = [];
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8")) as { paths?: string[] };
|
|
47
|
+
paths = raw.paths ?? [];
|
|
48
|
+
} catch {
|
|
49
|
+
/* No file (no .envignore, or an older env) — nothing to refuse. */
|
|
50
|
+
}
|
|
51
|
+
_envIgnored = new Set(paths);
|
|
52
|
+
return _envIgnored;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Repo-relative form of `p`: no leading `./`, POSIX separators. */
|
|
56
|
+
function relative(p: string): string {
|
|
57
|
+
return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Absolute in-VM path for a path in the user's repo. Absolute input is
|
|
62
|
+
* returned as-is. A relative path resolves against the project, preferring the
|
|
63
|
+
* copy that a warm start refreshes (see the header).
|
|
64
|
+
*
|
|
65
|
+
* A file that is missing everywhere resolves to its /workspace candidate, so
|
|
66
|
+
* the caller's own `fs` error names a real path — `readProjectFile` keeps
|
|
67
|
+
* reporting ENOENT the way it always has.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveProjectPath(p: string): string {
|
|
70
|
+
if (path.isAbsolute(p)) return p;
|
|
71
|
+
const rel = relative(p);
|
|
72
|
+
if (rel.startsWith("spectest/")) {
|
|
73
|
+
const app = path.join(APP_DIR, rel);
|
|
74
|
+
if (existsSync(app)) return app;
|
|
75
|
+
}
|
|
76
|
+
if (envIgnored().has(rel)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`project file "${p}" is excluded by spectest/.envignore, so the copy in the VM ` +
|
|
79
|
+
`is whatever a cold start uploaded and can be out of date. Move it under ` +
|
|
80
|
+
`spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return path.join(WORKSPACE, rel);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** {@link resolveProjectPath}, but a missing file is an error that names every
|
|
87
|
+
* place we looked. For inputs a user hands us by name — a fixture — where an
|
|
88
|
+
* ENOENT on one guessed path reads as a spectest bug rather than a typo. */
|
|
89
|
+
export function resolveExistingProjectPath(p: string, what = "file"): string {
|
|
90
|
+
const resolved = resolveProjectPath(p);
|
|
91
|
+
if (existsSync(resolved)) return resolved;
|
|
92
|
+
const rel = relative(p);
|
|
93
|
+
const looked = path.isAbsolute(p)
|
|
94
|
+
? [p]
|
|
95
|
+
: rel.startsWith("spectest/")
|
|
96
|
+
? [path.join(APP_DIR, rel), path.join(WORKSPACE, rel)]
|
|
97
|
+
: [path.join(WORKSPACE, rel)];
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${what} "${p}" is not in the VM (looked in ${looked.join(", ")}). Paths are ` +
|
|
100
|
+
`relative to your repo root. Check that the file is committed and not ` +
|
|
101
|
+
`excluded by .gitignore/.spectestignore.`,
|
|
102
|
+
);
|
|
103
|
+
}
|