@specific.dev/spectest 0.119.2 → 0.119.4

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
- Object.assign(svc, patch);
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 () => BOOTSTRAP_PROGRESS ?? {},
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.119.2",
3
+ "version": "0.119.4",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
- Object.assign(svc, patch);
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 () => BOOTSTRAP_PROGRESS ?? {},
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
+ }