@specific.dev/spectest 0.131.11 → 0.131.12

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.
@@ -176,7 +176,12 @@ export function replayWebSocket(args) {
176
176
  const progress = record.inputMode === "anchors"
177
177
  ? ` occurrence ${record.inputOccurrences[position]}; observed ${occurrences.get(expected) ?? 0} occurrences; received ${receivedMessages} messages, ${receivedCandidates} candidates; last candidate ${lastCandidate ?? "<none>"}`
178
178
  : "";
179
- return diagnostic(`required input never arrived; expected ${expected}${progress}`);
179
+ const reordered = record.inputMode === "anchors"
180
+ && (occurrences.get(expected) ?? 0) >= record.inputOccurrences[position];
181
+ const detail = reordered
182
+ ? "required input arrived before preceding anchors matched"
183
+ : "required input never arrived";
184
+ return diagnostic(`${detail}; expected ${expected}${progress}`);
180
185
  };
181
186
  const armDeadline = () => {
182
187
  clearTimeout(deadline);
package/dist/daemon.js CHANGED
@@ -52,7 +52,7 @@ import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harne
52
52
  import { dispatchFake } from "./harness/fake-websocket.js";
53
53
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
54
54
  import { summarizeBuildKit } from "./harness/buildkit-progress.js";
55
- import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
55
+ import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta, mergeServiceLogDeltas } from "./harness/log-delta.js";
56
56
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
57
57
  import { pollUntilReady } from "./harness/ready-poll.js";
58
58
  import { fsUsageDiagnostics } from "./harness/wrapper-rules.js";
