@frockbot/plugin-computer 0.2.4 → 0.3.0

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.
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ComputerProgressViewV1 } from "../protocol.js";
3
+ import {
4
+ COLD_PROVISION_PROGRESS_BUDGET_MS,
5
+ computerProgressFraction,
6
+ computerProgressFrame,
7
+ computerProgressRunKind,
8
+ UPDATE_PROGRESS_BUDGET_MS,
9
+ } from "./progress.js";
10
+
11
+ function progress(
12
+ provisioningIndex: number,
13
+ options: {
14
+ provisioningKind?: "provision" | "update";
15
+ resumed?: boolean;
16
+ updatedAfterMs?: number;
17
+ } = {},
18
+ ): ComputerProgressViewV1 {
19
+ const provisioningKind = options.provisioningKind ?? "provision";
20
+ return {
21
+ version: 1,
22
+ kind: "connect",
23
+ startedAt: "2026-09-03T00:00:00.000Z",
24
+ updatedAt: new Date(
25
+ Date.parse("2026-09-03T00:00:00.000Z") + (options.updatedAfterMs ?? 0),
26
+ ).toISOString(),
27
+ index: 1,
28
+ total: 5,
29
+ provisioning: {
30
+ version: 1,
31
+ kind: provisioningKind,
32
+ label: "Installing the browser",
33
+ index: provisioningIndex,
34
+ total: 5,
35
+ resumed: options.resumed ?? false,
36
+ },
37
+ steps: [
38
+ {
39
+ version: 1,
40
+ id: "waking",
41
+ label: "Waking the Computer",
42
+ status: "active",
43
+ },
44
+ ],
45
+ };
46
+ }
47
+
48
+ function projection(progressValue?: ComputerProgressViewV1) {
49
+ return {
50
+ phase: "provisioning" as const,
51
+ ...(progressValue ? { progress: progressValue } : {}),
52
+ };
53
+ }
54
+
55
+ describe("Computer progress easing", () => {
56
+ test("never regresses when a refreshed durable phase has a lower local eased position", () => {
57
+ const beforeRefresh = computerProgressFraction({
58
+ projection: projection(progress(1)),
59
+ elapsedMs: 20_000,
60
+ });
61
+ const afterRefresh = computerProgressFraction({
62
+ projection: projection(progress(1, { updatedAfterMs: 20_000 })),
63
+ elapsedMs: 21_000,
64
+ });
65
+
66
+ expect(beforeRefresh).toBeDefined();
67
+ expect(afterRefresh).toBeGreaterThanOrEqual(beforeRefresh!);
68
+ });
69
+
70
+ test("never eases beyond the next real provisioning boundary", () => {
71
+ const frame = computerProgressFrame({
72
+ projection: projection(progress(2)),
73
+ elapsedMs: COLD_PROVISION_PROGRESS_BUDGET_MS * 10,
74
+ });
75
+
76
+ expect(frame.fraction).toBe(frame.nextBoundary);
77
+ expect(frame.nextBoundary).toBeCloseTo(2 / 25);
78
+ });
79
+
80
+ test("a late durable phase ahead of the eased value jumps forward", () => {
81
+ const eased = computerProgressFraction({
82
+ projection: projection(progress(1)),
83
+ elapsedMs: 1_000,
84
+ });
85
+ const advanced = computerProgressFraction({
86
+ projection: projection(progress(4, { updatedAfterMs: 1_000 })),
87
+ elapsedMs: 1_000,
88
+ });
89
+
90
+ expect(eased).toBeLessThan(3 / 25);
91
+ expect(advanced).toBe(3 / 25);
92
+ });
93
+
94
+ test("durable completion snaps to 100 percent regardless of budget", () => {
95
+ expect(
96
+ computerProgressFraction({
97
+ projection: { phase: "ready" },
98
+ elapsedMs: 1,
99
+ }),
100
+ ).toBe(1);
101
+ });
102
+
103
+ test("a warm wake has no long-budget determinate bar", () => {
104
+ const warmProgress = progress(1);
105
+ delete warmProgress.provisioning;
106
+
107
+ expect(
108
+ computerProgressFrame({
109
+ projection: projection(warmProgress),
110
+ elapsedMs: 1_000,
111
+ }),
112
+ ).toEqual({ runKind: "warm-wake" });
113
+ });
114
+
115
+ test("updates are distinguished from cold and resumed provisions", () => {
116
+ const cold = projection(progress(1));
117
+ const resumed = projection(progress(1, { resumed: true }));
118
+ const update = projection(progress(1, { provisioningKind: "update" }));
119
+
120
+ expect(computerProgressRunKind(cold)).toBe("cold-provision");
121
+ expect(computerProgressRunKind(resumed)).toBe("resumed-provision");
122
+ expect(computerProgressRunKind(update)).toBe("update");
123
+ expect(
124
+ computerProgressFrame({ projection: update, elapsedMs: 0 }).remainingMs,
125
+ ).toBe(UPDATE_PROGRESS_BUDGET_MS / 5);
126
+ expect(UPDATE_PROGRESS_BUDGET_MS).toBeLessThan(
127
+ COLD_PROVISION_PROGRESS_BUDGET_MS,
128
+ );
129
+ });
130
+
131
+ test("top-level update progress is determinate without provisioning detail", () => {
132
+ const updateProgress = progress(1);
133
+ delete updateProgress.provisioning;
134
+ updateProgress.kind = "update";
135
+ updateProgress.total = 2;
136
+
137
+ const frame = computerProgressFrame({
138
+ projection: { phase: "updating", progress: updateProgress },
139
+ elapsedMs: 0,
140
+ });
141
+ expect(frame).toMatchObject({
142
+ runKind: "update",
143
+ fraction: 0,
144
+ nextBoundary: 0.5,
145
+ remainingMs: UPDATE_PROGRESS_BUDGET_MS / 2,
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,171 @@
1
+ import type {
2
+ ComputerProgressViewV1,
3
+ ComputerProjectionV1,
4
+ } from "../protocol.js";
5
+
6
+ /** The midpoint of the measured two-to-three-minute cold setup expectation. */
7
+ export const COLD_PROVISION_PROGRESS_BUDGET_MS = 150_000;
8
+
9
+ /** Updates are small, in-place installs and must not imply a cold-setup wait. */
10
+ export const UPDATE_PROGRESS_BUDGET_MS = 30_000;
11
+
12
+ export type ComputerProgressRunKind =
13
+ "cold-provision" | "resumed-provision" | "update" | "warm-wake";
14
+
15
+ export type ComputerProgressProjection = Pick<
16
+ ComputerProjectionV1,
17
+ "phase" | "progress"
18
+ >;
19
+
20
+ export interface ComputerProgressFrame {
21
+ runKind: ComputerProgressRunKind;
22
+ /** Present only when durable state provides a real phase position. */
23
+ fraction?: number;
24
+ /** The boundary of the next durable phase; easing cannot pass it. */
25
+ nextBoundary?: number;
26
+ /** Remaining presentational travel time to the next boundary. */
27
+ remainingMs?: number;
28
+ }
29
+
30
+ export interface ComputerProgressFractionInput {
31
+ projection: ComputerProgressProjection;
32
+ /** Elapsed time since progress.startedAt, supplied by the rendering clock. */
33
+ elapsedMs: number;
34
+ }
35
+
36
+ interface ProgressBounds {
37
+ floor: number;
38
+ nextBoundary: number;
39
+ timingStart: number;
40
+ timingSpan: number;
41
+ phaseCount: number;
42
+ }
43
+
44
+ function ordinal(index: number): number {
45
+ return Math.max(1, index);
46
+ }
47
+
48
+ function progressBounds(progress: ComputerProgressViewV1): ProgressBounds {
49
+ const outerOrdinal = ordinal(progress.index);
50
+ const outerFloor = (outerOrdinal - 1) / progress.total;
51
+ const outerSpan = 1 / progress.total;
52
+ const provisioning = progress.provisioning;
53
+
54
+ if (!provisioning) {
55
+ return {
56
+ floor: outerFloor,
57
+ nextBoundary: outerFloor + outerSpan,
58
+ timingStart: 0,
59
+ timingSpan: 1,
60
+ phaseCount: progress.total,
61
+ };
62
+ }
63
+
64
+ const provisioningOrdinal = ordinal(provisioning.index);
65
+ const phaseSpan = outerSpan / provisioning.total;
66
+ return {
67
+ floor: outerFloor + (provisioningOrdinal - 1) * phaseSpan,
68
+ nextBoundary: outerFloor + provisioningOrdinal * phaseSpan,
69
+ timingStart: outerFloor,
70
+ timingSpan: outerSpan,
71
+ phaseCount: provisioning.total,
72
+ };
73
+ }
74
+
75
+ function elapsedInCurrentPhase(
76
+ progress: ComputerProgressViewV1,
77
+ elapsedMs: number,
78
+ ): number {
79
+ const startedAt = Date.parse(progress.startedAt);
80
+ const updatedAt = Date.parse(progress.updatedAt);
81
+ const phaseStartedAfter = Math.max(0, updatedAt - startedAt);
82
+ return Math.max(0, elapsedMs - phaseStartedAfter);
83
+ }
84
+
85
+ export function computerProgressRunKind(
86
+ projection: ComputerProgressProjection,
87
+ ): ComputerProgressRunKind {
88
+ const progress = projection.progress;
89
+ const provisioning = progress?.provisioning;
90
+ if (
91
+ projection.phase === "updating" ||
92
+ progress?.kind === "update" ||
93
+ provisioning?.kind === "update"
94
+ ) {
95
+ return "update";
96
+ }
97
+ if (provisioning?.kind === "provision") {
98
+ return provisioning.resumed ? "resumed-provision" : "cold-provision";
99
+ }
100
+ return "warm-wake";
101
+ }
102
+
103
+ /**
104
+ * Projects durable phase truth into one presentational frame.
105
+ *
106
+ * The durable floor always wins. Time advances only inside the current real
107
+ * phase, and a run-wide time baseline prevents a refreshed projection for the
108
+ * same phase from moving the fill backwards. Neither path can cross the next
109
+ * durable boundary.
110
+ */
111
+ export function computerProgressFrame({
112
+ projection,
113
+ elapsedMs,
114
+ }: ComputerProgressFractionInput): ComputerProgressFrame {
115
+ const runKind = computerProgressRunKind(projection);
116
+ if (projection.phase === "ready") {
117
+ return { runKind, fraction: 1, nextBoundary: 1, remainingMs: 0 };
118
+ }
119
+
120
+ const progress = projection.progress;
121
+ if (!progress || runKind === "warm-wake") return { runKind };
122
+
123
+ const budgetMs =
124
+ runKind === "update"
125
+ ? UPDATE_PROGRESS_BUDGET_MS
126
+ : COLD_PROVISION_PROGRESS_BUDGET_MS;
127
+ const elapsed = Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0);
128
+ const bounds = progressBounds(progress);
129
+ const phaseBudgetMs = budgetMs / bounds.phaseCount;
130
+ const phaseElapsedMs = elapsedInCurrentPhase(progress, elapsed);
131
+ const phaseTimedFraction =
132
+ bounds.floor +
133
+ ((bounds.nextBoundary - bounds.floor) * phaseElapsedMs) / phaseBudgetMs;
134
+ const runTimedFraction =
135
+ bounds.timingStart + (bounds.timingSpan * elapsed) / budgetMs;
136
+ const fraction = Math.min(
137
+ bounds.nextBoundary,
138
+ Math.max(bounds.floor, phaseTimedFraction, runTimedFraction),
139
+ );
140
+
141
+ const phaseRemainingMs = Math.max(0, phaseBudgetMs - phaseElapsedMs);
142
+ const runBoundaryElapsedMs =
143
+ ((bounds.nextBoundary - bounds.timingStart) / bounds.timingSpan) * budgetMs;
144
+ const runRemainingMs = Math.max(0, runBoundaryElapsedMs - elapsed);
145
+
146
+ return {
147
+ runKind,
148
+ fraction,
149
+ nextBoundary: bounds.nextBoundary,
150
+ remainingMs:
151
+ fraction === bounds.nextBoundary
152
+ ? 0
153
+ : Math.round(Math.min(phaseRemainingMs, runRemainingMs)),
154
+ };
155
+ }
156
+
157
+ /** Pure `{ durable projection, elapsed ms } -> fraction` test seam. */
158
+ export function computerProgressFraction(
159
+ input: ComputerProgressFractionInput,
160
+ ): number | undefined {
161
+ return computerProgressFrame(input).fraction;
162
+ }
163
+
164
+ export function computerProgressElapsedMs(
165
+ progress: ComputerProgressViewV1 | undefined,
166
+ nowMs: number,
167
+ ): number {
168
+ if (!progress) return 0;
169
+ const startedAt = Date.parse(progress.startedAt);
170
+ return Number.isFinite(startedAt) ? Math.max(0, nowMs - startedAt) : 0;
171
+ }
@@ -122,7 +122,7 @@ describe("Computer client state machine", () => {
122
122
  });
123
123
  });
124
124
 
125
- test("an idle strip click expands before its connect request", () => {
125
+ test("an idle card click expands before its connect request", () => {
126
126
  const idle = {
127
127
  ...initialComputerMachineState(),
128
128
  phase: "idle" as const,
@@ -2,107 +2,6 @@
2
2
  margin: 0;
3
3
  }
4
4
 
5
- .computer-strip {
6
- display: grid;
7
- width: 100%;
8
- grid-template-columns: 72px minmax(0, 1fr);
9
- align-items: center;
10
- gap: 10px;
11
- padding: 6px;
12
- border: 0;
13
- border-radius: var(--frock-radius-control);
14
- color: var(--frock-text);
15
- background: transparent;
16
- font: inherit;
17
- text-align: left;
18
- cursor: pointer;
19
- transition: background-color var(--frock-motion-fast);
20
- }
21
-
22
- .computer-strip:hover:not(:disabled),
23
- .computer-strip:focus-visible {
24
- background: var(--frock-fill-hover);
25
- outline: none;
26
- }
27
-
28
- .computer-strip:disabled {
29
- cursor: default;
30
- }
31
-
32
- .computer-strip-capture {
33
- position: relative;
34
- display: block;
35
- overflow: hidden;
36
- aspect-ratio: 16 / 10;
37
- border: 1px solid var(--frock-border);
38
- border-radius: 7px;
39
- background: var(--frock-surface-subtle);
40
- box-shadow: inset 0 0 0 1px var(--frock-computer-inset);
41
- }
42
-
43
- .computer-strip-capture img {
44
- display: block;
45
- width: 100%;
46
- height: 100%;
47
- object-fit: cover;
48
- }
49
-
50
- .computer-strip-placeholder {
51
- position: absolute;
52
- inset: 0;
53
- display: grid;
54
- place-items: center;
55
- color: var(--frock-computer-accent-text);
56
- background:
57
- linear-gradient(var(--frock-computer-grid) 1px, transparent 1px),
58
- linear-gradient(90deg, var(--frock-computer-grid) 1px, transparent 1px),
59
- var(--frock-surface-subtle);
60
- background-size: 12px 12px;
61
- }
62
-
63
- .computer-strip-phase {
64
- display: flex;
65
- min-width: 0;
66
- align-items: center;
67
- gap: 7px;
68
- color: var(--frock-text-muted);
69
- font-size: var(--frock-text-xs);
70
- text-transform: capitalize;
71
- }
72
-
73
- .computer-strip-phase > :last-child {
74
- overflow: hidden;
75
- text-overflow: ellipsis;
76
- white-space: nowrap;
77
- }
78
-
79
- .computer-strip-dot {
80
- width: 7px;
81
- height: 7px;
82
- flex: 0 0 auto;
83
- border-radius: 999px;
84
- background: var(--frock-text-disabled);
85
- box-shadow: 0 0 0 2px var(--frock-computer-inset);
86
- }
87
-
88
- .computer-strip-dot.phase-ready {
89
- background: var(--frock-success);
90
- }
91
-
92
- .computer-strip-dot.phase-human-control,
93
- .computer-strip-dot.phase-taking-control {
94
- background: var(--frock-computer-accent);
95
- }
96
-
97
- .computer-strip-dot.phase-updating {
98
- background: var(--frock-warning);
99
- }
100
-
101
- .computer-strip-dot.phase-error,
102
- .computer-strip-dot.phase-disconnected {
103
- background: var(--frock-danger-strong);
104
- }
105
-
106
5
  .computer-status {
107
6
  flex: 0 0 auto;
108
7
  padding: 3px 7px;
@@ -230,8 +129,27 @@ button.computer-screen {
230
129
  background-size: 18px 18px;
231
130
  }
232
131
 
132
+ .computer-setup-expectation,
133
+ .computer-progress-phase {
134
+ display: block;
135
+ max-width: 320px;
136
+ color: var(--frock-viewer-content-muted);
137
+ font-size: var(--frock-text-sm);
138
+ line-height: var(--frock-leading-normal);
139
+ }
140
+
141
+ .computer-setup-expectation {
142
+ margin: 6px 0 0;
143
+ }
144
+
145
+ .computer-progress-phase {
146
+ margin: 9px 0 0;
147
+ color: var(--frock-viewer-content-text);
148
+ }
149
+
233
150
  .computer-progress-track {
234
151
  position: relative;
152
+ display: block;
235
153
  overflow: hidden;
236
154
  width: min(320px, 70vw);
237
155
  height: 4px;
@@ -242,6 +160,8 @@ button.computer-screen {
242
160
 
243
161
  .computer-progress-track > span {
244
162
  position: absolute;
163
+ inset-block: 0;
164
+ inset-inline-start: 0;
245
165
  width: 42%;
246
166
  height: 100%;
247
167
  border-radius: inherit;
@@ -249,6 +169,36 @@ button.computer-screen {
249
169
  animation: computer-progress 1.25s ease-in-out infinite;
250
170
  }
251
171
 
172
+ .computer-progress-track.is-determinate > span {
173
+ animation: none;
174
+ transition: width 1s linear;
175
+ }
176
+
177
+ .computer-progress-track.is-css-timed > span {
178
+ width: 100%;
179
+ transform: scaleX(var(--computer-progress-from));
180
+ transform-origin: left;
181
+ animation: computer-progress-determinate var(--computer-progress-duration)
182
+ linear forwards;
183
+ transition: none;
184
+ }
185
+
186
+ .computer-progress-track-compact {
187
+ width: min(210px, 80%);
188
+ height: 3px;
189
+ margin: 11px 0 0;
190
+ }
191
+
192
+ .computer-screen-thumbnail .computer-setup-expectation,
193
+ .computer-screen-thumbnail .computer-progress-phase {
194
+ max-width: 230px;
195
+ font-size: var(--frock-text-xs);
196
+ }
197
+
198
+ .computer-screen-thumbnail .computer-progress-phase {
199
+ margin-top: 6px;
200
+ }
201
+
252
202
  .computer-progress-meta {
253
203
  display: flex;
254
204
  width: min(320px, 70vw);
@@ -309,6 +259,30 @@ button.computer-screen {
309
259
  }
310
260
  }
311
261
 
262
+ @keyframes computer-progress-determinate {
263
+ from {
264
+ transform: scaleX(var(--computer-progress-from));
265
+ }
266
+
267
+ to {
268
+ transform: scaleX(var(--computer-progress-to));
269
+ }
270
+ }
271
+
272
+ @media (prefers-reduced-motion: reduce) {
273
+ .computer-progress-track > span {
274
+ animation: none;
275
+ }
276
+
277
+ .computer-progress-track.is-determinate > span {
278
+ transition: none;
279
+ }
280
+
281
+ .computer-progress-track.is-css-timed > span {
282
+ transform: scaleX(var(--computer-progress-from));
283
+ }
284
+ }
285
+
312
286
  .computer-mark {
313
287
  display: grid;
314
288
  width: 28px;
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  ComputerProtocolDecodeError,
4
+ decodeComputerCommandResponse,
4
5
  decodeComputerProjectionV1,
5
6
  type ComputerProgressViewV1,
6
7
  type ComputerProjectionV1,
@@ -20,6 +21,14 @@ const projection: ComputerProjectionV1 & { progress: ComputerProgressViewV1 } =
20
21
  updatedAt: "2026-09-03T00:00:02.000Z",
21
22
  index: 2,
22
23
  total: 3,
24
+ provisioning: {
25
+ version: 1,
26
+ kind: "provision",
27
+ label: "installing the browser",
28
+ index: 4,
29
+ total: 5,
30
+ resumed: false,
31
+ },
23
32
  steps: [
24
33
  {
25
34
  version: 1,
@@ -51,6 +60,14 @@ describe("Computer projection progress", () => {
51
60
  );
52
61
  });
53
62
 
63
+ test("decodes the previous V1 progress shape without provisioning detail", () => {
64
+ const { provisioning: _provisioning, ...previous } = projection.progress;
65
+ expect(
66
+ decodeComputerProjectionV1({ ...projection, progress: previous })
67
+ .progress,
68
+ ).toEqual(previous);
69
+ });
70
+
54
71
  test("refuses malformed or extended progress at the client seam", () => {
55
72
  expect(() =>
56
73
  decodeComputerProjectionV1({
@@ -75,3 +92,23 @@ describe("Computer projection progress", () => {
75
92
  ).toThrow(ComputerProtocolDecodeError);
76
93
  });
77
94
  });
95
+
96
+ describe("Computer command acceptance", () => {
97
+ test("keeps durable admission distinct from a terminal receipt", () => {
98
+ expect(
99
+ decodeComputerCommandResponse({
100
+ version: 2,
101
+ commandId: "connect-1",
102
+ type: "connect",
103
+ status: "accepted",
104
+ admittedAt: "2026-09-03T00:00:00.000Z",
105
+ }),
106
+ ).toEqual({
107
+ version: 2,
108
+ commandId: "connect-1",
109
+ type: "connect",
110
+ status: "accepted",
111
+ admittedAt: "2026-09-03T00:00:00.000Z",
112
+ });
113
+ });
114
+ });