@specific.dev/spectest 0.131.10 → 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.
@@ -154,6 +154,8 @@ export function replayWebSocket(args) {
154
154
  let deadline;
155
155
  let stopped = false;
156
156
  let sentClose = false;
157
+ let receivedMessages = 0, receivedCandidates = 0;
158
+ let lastCandidate;
157
159
  const diagnostic = (detail) => new Error(`${label}, input #${position + 1}: ${detail}`);
158
160
  const clear = () => { clearTimeout(timer); clearTimeout(deadline); };
159
161
  const finish = (error) => {
@@ -169,7 +171,18 @@ export function replayWebSocket(args) {
169
171
  finish(error);
170
172
  socket?.close(1011, "WebSocket replay failed");
171
173
  };
172
- const missing = () => diagnostic(`required input never arrived; expected ${record.inputs[position]}`);
174
+ const missing = () => {
175
+ const expected = record.inputs[position];
176
+ const progress = record.inputMode === "anchors"
177
+ ? ` occurrence ${record.inputOccurrences[position]}; observed ${occurrences.get(expected) ?? 0} occurrences; received ${receivedMessages} messages, ${receivedCandidates} candidates; last candidate ${lastCandidate ?? "<none>"}`
178
+ : "";
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}`);
185
+ };
173
186
  const armDeadline = () => {
174
187
  clearTimeout(deadline);
175
188
  if (position < record.inputs.length)
@@ -251,10 +264,13 @@ export function replayWebSocket(args) {
251
264
  if (stopped)
252
265
  return;
253
266
  try {
267
+ receivedMessages++;
254
268
  if (args.match.candidates) {
255
269
  let matched = false;
256
270
  for (const actual of args.match.candidates(message)) {
257
271
  const count = occurrence(occurrences, actual);
272
+ receivedCandidates++;
273
+ lastCandidate = actual;
258
274
  if (actual !== record.inputs[position] || count !== record.inputOccurrences[position])
259
275
  continue;
260
276
  barriers[++position] = performance.now();
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)
@@ -4423,9 +4450,8 @@ async function runOne(testCase) {
4423
4450
  if (timer)
4424
4451
  clearTimeout(timer);
4425
4452
  phoneRecordings = await phones.finish();
4426
- const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
4427
- if (phoneErrors.length && outcome.status === "passed")
4428
- outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
4453
+ // Recordings are dashboard artifacts. Keep capture errors on their metadata
4454
+ // without changing the result of the call steps and assertions.
4429
4455
  nativeAndroid.deactivate();
4430
4456
  RECORDING_EXEC = undefined;
4431
4457
  INTERCEPTORS.endScope();
@@ -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.10",
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",
@@ -197,6 +197,8 @@ export function replayWebSocket(args: {
197
197
  let deadline: ReturnType<typeof setTimeout> | undefined;
198
198
  let stopped = false;
199
199
  let sentClose = false;
200
+ let receivedMessages = 0, receivedCandidates = 0;
201
+ let lastCandidate: string | undefined;
200
202
  const diagnostic = (detail: string) => new Error(`${label}, input #${position + 1}: ${detail}`);
201
203
  const clear = () => { clearTimeout(timer); clearTimeout(deadline); };
202
204
  const finish = (error?: Error) => {
@@ -211,7 +213,18 @@ export function replayWebSocket(args: {
211
213
  finish(error);
212
214
  socket?.close(1011, "WebSocket replay failed");
213
215
  };
214
- const missing = () => diagnostic(`required input never arrived; expected ${record.inputs[position]}`);
216
+ const missing = () => {
217
+ const expected = record.inputs[position];
218
+ const progress = record.inputMode === "anchors"
219
+ ? ` occurrence ${record.inputOccurrences![position]}; observed ${occurrences.get(expected) ?? 0} occurrences; received ${receivedMessages} messages, ${receivedCandidates} candidates; last candidate ${lastCandidate ?? "<none>"}`
220
+ : "";
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}`);
227
+ };
215
228
  const armDeadline = () => {
216
229
  clearTimeout(deadline);
217
230
  if (position < record.inputs.length) deadline = setTimeout(() => fail(missing()), args.timeoutMs);
@@ -277,10 +290,13 @@ export function replayWebSocket(args: {
277
290
  message(_ws, message) {
278
291
  if (stopped) return;
279
292
  try {
293
+ receivedMessages++;
280
294
  if (args.match.candidates) {
281
295
  let matched = false;
282
296
  for (const actual of args.match.candidates(message)) {
283
297
  const count = occurrence(occurrences, actual);
298
+ receivedCandidates++;
299
+ lastCandidate = actual;
284
300
  if (actual !== record.inputs[position] || count !== record.inputOccurrences![position]) continue;
285
301
  barriers[++position] = performance.now();
286
302
  matched = true;
@@ -344,6 +344,30 @@ test("unmatched candidates cannot extend the required anchor deadline", async ()
344
344
  expect(h.state.websocketErrors.join()).toContain("required input never arrived");
345
345
  });
346
346
 
347
+ test("missing anchors report hashed candidate progress and occurrence counts without payloads", async () => {
348
+ const key: WebSocketKey<null> = { initial: null,
349
+ input(state, message) { return { state, key: message === "ignored" ? null : message }; },
350
+ output(state) { return { state, anchorPreviousInput: true }; },
351
+ };
352
+ const wanted = websocketMatcher(() => "private wanted")("x")!;
353
+ const last = websocketMatcher(() => "private last")("x")!;
354
+ const h = await harness([{ request, inputMode: "anchors", inputs: [wanted], inputOccurrences: [2], messages: [] }], key);
355
+ const c = await h.connect();
356
+ await c.message("private wanted");
357
+ await c.message("private last");
358
+ await c.message("ignored");
359
+ await c.close();
360
+ const error = h.state.websocketErrors.join();
361
+ expect(error).toContain(`expected ${wanted} occurrence 2; observed 1 occurrences`);
362
+ expect(error).toContain(`received 3 messages, 2 candidates; last candidate ${last}`);
363
+ expect(error).not.toContain("private");
364
+
365
+ const empty = await harness([{ request, inputMode: "anchors", inputs: [wanted], inputOccurrences: [1], messages: [] }], key);
366
+ const noInput = await empty.connect();
367
+ await noInput.close();
368
+ expect(empty.state.websocketErrors.join()).toContain("observed 0 occurrences; received 0 messages, 0 candidates; last candidate <none>");
369
+ });
370
+
347
371
  test("anchor validation and hook failures are explicit", async () => {
348
372
  const key: WebSocketKey<null> = { initial: null,
349
373
  input(state, message) { return { state, key: message }; },
@@ -428,3 +452,26 @@ test("a reached response anchor catches up older output without adding its stale
428
452
  expect(c.sent).toEqual([...messages.map(m => m.data), "response"]);
429
453
  await c.close(); expect(h.state.websocketErrors).toEqual([]);
430
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
  // ────────────────────────────────────────────────────────────────────────
@@ -5297,8 +5322,8 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5297
5322
  } finally {
5298
5323
  if (timer) clearTimeout(timer);
5299
5324
  phoneRecordings = await phones.finish();
5300
- const phoneErrors = phoneRecordings.flatMap(r => r.error ? [r.error] : []);
5301
- if (phoneErrors.length && outcome.status === "passed") outcome = { status: "failed", error: { message: phoneErrors.join("\n") } };
5325
+ // Recordings are dashboard artifacts. Keep capture errors on their metadata
5326
+ // without changing the result of the call steps and assertions.
5302
5327
  nativeAndroid.deactivate();
5303
5328
  RECORDING_EXEC = undefined;
5304
5329
  INTERCEPTORS.endScope();
@@ -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
+ }
package/src/phone.test.ts CHANGED
@@ -434,7 +434,7 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
434
434
  assert.equal(loaded.cases.length, 4);
435
435
  for (const [index, test] of loaded.cases.entries()) {
436
436
  const result = await METHODS.run({caseId:test.id});
437
- assert.equal(result.status, index === 0 ? "passed" : "failed", JSON.stringify(result));
437
+ assert.equal(result.status, index === 0 || index === 3 ? "passed" : "failed", JSON.stringify(result));
438
438
  if (index === 1) assert.match(result.error.message, /application assertion failed/);
439
439
  if (index === 2) assert.match(result.error.message, /timed out/);
440
440
  assert.equal(result.phoneRecordings.length, 1);
@@ -442,7 +442,7 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
442
442
  if (index === 3) {
443
443
  const message = "Call recording could not be finalized: Phone recording encoder could not keep up with audio";
444
444
  assert.equal(recording.error, message);
445
- assert.equal(result.error.message, message);
445
+ assert.equal(result.error, undefined);
446
446
  await assert.rejects(METHODS.phoneRecordingChunk({caseId:recording.id}), /Unknown/);
447
447
  continue;
448
448
  }
@@ -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);