@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.
- package/dist/components/replay-websocket.js +17 -1
- package/dist/daemon.js +63 -37
- package/dist/harness/log-delta.d.ts +9 -0
- package/dist/harness/log-delta.js +10 -0
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +17 -1
- package/src/components/replayFake-websocket.test.ts +47 -0
- package/src/daemon.ts +67 -42
- package/src/harness/log-delta.test.ts +17 -0
- package/src/harness/log-delta.ts +15 -0
- package/src/phone.test.ts +2 -2
- package/src/runtime-service-logs.test.ts +74 -0
|
@@ -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 = () =>
|
|
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
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
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:
|
|
2980
|
-
stdout:
|
|
2981
|
-
stdoutTruncated:
|
|
2982
|
-
stdoutReset:
|
|
2983
|
-
stderr: err.
|
|
2989
|
+
service: name,
|
|
2990
|
+
stdout: "",
|
|
2991
|
+
stdoutTruncated: false,
|
|
2992
|
+
stdoutReset: false,
|
|
2993
|
+
stderr: err.value,
|
|
2984
2994
|
stderrTruncated: err.truncated,
|
|
2985
|
-
stderrReset:
|
|
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
|
-
|
|
4427
|
-
|
|
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
|
@@ -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 = () =>
|
|
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
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
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
|
-
|
|
5301
|
-
|
|
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
|
+
});
|
package/src/harness/log-delta.ts
CHANGED
|
@@ -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
|
|
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);
|