@specific.dev/spectest 0.119.2 → 0.119.3
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/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
|
4
4
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
5
5
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
6
6
|
import { waitForAll } from "./harness/concurrency.js";
|
|
7
|
+
import { updateServiceProgress, serviceProgressSnapshot } from "./harness/service-progress.js";
|
|
7
8
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
8
9
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
9
10
|
// a container). Owns:
|
|
@@ -426,13 +427,14 @@ function bootLogText() {
|
|
|
426
427
|
return head + BOOT_LOG.lines.join("");
|
|
427
428
|
}
|
|
428
429
|
let BOOTSTRAP_PROGRESS = null;
|
|
429
|
-
function progressInit(services) {
|
|
430
|
+
function progressInit(services, prepared) {
|
|
430
431
|
BOOTSTRAP_PROGRESS = {
|
|
431
432
|
phase: "Preparing images",
|
|
432
433
|
services: services.map((s) => ({
|
|
433
434
|
name: s.name,
|
|
434
435
|
kind: s.image.type === "registry" ? "pull" : "build",
|
|
435
436
|
status: "pending",
|
|
437
|
+
elapsedMs: prepared?.images[s.name]?.prepMs,
|
|
436
438
|
})),
|
|
437
439
|
startedAt: Date.now(),
|
|
438
440
|
updatedAt: Date.now(),
|
|
@@ -451,7 +453,7 @@ function progressService(name, patch) {
|
|
|
451
453
|
const svc = BOOTSTRAP_PROGRESS.services.find((s) => s.name === name);
|
|
452
454
|
if (!svc)
|
|
453
455
|
return;
|
|
454
|
-
|
|
456
|
+
updateServiceProgress(svc, patch);
|
|
455
457
|
BOOTSTRAP_PROGRESS.updatedAt = Date.now();
|
|
456
458
|
}
|
|
457
459
|
function progressDone() {
|
|
@@ -2719,7 +2721,7 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2719
2721
|
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
2720
2722
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
2721
2723
|
}
|
|
2722
|
-
progressInit(tasks);
|
|
2724
|
+
progressInit(tasks, prepared);
|
|
2723
2725
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
2724
2726
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
2725
2727
|
// text but different build-context content.
|
|
@@ -2787,6 +2789,7 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2787
2789
|
for (const svc of tasks) {
|
|
2788
2790
|
const p = (async () => {
|
|
2789
2791
|
const t0 = Date.now();
|
|
2792
|
+
progressService(svc.name, { status: svc.image.type === "registry" ? "pulling" : "building" });
|
|
2790
2793
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
2791
2794
|
const { tag, buildSteps } = await prepImage(svc);
|
|
2792
2795
|
progressService(svc.name, { status: "prepared", detail: undefined });
|
|
@@ -5173,7 +5176,12 @@ export function harnessMethods(state) {
|
|
|
5173
5176
|
// Live bootstrap progress, polled by the control plane during
|
|
5174
5177
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
5175
5178
|
// first bootstrap of this harness process.
|
|
5176
|
-
progress: async () =>
|
|
5179
|
+
progress: async () => {
|
|
5180
|
+
if (!BOOTSTRAP_PROGRESS)
|
|
5181
|
+
return {};
|
|
5182
|
+
const now = Date.now();
|
|
5183
|
+
return { ...BOOTSTRAP_PROGRESS, services: BOOTSTRAP_PROGRESS.services.map(s => serviceProgressSnapshot(s, now)) };
|
|
5184
|
+
},
|
|
5177
5185
|
/**
|
|
5178
5186
|
* Live logs for a running environment: the harness's own boot log
|
|
5179
5187
|
* (bootstrap plus every `setup` hook's console output) and each
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Timing for each service's active build/start work, excluding dependency waits. */
|
|
2
|
+
export type ServiceProgressStatus = "pending" | "pulling" | "building" | "prepared" | "starting" | "probing" | "ready" | "failed";
|
|
3
|
+
export interface ServiceProgress {
|
|
4
|
+
name: string;
|
|
5
|
+
kind: "pull" | "build";
|
|
6
|
+
status: ServiceProgressStatus;
|
|
7
|
+
detail?: string;
|
|
8
|
+
elapsedMs?: number;
|
|
9
|
+
/** Start of the current active interval; omitted from the wire snapshot. */
|
|
10
|
+
activeSince?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function updateServiceProgress(service: ServiceProgress, patch: Partial<ServiceProgress>, now?: number): void;
|
|
13
|
+
/** Calculate at poll time, even when the build has emitted no new messages. */
|
|
14
|
+
export declare function serviceProgressSnapshot(service: ServiceProgress, now?: number): Omit<ServiceProgress, "activeSince">;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function active(status) {
|
|
2
|
+
return ["pulling", "building", "starting", "probing"].includes(status);
|
|
3
|
+
}
|
|
4
|
+
export function updateServiceProgress(service, patch, now = Date.now()) {
|
|
5
|
+
const wasActive = active(service.status);
|
|
6
|
+
const isActive = active(patch.status ?? service.status);
|
|
7
|
+
if (!wasActive && isActive) {
|
|
8
|
+
service.activeSince = now;
|
|
9
|
+
service.elapsedMs ??= 0;
|
|
10
|
+
}
|
|
11
|
+
else if (wasActive && !isActive && service.activeSince !== undefined) {
|
|
12
|
+
service.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - service.activeSince);
|
|
13
|
+
service.activeSince = undefined;
|
|
14
|
+
}
|
|
15
|
+
Object.assign(service, patch);
|
|
16
|
+
}
|
|
17
|
+
/** Calculate at poll time, even when the build has emitted no new messages. */
|
|
18
|
+
export function serviceProgressSnapshot(service, now = Date.now()) {
|
|
19
|
+
const { activeSince, ...snapshot } = service;
|
|
20
|
+
if (activeSince !== undefined) {
|
|
21
|
+
snapshot.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - activeSince);
|
|
22
|
+
}
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
|
4
4
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
5
5
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
6
6
|
import { waitForAll } from "./harness/concurrency.js";
|
|
7
|
+
import { updateServiceProgress, serviceProgressSnapshot, type ServiceProgress } from "./harness/service-progress.js";
|
|
7
8
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
8
9
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
9
10
|
// a container). Owns:
|
|
@@ -679,24 +680,6 @@ function bootLogText(): string {
|
|
|
679
680
|
// streams this snapshot into the test-run row, so the web UI shows what's
|
|
680
681
|
// being built *before* the suite starts. In-memory only; reset at the top
|
|
681
682
|
// of each bootstrap(). All updaters no-op when no bootstrap is in flight.
|
|
682
|
-
type ServiceProgressStatus =
|
|
683
|
-
| "pending"
|
|
684
|
-
| "pulling"
|
|
685
|
-
| "building"
|
|
686
|
-
| "prepared"
|
|
687
|
-
| "starting"
|
|
688
|
-
| "probing"
|
|
689
|
-
| "ready"
|
|
690
|
-
| "failed";
|
|
691
|
-
|
|
692
|
-
interface ServiceProgress {
|
|
693
|
-
name: string;
|
|
694
|
-
kind: "pull" | "build";
|
|
695
|
-
status: ServiceProgressStatus;
|
|
696
|
-
/** Live free-text detail, e.g. "step 4/9 RUN bun install" or "12 layers". */
|
|
697
|
-
detail?: string;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
683
|
interface BootstrapProgress {
|
|
701
684
|
phase: string;
|
|
702
685
|
services: ServiceProgress[];
|
|
@@ -707,13 +690,14 @@ interface BootstrapProgress {
|
|
|
707
690
|
|
|
708
691
|
let BOOTSTRAP_PROGRESS: BootstrapProgress | null = null;
|
|
709
692
|
|
|
710
|
-
function progressInit(services: NamedService[]): void {
|
|
693
|
+
function progressInit(services: NamedService[], prepared?: PreparedImages): void {
|
|
711
694
|
BOOTSTRAP_PROGRESS = {
|
|
712
695
|
phase: "Preparing images",
|
|
713
696
|
services: services.map((s) => ({
|
|
714
697
|
name: s.name,
|
|
715
698
|
kind: s.image.type === "registry" ? "pull" : "build",
|
|
716
699
|
status: "pending",
|
|
700
|
+
elapsedMs: prepared?.images[s.name]?.prepMs,
|
|
717
701
|
})),
|
|
718
702
|
startedAt: Date.now(),
|
|
719
703
|
updatedAt: Date.now(),
|
|
@@ -731,7 +715,7 @@ function progressService(name: string, patch: Partial<ServiceProgress>): void {
|
|
|
731
715
|
if (!BOOTSTRAP_PROGRESS) return;
|
|
732
716
|
const svc = BOOTSTRAP_PROGRESS.services.find((s) => s.name === name);
|
|
733
717
|
if (!svc) return;
|
|
734
|
-
|
|
718
|
+
updateServiceProgress(svc, patch);
|
|
735
719
|
BOOTSTRAP_PROGRESS.updatedAt = Date.now();
|
|
736
720
|
}
|
|
737
721
|
|
|
@@ -3254,7 +3238,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3254
3238
|
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
3255
3239
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
3256
3240
|
}
|
|
3257
|
-
progressInit(tasks);
|
|
3241
|
+
progressInit(tasks, prepared);
|
|
3258
3242
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
3259
3243
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
3260
3244
|
// text but different build-context content.
|
|
@@ -3324,6 +3308,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3324
3308
|
for (const svc of tasks) {
|
|
3325
3309
|
const p = (async () => {
|
|
3326
3310
|
const t0 = Date.now();
|
|
3311
|
+
progressService(svc.name, { status: svc.image.type === "registry" ? "pulling" : "building" });
|
|
3327
3312
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
3328
3313
|
const { tag, buildSteps } = await prepImage(svc);
|
|
3329
3314
|
progressService(svc.name, { status: "prepared", detail: undefined });
|
|
@@ -6193,7 +6178,11 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6193
6178
|
// Live bootstrap progress, polled by the control plane during
|
|
6194
6179
|
// `bootstrap` and streamed into the test-run row. `{}` before the
|
|
6195
6180
|
// first bootstrap of this harness process.
|
|
6196
|
-
progress: async () =>
|
|
6181
|
+
progress: async () => {
|
|
6182
|
+
if (!BOOTSTRAP_PROGRESS) return {};
|
|
6183
|
+
const now = Date.now();
|
|
6184
|
+
return { ...BOOTSTRAP_PROGRESS, services: BOOTSTRAP_PROGRESS.services.map(s => serviceProgressSnapshot(s, now)) };
|
|
6185
|
+
},
|
|
6197
6186
|
|
|
6198
6187
|
/**
|
|
6199
6188
|
* Live logs for a running environment: the harness's own boot log
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { serviceProgressSnapshot, updateServiceProgress, type ServiceProgress } from "./service-progress";
|
|
3
|
+
|
|
4
|
+
function service(): ServiceProgress {
|
|
5
|
+
return { name: "api", kind: "build", status: "pending" };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
test("measures quiet builds without resetting on status or detail updates", () => {
|
|
9
|
+
const s = service();
|
|
10
|
+
expect(serviceProgressSnapshot(s, 1000).elapsedMs).toBeUndefined();
|
|
11
|
+
updateServiceProgress(s, { status: "building" }, 1000);
|
|
12
|
+
updateServiceProgress(s, { status: "building", detail: "RUN install" }, 2000);
|
|
13
|
+
expect(serviceProgressSnapshot(s, 6000).elapsedMs).toBe(5000);
|
|
14
|
+
expect(serviceProgressSnapshot(s, 6000)).not.toHaveProperty("activeSince");
|
|
15
|
+
expect(s.elapsedMs).toBe(0); // Polling must not accumulate the interval twice.
|
|
16
|
+
expect(serviceProgressSnapshot(s, 7000).elapsedMs).toBe(6000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("freezes while prepared, resumes for startup, and keeps the ready duration", () => {
|
|
20
|
+
const s = service();
|
|
21
|
+
updateServiceProgress(s, { status: "pulling" }, 1000);
|
|
22
|
+
updateServiceProgress(s, { status: "prepared" }, 5000);
|
|
23
|
+
updateServiceProgress(s, { status: "prepared", detail: undefined }, 6000);
|
|
24
|
+
expect(serviceProgressSnapshot(s, 20000).elapsedMs).toBe(4000);
|
|
25
|
+
updateServiceProgress(s, { status: "starting" }, 20000);
|
|
26
|
+
updateServiceProgress(s, { status: "probing" }, 21000);
|
|
27
|
+
updateServiceProgress(s, { status: "probing", detail: "running setup" }, 22000);
|
|
28
|
+
expect(serviceProgressSnapshot(s, 23000).elapsedMs).toBe(7000);
|
|
29
|
+
updateServiceProgress(s, { status: "ready" }, 24000);
|
|
30
|
+
expect(serviceProgressSnapshot(s, 30000).elapsedMs).toBe(8000);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("retains elapsed time on failure and never returns a negative interval", () => {
|
|
34
|
+
const s = service();
|
|
35
|
+
updateServiceProgress(s, { status: "building" }, 1000);
|
|
36
|
+
expect(serviceProgressSnapshot(s, 500).elapsedMs).toBe(0);
|
|
37
|
+
updateServiceProgress(s, { status: "failed" }, 3000);
|
|
38
|
+
expect(serviceProgressSnapshot(s, 20000).elapsedMs).toBe(2000);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("carries image preparation time across a separate builder handoff", () => {
|
|
42
|
+
const s = { ...service(), elapsedMs: 12500 };
|
|
43
|
+
updateServiceProgress(s, { status: "building" }, 1000);
|
|
44
|
+
updateServiceProgress(s, { status: "prepared" }, 1100);
|
|
45
|
+
updateServiceProgress(s, { status: "starting" }, 3000);
|
|
46
|
+
updateServiceProgress(s, { status: "ready" }, 4000);
|
|
47
|
+
expect(serviceProgressSnapshot(s, 9000).elapsedMs).toBe(13600);
|
|
48
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Timing for each service's active build/start work, excluding dependency waits. */
|
|
2
|
+
export type ServiceProgressStatus =
|
|
3
|
+
| "pending" | "pulling" | "building" | "prepared"
|
|
4
|
+
| "starting" | "probing" | "ready" | "failed";
|
|
5
|
+
|
|
6
|
+
export interface ServiceProgress {
|
|
7
|
+
name: string;
|
|
8
|
+
kind: "pull" | "build";
|
|
9
|
+
status: ServiceProgressStatus;
|
|
10
|
+
detail?: string;
|
|
11
|
+
elapsedMs?: number;
|
|
12
|
+
/** Start of the current active interval; omitted from the wire snapshot. */
|
|
13
|
+
activeSince?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function active(status: ServiceProgressStatus): boolean {
|
|
17
|
+
return ["pulling", "building", "starting", "probing"].includes(status);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function updateServiceProgress(service: ServiceProgress, patch: Partial<ServiceProgress>, now = Date.now()): void {
|
|
21
|
+
const wasActive = active(service.status);
|
|
22
|
+
const isActive = active(patch.status ?? service.status);
|
|
23
|
+
if (!wasActive && isActive) {
|
|
24
|
+
service.activeSince = now;
|
|
25
|
+
service.elapsedMs ??= 0;
|
|
26
|
+
} else if (wasActive && !isActive && service.activeSince !== undefined) {
|
|
27
|
+
service.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - service.activeSince);
|
|
28
|
+
service.activeSince = undefined;
|
|
29
|
+
}
|
|
30
|
+
Object.assign(service, patch);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Calculate at poll time, even when the build has emitted no new messages. */
|
|
34
|
+
export function serviceProgressSnapshot(service: ServiceProgress, now = Date.now()): Omit<ServiceProgress, "activeSince"> {
|
|
35
|
+
const { activeSince, ...snapshot } = service;
|
|
36
|
+
if (activeSince !== undefined) {
|
|
37
|
+
snapshot.elapsedMs = (service.elapsedMs ?? 0) + Math.max(0, now - activeSince);
|
|
38
|
+
}
|
|
39
|
+
return snapshot;
|
|
40
|
+
}
|