@@ -1158,6 +1158,12 @@ async function runContainer(svc, tag, volumeFlags,
1158
1158
  // runtime `startService` (whose service isn't in LOWERED) to give the new
1159
1159
  // container docker-native multi-label resolution for its `hostnames`.
1160
1160
  extraAliases = []) {
1161
+ // Preserve the previous incarnation before the name is reused.
1162
+ if (RUNTIME_SERVICES.has(svc.name))
1163
+ await stopRuntimeService(svc.name);
1164
+ STOPPED_LOG_SERVICES.delete(svc.name);
1165
+ LOG_MARKERS.delete(`${svc.name}\0stdout`);
1166
+ LOG_MARKERS.delete(`${svc.name}\0stderr`);
1161
1167
  // Idempotent: clean up any leftover container with the same name.
1162
1168
  await docker(["rm", "-f", svc.name], 30_000);
1163
1169
  // The argv, and every rule shaping it, lives in `harness/container-run.ts`
@@ -2469,6 +2475,15 @@ async function stopRuntimeService(name) {
2469
2475
  const t0 = Date.now();
2470
2476
  const resv = reserveEvent();
2471
2477
  const svc = RUNTIME_SERVICES.get(name);
2478
+ // Freeze log production before reading, while Docker still owns the logs.
2479
+ // Keep force-stop semantics; removing first destroys the only copy.
2480
+ await docker(["kill", name], 30_000);
2481
+ if (!STOPPED_LOG_SERVICES.has(name)) {
2482
+ const delta = await captureServiceLogDelta(name, true);
2483
+ const previous = STOPPED_LOG_DELTAS.get(name);
2484
+ STOPPED_LOG_DELTAS.set(name, previous ? mergeServiceLogDeltas(previous, delta) : delta);
2485
+ STOPPED_LOG_SERVICES.add(name);
2486
+ }
2472
2487
  await docker(["rm", "-f", name], 30_000);
2473
2488
  RUNTIME_SERVICES.delete(name);
2474
2489
  for (const entry of svc?.tls ?? [])
@@ -2921,6 +2936,10 @@ function parkCoverageBundle(caseId, captures) {
2921
2936
  * so it travels with the snapshot into every fork.
2922
2937
  */
2923
2938
  const LOG_MARKERS = new Map();
2939
+ // Fork with the harness, like markers. Deltas drain once per case; names remain
2940
+ // excluded from Docker reads until a replacement container starts.
2941
+ const STOPPED_LOG_DELTAS = new Map();
2942
+ const STOPPED_LOG_SERVICES = new Set();
2924
2943
  /**
2925
2944
  * Inspect a container's run state for {@link ServiceLogDelta}: `{}` while
2926
2945
  * running (or when inspect fails — a removed container has no state left to
@@ -2949,43 +2968,51 @@ async function captureServiceLogDeltas() {
2949
2968
  byName.set(s.name, s);
2950
2969
  for (const [name, s] of RUNTIME_SERVICES)
2951
2970
  byName.set(name, s);
2952
- const services = [...byName.values()];
2953
- return Promise.all(services.map(async (svc) => {
2954
- const [r, stop] = await Promise.all([
2955
- docker(["logs", "--timestamps", svc.name], 30_000),
2956
- containerStopState(svc.name),
2957
- ]);
2958
- if (r.code !== 0) {
2959
- // Container gone/renamed — surface the CLI error, leave markers put.
2960
- const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
2961
- return {
2962
- service: svc.name,
2963
- stdout: "",
2964
- stdoutTruncated: false,
2965
- stdoutReset: false,
2966
- stderr: err.value,
2967
- stderrTruncated: err.truncated,
2968
- stderrReset: false,
2969
- ...stop,
2970
- };
2971
- }
2972
- const outKey = `${svc.name}stdout`;
2973
- const errKey = `${svc.name}stderr`;
2974
- const out = streamDelta(r.stdout, LOG_MARKERS.get(outKey)?.lines ?? 0);
2975
- const err = streamDelta(r.stderr, LOG_MARKERS.get(errKey)?.lines ?? 0);
2976
- LOG_MARKERS.set(outKey, { lines: out.total });
2977
- LOG_MARKERS.set(errKey, { lines: err.total });
2971
+ const deltas = new Map(STOPPED_LOG_DELTAS);
2972
+ for (const delta of await Promise.all([...byName.keys()]
2973
+ .filter(name => !STOPPED_LOG_SERVICES.has(name)).map(name => captureServiceLogDelta(name)))) {
2974
+ const previous = deltas.get(delta.service);
2975
+ deltas.set(delta.service, previous ? mergeServiceLogDeltas(previous, delta) : delta);
2976
+ }
2977
+ STOPPED_LOG_DELTAS.clear();
2978
+ return [...deltas.values()];
2979
+ }
2980
+ async function captureServiceLogDelta(name, final = false) {
2981
+ const [r, stop] = await Promise.all([
2982
+ docker(["logs", "--timestamps", name], 30_000),
2983
+ containerStopState(name),
2984
+ ]);
2985
+ if (r.code !== 0) {
2986
+ // Container gone/renamed — surface the CLI error, leave markers put.
2987
+ const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
2978
2988
  return {
2979
- service: svc.name,
2980
- stdout: out.delta,
2981
- stdoutTruncated: out.truncated,
2982
- stdoutReset: out.reset,
2983
- stderr: err.delta,
2989
+ service: name,
2990
+ stdout: "",
2991
+ stdoutTruncated: false,
2992
+ stdoutReset: false,
2993
+ stderr: err.value,
2984
2994
  stderrTruncated: err.truncated,
2985
- stderrReset: err.reset,
2995
+ stderrReset: false,
2986
2996
  ...stop,
2987
2997
  };
2988
- }));
2998
+ }
2999
+ const outKey = `${name}stdout`;
3000
+ const errKey = `${name}stderr`;
3001
+ const complete = (text) => final && text && !text.endsWith("\n") ? text + "\n" : text;
3002
+ const out = streamDelta(complete(r.stdout), LOG_MARKERS.get(outKey)?.lines ?? 0);
3003
+ const err = streamDelta(complete(r.stderr), LOG_MARKERS.get(errKey)?.lines ?? 0);
3004
+ LOG_MARKERS.set(outKey, { lines: out.total });
3005
+ LOG_MARKERS.set(errKey, { lines: err.total });
3006
+ return {
3007
+ service: name,
3008
+ stdout: out.delta,
3009
+ stdoutTruncated: out.truncated,
3010
+ stdoutReset: out.reset,
3011
+ stderr: err.delta,
3012
+ stderrTruncated: err.truncated,
3013
+ stderrReset: err.reset,
3014
+ ...stop,
3015
+ };
2989
3016
  }
2990
3017
  // ────────────────────────────────────────────────────────────────────────
2991
3018
  // Coverage capture (opt-in per service; see coverage.ts + harness/coverage.ts)
@@ -52,3 +52,12 @@ export interface StreamDelta {
52
52
  * capture repeats the other half, since the marker counts lines.
53
53
  */
54
54
  export declare function streamDelta(full: string, marker: number): StreamDelta;
55
+ /** Join successive container incarnations into one bounded per-service delta. */
56
+ export declare function mergeServiceLogDeltas<T extends {
57
+ stdout: string;
58
+ stderr: string;
59
+ stdoutTruncated: boolean;
60
+ stderrTruncated: boolean;
61
+ stdoutReset: boolean;
62
+ stderrReset: boolean;
63
+ }>(earlier: T, later: T): T;
@@ -81,3 +81,13 @@ export function streamDelta(full, marker) {
81
81
  const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
82
82
  return { delta: capped.value, total, reset, truncated: capped.truncated };
83
83
  }
84
+ /** Join successive container incarnations into one bounded per-service delta. */
85
+ export function mergeServiceLogDeltas(earlier, later) {
86
+ const stdout = capMiddle(earlier.stdout + later.stdout, LOG_DELTA_MAX_BYTES);
87
+ const stderr = capMiddle(earlier.stderr + later.stderr, LOG_DELTA_MAX_BYTES);
88
+ return { ...later, stdout: stdout.value, stderr: stderr.value,
89
+ stdoutTruncated: earlier.stdoutTruncated || later.stdoutTruncated || stdout.truncated,
90
+ stderrTruncated: earlier.stderrTruncated || later.stderrTruncated || stderr.truncated,
91
+ stdoutReset: earlier.stdoutReset || later.stdoutReset,
92
+ stderrReset: earlier.stderrReset || later.stderrReset };
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.11",
3
+ "version": "0.131.12",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -218,7 +218,12 @@ export function replayWebSocket(args: {
218
218
  const progress = record.inputMode === "anchors"
219
219
  ? ` occurrence ${record.inputOccurrences![position]}; observed ${occurrences.get(expected) ?? 0} occurrences; received ${receivedMessages} messages, ${receivedCandidates} candidates; last candidate ${lastCandidate ?? "<none>"}`
220
220
  : "";
221
- return diagnostic(`required input never arrived; expected ${expected}${progress}`);
221
+ const reordered = record.inputMode === "anchors"
222
+ && (occurrences.get(expected) ?? 0) >= record.inputOccurrences![position];
223
+ const detail = reordered
224
+ ? "required input arrived before preceding anchors matched"
225
+ : "required input never arrived";
226
+ return diagnostic(`${detail}; expected ${expected}${progress}`);
222
227
  };
223
228
  const armDeadline = () => {
224
229
  clearTimeout(deadline);
@@ -452,3 +452,26 @@ test("a reached response anchor catches up older output without adding its stale
452
452
  expect(c.sent).toEqual([...messages.map(m => m.data), "response"]);
453
453
  await c.close(); expect(h.state.websocketErrors).toEqual([]);
454
454
  });
455
+
456
+
457
+ test("an anchor observed before its predecessor is diagnosed as out of order", async () => {
458
+ const key: WebSocketKey<null> = { initial: null,
459
+ input(state, message) { return { state, key: message }; },
460
+ output(state) { return { state, anchorPreviousInput: true }; },
461
+ };
462
+ const hash = (value: string) => websocketMatcher(() => value)("x")!;
463
+ const h = await harness([{ request, inputMode: "anchors",
464
+ inputs: [hash("start"), hash("warmup"), hash("question")],
465
+ inputOccurrences: [1, 1, 1],
466
+ messages: [{ type: "text", data: "answer", afterInput: 3, delayMs: 0 }],
467
+ }], key);
468
+ const c = await h.connect();
469
+ await c.message("start");
470
+ await c.message("question");
471
+ await c.message("warmup");
472
+ await tick();
473
+ expect(c.sent).toEqual([]);
474
+ await c.close();
475
+ expect(h.state.websocketErrors.join()).toContain("input #3: required input arrived before preceding anchors matched");
476
+ expect(h.state.websocketErrors.join()).toContain("occurrence 1; observed 1 occurrences");
477
+ });
package/src/daemon.ts CHANGED
@@ -109,7 +109,7 @@ import {
109
109
  type GroupMeta,
110
110
  } from "./harness/catalogue.js";
111
111
  import { summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
112
- import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
112
+ import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta, mergeServiceLogDeltas } from "./harness/log-delta.js";
113
113
  import {
114
114
  resolveHostPath as resolveVolumeHostPath,
115
115
  sanitizeSegment,
@@ -1489,6 +1489,11 @@ async function runContainer(
1489
1489
  // container docker-native multi-label resolution for its `hostnames`.
1490
1490
  extraAliases: string[] = [],
1491
1491
  ): Promise<void> {
1492
+ // Preserve the previous incarnation before the name is reused.
1493
+ if (RUNTIME_SERVICES.has(svc.name)) await stopRuntimeService(svc.name);
1494
+ STOPPED_LOG_SERVICES.delete(svc.name);
1495
+ LOG_MARKERS.delete(`${svc.name}\0stdout`);
1496
+ LOG_MARKERS.delete(`${svc.name}\0stderr`);
1492
1497
  // Idempotent: clean up any leftover container with the same name.
1493
1498
  await docker(["rm", "-f", svc.name], 30_000);
1494
1499
 
@@ -2947,6 +2952,15 @@ async function stopRuntimeService(name: string): Promise<void> {
2947
2952
  const t0 = Date.now();
2948
2953
  const resv = reserveEvent();
2949
2954
  const svc = RUNTIME_SERVICES.get(name);
2955
+ // Freeze log production before reading, while Docker still owns the logs.
2956
+ // Keep force-stop semantics; removing first destroys the only copy.
2957
+ await docker(["kill", name], 30_000);
2958
+ if (!STOPPED_LOG_SERVICES.has(name)) {
2959
+ const delta = await captureServiceLogDelta(name, true);
2960
+ const previous = STOPPED_LOG_DELTAS.get(name);
2961
+ STOPPED_LOG_DELTAS.set(name, previous ? mergeServiceLogDeltas(previous, delta) : delta);
2962
+ STOPPED_LOG_SERVICES.add(name);
2963
+ }
2950
2964
  await docker(["rm", "-f", name], 30_000);
2951
2965
  RUNTIME_SERVICES.delete(name);
2952
2966
  for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname);
@@ -3482,6 +3496,10 @@ function parkCoverageBundle(
3482
3496
  * so it travels with the snapshot into every fork.
3483
3497
  */
3484
3498
  const LOG_MARKERS = new Map<string, { lines: number }>();
3499
+ // Fork with the harness, like markers. Deltas drain once per case; names remain
3500
+ // excluded from Docker reads until a replacement container starts.
3501
+ const STOPPED_LOG_DELTAS = new Map<string, ServiceLogDelta>();
3502
+ const STOPPED_LOG_SERVICES = new Set<string>();
3485
3503
 
3486
3504
  /** Per-(service, stream) delta byte cap. Over this we keep head+tail and
3487
3505
  * elide the middle — the head preserves the continuation from the parent,
@@ -3537,45 +3555,52 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
3537
3555
  const byName = new Map<string, NamedService>();
3538
3556
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
3539
3557
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
3540
- const services = [...byName.values()];
3541
- return Promise.all(
3542
- services.map(async (svc): Promise<ServiceLogDelta> => {
3543
- const [r, stop] = await Promise.all([
3544
- docker(["logs", "--timestamps", svc.name], 30_000),
3545
- containerStopState(svc.name),
3546
- ]);
3547
- if (r.code !== 0) {
3548
- // Container gone/renamed — surface the CLI error, leave markers put.
3549
- const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
3550
- return {
3551
- service: svc.name,
3552
- stdout: "",
3553
- stdoutTruncated: false,
3554
- stdoutReset: false,
3555
- stderr: err.value,
3556
- stderrTruncated: err.truncated,
3557
- stderrReset: false,
3558
- ...stop,
3559
- };
3560
- }
3561
- const outKey = `${svc.name}stdout`;
3562
- const errKey = `${svc.name}stderr`;
3563
- const out = streamDelta(r.stdout, LOG_MARKERS.get(outKey)?.lines ?? 0);
3564
- const err = streamDelta(r.stderr, LOG_MARKERS.get(errKey)?.lines ?? 0);
3565
- LOG_MARKERS.set(outKey, { lines: out.total });
3566
- LOG_MARKERS.set(errKey, { lines: err.total });
3567
- return {
3568
- service: svc.name,
3569
- stdout: out.delta,
3570
- stdoutTruncated: out.truncated,
3571
- stdoutReset: out.reset,
3572
- stderr: err.delta,
3573
- stderrTruncated: err.truncated,
3574
- stderrReset: err.reset,
3575
- ...stop,
3576
- };
3577
- }),
3578
- );
3558
+ const deltas = new Map(STOPPED_LOG_DELTAS);
3559
+ for (const delta of await Promise.all([...byName.keys()]
3560
+ .filter(name => !STOPPED_LOG_SERVICES.has(name)).map(name => captureServiceLogDelta(name)))) {
3561
+ const previous = deltas.get(delta.service);
3562
+ deltas.set(delta.service, previous ? mergeServiceLogDeltas(previous, delta) : delta);
3563
+ }
3564
+ STOPPED_LOG_DELTAS.clear();
3565
+ return [...deltas.values()];
3566
+ }
3567
+
3568
+ async function captureServiceLogDelta(name: string, final = false): Promise<ServiceLogDelta> {
3569
+ const [r, stop] = await Promise.all([
3570
+ docker(["logs", "--timestamps", name], 30_000),
3571
+ containerStopState(name),
3572
+ ]);
3573
+ if (r.code !== 0) {
3574
+ // Container gone/renamed — surface the CLI error, leave markers put.
3575
+ const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
3576
+ return {
3577
+ service: name,
3578
+ stdout: "",
3579
+ stdoutTruncated: false,
3580
+ stdoutReset: false,
3581
+ stderr: err.value,
3582
+ stderrTruncated: err.truncated,
3583
+ stderrReset: false,
3584
+ ...stop,
3585
+ };
3586
+ }
3587
+ const outKey = `${name}stdout`;
3588
+ const errKey = `${name}stderr`;
3589
+ const complete = (text: string) => final && text && !text.endsWith("\n") ? text + "\n" : text;
3590
+ const out = streamDelta(complete(r.stdout), LOG_MARKERS.get(outKey)?.lines ?? 0);
3591
+ const err = streamDelta(complete(r.stderr), LOG_MARKERS.get(errKey)?.lines ?? 0);
3592
+ LOG_MARKERS.set(outKey, { lines: out.total });
3593
+ LOG_MARKERS.set(errKey, { lines: err.total });
3594
+ return {
3595
+ service: name,
3596
+ stdout: out.delta,
3597
+ stdoutTruncated: out.truncated,
3598
+ stdoutReset: out.reset,
3599
+ stderr: err.delta,
3600
+ stderrTruncated: err.truncated,
3601
+ stderrReset: err.reset,
3602
+ ...stop,
3603
+ };
3579
3604
  }
3580
3605
 
3581
3606
  // ────────────────────────────────────────────────────────────────────────
@@ -123,3 +123,20 @@ describe("streamDelta", () => {
123
123
  expect(out.delta.length).toBeLessThan(big.length);
124
124
  });
125
125
  });
126
+
127
+ test("repeated service incarnations retain both ends within the log cap", async () => {
128
+ const { mergeServiceLogDeltas, LOG_DELTA_MAX_BYTES } = await import("./log-delta");
129
+ const earlier = { stdout: "first\n" + "x".repeat(LOG_DELTA_MAX_BYTES), stderr: "old error\n",
130
+ stdoutTruncated: false, stderrTruncated: true, stdoutReset: false, stderrReset: false,
131
+ stopped: true, exitCode: 137 };
132
+ const later = { ...earlier, stdout: "last\n", stderr: "new error\n", stderrTruncated: false,
133
+ stopped: false, exitCode: 0 };
134
+ const result = mergeServiceLogDeltas(earlier, later);
135
+ expect(result.stdout.startsWith("first\n")).toBe(true);
136
+ expect(result.stdout.endsWith("last\n")).toBe(true);
137
+ expect(result.stdout.length).toBeLessThan(LOG_DELTA_MAX_BYTES + 100);
138
+ expect(result.stdoutTruncated).toBe(true);
139
+ expect(result.stderr).toBe("old error\nnew error\n");
140
+ expect(result.stderrTruncated).toBe(true);
141
+ expect(result.stopped).toBe(false);
142
+ });
@@ -98,3 +98,18 @@ export function streamDelta(full: string, marker: number): StreamDelta {
98
98
  const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
99
99
  return { delta: capped.value, total, reset, truncated: capped.truncated };
100
100
  }
101
+
102
+ /** Join successive container incarnations into one bounded per-service delta. */
103
+ export function mergeServiceLogDeltas<T extends {
104
+ stdout: string; stderr: string;
105
+ stdoutTruncated: boolean; stderrTruncated: boolean;
106
+ stdoutReset: boolean; stderrReset: boolean;
107
+ }>(earlier: T, later: T): T {
108
+ const stdout = capMiddle(earlier.stdout + later.stdout, LOG_DELTA_MAX_BYTES);
109
+ const stderr = capMiddle(earlier.stderr + later.stderr, LOG_DELTA_MAX_BYTES);
110
+ return { ...later, stdout: stdout.value, stderr: stderr.value,
111
+ stdoutTruncated: earlier.stdoutTruncated || later.stdoutTruncated || stdout.truncated,
112
+ stderrTruncated: earlier.stderrTruncated || later.stderrTruncated || stderr.truncated,
113
+ stdoutReset: earlier.stdoutReset || later.stdoutReset,
114
+ stderrReset: earlier.stderrReset || later.stderrReset };
115
+ }
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("stopped service logs survive removal and are emitted only in their owning case", async () => {
7
+ const root = await mkdtemp(join(tmpdir(), "spectest-stopped-logs-"));
8
+ try {
9
+ await mkdir(join(root, "spectest"));
10
+ await mkdir(join(root, "bin"));
11
+ await writeFile(join(root, "bin/docker"), `#!/bin/sh
12
+ case "$1" in
13
+ kill) touch "$SPECTEST_APP_DIR/stopped" ;;
14
+ rm) touch "$SPECTEST_APP_DIR/removed" ;;
15
+ run) rm -f "$SPECTEST_APP_DIR/stopped" "$SPECTEST_APP_DIR/removed" ;;
16
+ inspect) echo 'exited 137' ;;
17
+ logs)
18
+ if [ -f "$SPECTEST_APP_DIR/removed" ]; then echo 'container removed' >&2; exit 1; fi
19
+ if [ -f "$SPECTEST_APP_DIR/stopped" ]; then
20
+ printf 'startup\nlast diagnostic\n'
21
+ printf 'failure detail' >&2
22
+ else printf 'startup\n'; fi ;;
23
+ esac
24
+ `, { mode: 0o755 });
25
+ await writeFile(join(root, "spectest/index.ts"), `
26
+ import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
27
+ const env = defineEnvironment({ name: "logs", services: {
28
+ agent: { image: { type: "registry", reference: "busybox" } }
29
+ }});
30
+ export default env.project([
31
+ env.test("parent", async () => {}),
32
+ env.test("stop", async ctx => { await ctx.stopService("agent"); await ctx.stopService("agent"); }),
33
+ env.test("child", async () => {}),
34
+ env.test("reuse", async ctx => {
35
+ const spec = { name: "runtime", image: { type: "registry", reference: "busybox" } };
36
+ await ctx.startService(spec);
37
+ await ctx.stopService("runtime");
38
+ await ctx.startService(spec);
39
+ await ctx.stopService("runtime");
40
+ }),
41
+ ]);
42
+ `);
43
+ const script = `
44
+ import { strict as assert } from "node:assert";
45
+ import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
46
+ const loaded = await METHODS.load({});
47
+ const parent = await METHODS.run({caseId: loaded.cases[0].id});
48
+ assert.equal(parent.serviceLogDeltas[0].stdout, "startup\\n");
49
+ const stopped = await METHODS.run({caseId: loaded.cases[1].id});
50
+ assert.equal(stopped.status, "passed", JSON.stringify(stopped));
51
+ assert.equal(stopped.serviceLogDeltas.length, 1);
52
+ assert.equal(stopped.serviceLogDeltas[0].stdout, "last diagnostic\\n");
53
+ assert.equal(stopped.serviceLogDeltas[0].stderr, "failure detail\\n");
54
+ assert.equal(stopped.serviceLogDeltas[0].stopped, true);
55
+ assert.equal(stopped.serviceLogDeltas[0].exitCode, 137);
56
+ const child = await METHODS.run({caseId: loaded.cases[2].id});
57
+ assert.deepEqual(child.serviceLogDeltas, []);
58
+ const reuse = await METHODS.run({caseId: loaded.cases[3].id});
59
+ assert.equal(reuse.status, "passed", JSON.stringify(reuse));
60
+ const logs = reuse.serviceLogDeltas.find(d => d.service === "runtime");
61
+ assert.equal(logs.stdout, "startup\\nlast diagnostic\\nstartup\\nlast diagnostic\\n");
62
+ assert.equal(logs.stderr, "failure detail\\nfailure detail\\n");
63
+ process.exit(0);
64
+ `;
65
+ const child = Bun.spawn([process.execPath, "--eval", script], {
66
+ env: { ...process.env, SPECTEST_APP_DIR: root, PATH: `${root}/bin:${process.env.PATH}` },
67
+ stdout: "pipe", stderr: "pipe",
68
+ });
69
+ const [code, stdout, stderr] = await Promise.all([
70
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
71
+ ]);
72
+ expect(code, stdout + stderr).toBe(0);
73
+ } finally { await rm(root, { recursive: true, force: true }); }
74
+ }, 20_000);