@frockbot/plugin-computer 0.2.5 → 0.3.1
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/frockbot.json +0 -1
- package/package.json +12 -12
- package/src/agent.ts +74 -70
- package/src/backend.ts +4 -4
- package/src/bot.test.ts +566 -3
- package/src/bot.ts +410 -10
- package/src/capture.ts +107 -0
- package/src/client/ComputerCard.test.ts +24 -4
- package/src/client/ComputerCard.vue +108 -16
- package/src/client/ComputerViewerOverlay.vue +67 -10
- package/src/client/application.test.ts +93 -15
- package/src/client/application.ts +50 -12
- package/src/client/index.ts +0 -6
- package/src/client/progress.test.ts +148 -0
- package/src/client/progress.ts +171 -0
- package/src/client/state-machine.test.ts +1 -1
- package/src/client/styles.css +75 -101
- package/src/protocol.test.ts +37 -0
- package/src/protocol.ts +111 -1
- package/src/screenshot.test.ts +100 -3
- package/src/workspace-fixture.ts +4 -0
- package/src/client/ComputerStrip.test.ts +0 -54
- package/src/client/ComputerStrip.vue +0 -55
|
@@ -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
|
|
125
|
+
test("an idle card click expands before its connect request", () => {
|
|
126
126
|
const idle = {
|
|
127
127
|
...initialComputerMachineState(),
|
|
128
128
|
phase: "idle" as const,
|
package/src/client/styles.css
CHANGED
|
@@ -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;
|
package/src/protocol.test.ts
CHANGED
|
@@ -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
|
+
});
|