@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.
- package/dist/components/replay-websocket.js +6 -1
- package/dist/daemon.js +61 -34
- 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 +6 -1
- package/src/components/replayFake-websocket.test.ts +23 -0
- package/src/daemon.ts +65 -40
- package/src/harness/log-delta.test.ts +17 -0
- package/src/harness/log-delta.ts +15 -0
- package/src/runtime-service-logs.test.ts +74 -0
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
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
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -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
|
+
}
|
|
@@ -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);
|