@frockbot/plugin-shell 0.3.1 → 0.3.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/package.json +32 -29
- package/src/agent.test.ts +15 -3
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +66 -27
- package/src/backend-completion.ts +4 -2
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend-recovery.ts +20 -12
- package/src/backend-runner-iframe.test.ts +10 -1
- package/src/backend-runner.ts +9 -2
- package/src/backend-stop.test.ts +6 -6
- package/src/backend-supersede.test.ts +377 -0
- package/src/backend.ts +567 -13
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +195 -21
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +221 -7
- package/src/client/index.ts +398 -6
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +118 -1
- package/src/composition-views.ts +31 -6
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +70 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -0
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
decodePackageIframePageMessageV2,
|
|
4
|
+
packageIframeExternalUrlAllowedV2,
|
|
5
|
+
packageIframeFocusAllowedV2,
|
|
4
6
|
packageIframeToolAllowedV1,
|
|
7
|
+
type PackageIframeBridgeVersionV2,
|
|
5
8
|
type PackageIframeContributionViewV1,
|
|
6
|
-
type
|
|
9
|
+
type PackageIframeHostMessageV2,
|
|
10
|
+
type PackageIframePageViewV1,
|
|
7
11
|
} from "@frockbot/kernel-contracts";
|
|
8
12
|
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
9
13
|
import { frockBotWebDataKey } from "../shared.js";
|
|
10
14
|
import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
|
|
11
15
|
|
|
12
|
-
const props =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
contribution: PackageIframeContributionViewV1;
|
|
19
|
+
page: PackageIframePageViewV1;
|
|
20
|
+
slot: string;
|
|
21
|
+
/** The named state feeds this frame receives, by state name. */
|
|
22
|
+
states: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* `flow` gives the frame the height the page asks for; `fill` gives it the
|
|
25
|
+
* height of its container, for a page that owns a whole panel.
|
|
26
|
+
*/
|
|
27
|
+
layout?: "flow" | "fill";
|
|
28
|
+
/** A page hosted in a surface has its attribution drawn by the surface. */
|
|
29
|
+
attribution?: boolean;
|
|
30
|
+
}>(),
|
|
31
|
+
{ layout: "flow", attribution: true },
|
|
32
|
+
);
|
|
18
33
|
const providedWeb = inject(frockBotWebDataKey);
|
|
19
34
|
if (!providedWeb) throw new Error("Package iframe host data was not provided");
|
|
20
35
|
const web = providedWeb;
|
|
@@ -22,37 +37,86 @@ const frame = ref<HTMLIFrameElement>();
|
|
|
22
37
|
const height = ref(240);
|
|
23
38
|
const failure = ref<string>();
|
|
24
39
|
const lastStateWireByName = new Map<string, string>();
|
|
40
|
+
/*
|
|
41
|
+
* Which bridge this page reads. A page announces version 2 with `hello`; one
|
|
42
|
+
* that never announces is a version 1 page and is only ever sent version 1
|
|
43
|
+
* messages, so a page published before the bump keeps working unchanged.
|
|
44
|
+
*/
|
|
45
|
+
const bridgeVersion = ref<PackageIframeBridgeVersionV2>(1);
|
|
25
46
|
const catalog = computed(() => web.value.packageUi);
|
|
26
47
|
const source = computed(() => {
|
|
27
48
|
const origin = catalog.value?.artifactOrigin;
|
|
28
49
|
return origin
|
|
29
|
-
? `${origin}/packages/${props.
|
|
50
|
+
? `${origin}/packages/${props.page.artifact.contentHash}.html`
|
|
30
51
|
: "about:blank";
|
|
31
52
|
});
|
|
32
53
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
/*
|
|
55
|
+
* The theme a page is given.
|
|
56
|
+
*
|
|
57
|
+
* Design tokens are the contract between the shell and a Package's page (ADR
|
|
58
|
+
* 0007): a page is handed semantic names, never the shell's stylesheet, and
|
|
59
|
+
* never a colour to hard-code. The list is what an Applet kit needs to build a
|
|
60
|
+
* whole screen — surfaces, text, borders, the accent, the three status
|
|
61
|
+
* colours, the focus ring, geometry, and the type scale — under names that say
|
|
62
|
+
* what a value is for rather than which shell control it came from.
|
|
63
|
+
*/
|
|
64
|
+
const THEME_TOKENS: ReadonlyArray<readonly [string, string]> = [
|
|
65
|
+
["surface", "surface"],
|
|
66
|
+
["surface-raised", "surface-raised"],
|
|
67
|
+
["surface-subtle", "surface-subtle"],
|
|
68
|
+
["surface-window", "surface-window"],
|
|
69
|
+
["text", "text"],
|
|
70
|
+
["text-muted", "text-muted"],
|
|
71
|
+
["text-subtle", "text-subtle"],
|
|
72
|
+
["border", "border"],
|
|
73
|
+
["border-strong", "border-strong"],
|
|
74
|
+
["accent", "action-primary"],
|
|
75
|
+
["accent-hover", "action-primary-hover"],
|
|
76
|
+
["accent-pressed", "action-primary-pressed"],
|
|
77
|
+
["accent-surface", "accent-surface"],
|
|
78
|
+
["accent-text", "accent-text"],
|
|
79
|
+
["on-accent", "on-accent"],
|
|
80
|
+
["danger", "danger-text"],
|
|
81
|
+
["danger-surface", "danger-surface"],
|
|
82
|
+
["danger-border", "danger-border"],
|
|
83
|
+
["success", "success"],
|
|
84
|
+
["success-surface", "success-surface"],
|
|
85
|
+
["success-border", "success-border"],
|
|
86
|
+
["warning", "warning"],
|
|
87
|
+
["warning-surface", "warning-surface"],
|
|
88
|
+
["warning-border", "warning-border"],
|
|
89
|
+
["focus-ring", "focus-ring"],
|
|
90
|
+
["fill-hover", "fill-hover"],
|
|
91
|
+
["fill-pressed", "fill-pressed"],
|
|
92
|
+
["radius-control", "radius-control"],
|
|
93
|
+
["radius-card", "radius-card"],
|
|
94
|
+
["control-sm", "control-sm"],
|
|
95
|
+
["control-md", "control-md"],
|
|
96
|
+
["control-lg", "control-lg"],
|
|
97
|
+
["font-sans", "font-sans"],
|
|
98
|
+
["font-mono", "font-mono"],
|
|
99
|
+
["text-xs", "text-xs"],
|
|
100
|
+
["text-sm", "text-sm"],
|
|
101
|
+
["text-base", "text-base"],
|
|
102
|
+
["text-md", "text-md"],
|
|
103
|
+
["text-lg", "text-lg"],
|
|
104
|
+
["text-xl", "text-xl"],
|
|
105
|
+
["leading-normal", "leading-normal"],
|
|
106
|
+
["motion-fast", "motion-fast"],
|
|
43
107
|
] as const;
|
|
44
108
|
|
|
45
109
|
function themeTokens(): Record<string, string> {
|
|
46
110
|
const styles = getComputedStyle(document.documentElement);
|
|
47
111
|
return Object.fromEntries(
|
|
48
|
-
|
|
112
|
+
THEME_TOKENS.map(([name, token]) => [
|
|
49
113
|
name,
|
|
50
|
-
styles.getPropertyValue(`--frock-${
|
|
51
|
-
]),
|
|
114
|
+
styles.getPropertyValue(`--frock-${token}`).trim(),
|
|
115
|
+
]).filter(([, value]) => value !== ""),
|
|
52
116
|
);
|
|
53
117
|
}
|
|
54
118
|
|
|
55
|
-
function post(message:
|
|
119
|
+
function post(message: PackageIframeHostMessageV2): void {
|
|
56
120
|
const target = frame.value?.contentWindow;
|
|
57
121
|
if (!target) return;
|
|
58
122
|
try {
|
|
@@ -63,51 +127,75 @@ function post(message: PackageIframeHostMessageV1): void {
|
|
|
63
127
|
}
|
|
64
128
|
}
|
|
65
129
|
|
|
130
|
+
function postStates(): void {
|
|
131
|
+
for (const [name, value] of Object.entries(props.states)) {
|
|
132
|
+
post({
|
|
133
|
+
schemaVersion: bridgeVersion.value,
|
|
134
|
+
type: "state",
|
|
135
|
+
name,
|
|
136
|
+
value,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
66
141
|
function initialize(): void {
|
|
67
142
|
const botId = web.value.activeBotId;
|
|
68
143
|
if (!botId) return;
|
|
69
144
|
lastStateWireByName.clear();
|
|
70
145
|
post({
|
|
71
|
-
schemaVersion:
|
|
146
|
+
schemaVersion: bridgeVersion.value,
|
|
72
147
|
type: "init",
|
|
73
148
|
themeTokens: themeTokens(),
|
|
74
149
|
packageId: props.contribution.packageId,
|
|
75
150
|
botId,
|
|
76
151
|
slot: props.slot,
|
|
152
|
+
pageId: props.page.id,
|
|
77
153
|
});
|
|
78
|
-
|
|
79
|
-
schemaVersion: 1,
|
|
80
|
-
type: "state",
|
|
81
|
-
name: props.stateName,
|
|
82
|
-
value: props.stateValue,
|
|
83
|
-
});
|
|
154
|
+
postStates();
|
|
84
155
|
}
|
|
85
156
|
|
|
86
|
-
watch(
|
|
87
|
-
() => [props.stateName, props.stateValue] as const,
|
|
88
|
-
() =>
|
|
89
|
-
post({
|
|
90
|
-
schemaVersion: 1,
|
|
91
|
-
type: "state",
|
|
92
|
-
name: props.stateName,
|
|
93
|
-
value: props.stateValue,
|
|
94
|
-
}),
|
|
95
|
-
{ deep: true },
|
|
96
|
-
);
|
|
157
|
+
watch(() => props.states, postStates, { deep: true });
|
|
97
158
|
|
|
98
159
|
async function onMessage(event: MessageEvent): Promise<void> {
|
|
99
160
|
if (!frame.value?.contentWindow || event.source !== frame.value.contentWindow)
|
|
100
161
|
return;
|
|
101
162
|
let message;
|
|
102
163
|
try {
|
|
103
|
-
message =
|
|
164
|
+
message = decodePackageIframePageMessageV2(event.data);
|
|
104
165
|
} catch {
|
|
105
166
|
return;
|
|
106
167
|
}
|
|
168
|
+
if (message.type === "hello") {
|
|
169
|
+
// The announcement can land before or after the frame's load event, so the
|
|
170
|
+
// handshake is re-sent at the announced version either way.
|
|
171
|
+
if (bridgeVersion.value === message.bridgeVersion) return;
|
|
172
|
+
bridgeVersion.value = message.bridgeVersion;
|
|
173
|
+
initialize();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
107
176
|
if (message.type === "resize") {
|
|
108
177
|
height.value = Math.min(1_200, Math.max(96, Math.round(message.height)));
|
|
109
178
|
return;
|
|
110
179
|
}
|
|
180
|
+
if (message.type === "focus") {
|
|
181
|
+
if (!packageIframeFocusAllowedV2(props.contribution)) {
|
|
182
|
+
failure.value = "This Package cannot change the focused Applet.";
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
failure.value = undefined;
|
|
186
|
+
await web.value.setFocusedApplet(message.appletId);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (message.type === "openExternal") {
|
|
190
|
+
const origin = catalog.value?.artifactOrigin;
|
|
191
|
+
if (!origin || !packageIframeExternalUrlAllowedV2(message.url, origin)) {
|
|
192
|
+
failure.value = "This Package page can only open its own pages.";
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
failure.value = undefined;
|
|
196
|
+
window.open(message.url, "_blank", "noopener,noreferrer");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
111
199
|
if (!packageIframeToolAllowedV1(props.contribution, message.name)) {
|
|
112
200
|
failure.value = `This Package did not declare ${message.name}.`;
|
|
113
201
|
return;
|
|
@@ -120,7 +208,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
|
|
|
120
208
|
message.input,
|
|
121
209
|
);
|
|
122
210
|
post({
|
|
123
|
-
schemaVersion:
|
|
211
|
+
schemaVersion: bridgeVersion.value,
|
|
124
212
|
type: "state",
|
|
125
213
|
name: `tool:${message.name}`,
|
|
126
214
|
value: result,
|
|
@@ -128,7 +216,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
|
|
|
128
216
|
} catch (error) {
|
|
129
217
|
failure.value = error instanceof Error ? error.message : "Tool call failed";
|
|
130
218
|
post({
|
|
131
|
-
schemaVersion:
|
|
219
|
+
schemaVersion: bridgeVersion.value,
|
|
132
220
|
type: "state",
|
|
133
221
|
name: `tool:${message.name}`,
|
|
134
222
|
value: { isError: true, content: failure.value },
|
|
@@ -141,8 +229,8 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
|
|
|
141
229
|
</script>
|
|
142
230
|
|
|
143
231
|
<template>
|
|
144
|
-
<section class="package-iframe-frame">
|
|
145
|
-
<header class="package-iframe-attribution">
|
|
232
|
+
<section class="package-iframe-frame" :class="`package-iframe-${layout}`">
|
|
233
|
+
<header v-if="attribution" class="package-iframe-attribution">
|
|
146
234
|
<strong>{{ contribution.displayName }}</strong>
|
|
147
235
|
<span>{{ contribution.provenance }} Package</span>
|
|
148
236
|
</header>
|
|
@@ -151,7 +239,7 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
|
|
|
151
239
|
ref="frame"
|
|
152
240
|
:title="`${contribution.displayName} Package page`"
|
|
153
241
|
:src="source"
|
|
154
|
-
:style="{ height: `${height}px` }"
|
|
242
|
+
:style="layout === 'fill' ? undefined : { height: `${height}px` }"
|
|
155
243
|
sandbox="allow-scripts"
|
|
156
244
|
credentialless
|
|
157
245
|
referrerpolicy="no-referrer"
|
|
@@ -172,6 +260,19 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
|
|
|
172
260
|
background: var(--frock-surface);
|
|
173
261
|
}
|
|
174
262
|
|
|
263
|
+
.package-iframe-fill {
|
|
264
|
+
display: flex;
|
|
265
|
+
height: 100%;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
border: 0;
|
|
268
|
+
border-radius: 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.package-iframe-fill iframe {
|
|
272
|
+
min-height: 0;
|
|
273
|
+
flex: 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
175
276
|
.package-iframe-attribution {
|
|
176
277
|
position: relative;
|
|
177
278
|
z-index: 1;
|
|
@@ -11,9 +11,11 @@ const slot = "frockbot.bot-settings-sections";
|
|
|
11
11
|
const contributions = computed(() =>
|
|
12
12
|
(web.value.packageUi?.contributions ?? [])
|
|
13
13
|
.flatMap((contribution) =>
|
|
14
|
-
contribution.
|
|
15
|
-
.
|
|
16
|
-
|
|
14
|
+
contribution.pages.flatMap((page) =>
|
|
15
|
+
page.mounts
|
|
16
|
+
.filter((mount) => mount.slot === slot)
|
|
17
|
+
.map((mount) => ({ contribution, page, order: mount.order ?? 0 })),
|
|
18
|
+
),
|
|
17
19
|
)
|
|
18
20
|
.sort(
|
|
19
21
|
(left, right) =>
|
|
@@ -34,11 +36,11 @@ function settingsFor(packageId: string): Record<string, unknown> {
|
|
|
34
36
|
<div v-if="contributions.length > 0" class="package-iframe-settings">
|
|
35
37
|
<PackageIframeHost
|
|
36
38
|
v-for="entry in contributions"
|
|
37
|
-
:key="entry.contribution.packageId"
|
|
39
|
+
:key="`${entry.contribution.packageId}:${entry.page.id}`"
|
|
38
40
|
:contribution="entry.contribution"
|
|
41
|
+
:page="entry.page"
|
|
39
42
|
:slot="slot"
|
|
40
|
-
|
|
41
|
-
:state-value="settingsFor(entry.contribution.packageId)"
|
|
43
|
+
:states="{ settings: settingsFor(entry.contribution.packageId) }"
|
|
42
44
|
/>
|
|
43
45
|
</div>
|
|
44
46
|
</template>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A Package page hosted as a surface.
|
|
4
|
+
*
|
|
5
|
+
* The surface's chrome — its title and the way out of it — belongs to the
|
|
6
|
+
* shell; the page fills the body and is attributed to the Package that ships
|
|
7
|
+
* it, so a User always knows whose screen they are looking at.
|
|
8
|
+
*/
|
|
9
|
+
import { computed, inject } from "vue";
|
|
10
|
+
import { frockBotWebDataKey } from "../shared.js";
|
|
11
|
+
import { appletsBridgeStateV2 } from "./applets-state.js";
|
|
12
|
+
import PackageIframeHost from "./PackageIframeHost.vue";
|
|
13
|
+
import type { PackageIframeEntryV1 } from "./package-iframe-entries.js";
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{ entry: PackageIframeEntryV1 }>();
|
|
16
|
+
const providedWeb = inject(frockBotWebDataKey);
|
|
17
|
+
if (!providedWeb) throw new Error("Package surface data was not provided");
|
|
18
|
+
const web = providedWeb;
|
|
19
|
+
const states = computed(() => ({ applets: appletsBridgeStateV2(web.value) }));
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<div class="package-surface-page">
|
|
24
|
+
<PackageIframeHost
|
|
25
|
+
:contribution="props.entry.contribution"
|
|
26
|
+
:page="props.entry.page"
|
|
27
|
+
:slot="props.entry.slot"
|
|
28
|
+
:states="states"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<style scoped>
|
|
34
|
+
.package-surface-page {
|
|
35
|
+
display: grid;
|
|
36
|
+
min-width: 0;
|
|
37
|
+
gap: 16px;
|
|
38
|
+
}
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
mostRecentlyChangedFileV1,
|
|
4
|
+
readAppletList,
|
|
5
|
+
readAppletSource,
|
|
6
|
+
readFocusedAppletId,
|
|
7
|
+
writeFocusedAppletId,
|
|
8
|
+
type AppletsHostedRequest,
|
|
9
|
+
} from "./applets-client.js";
|
|
10
|
+
import { appletsAvailableV1, appletsBridgeStateV2 } from "./applets-state.js";
|
|
11
|
+
import type { FrockBotWebData } from "../shared.js";
|
|
12
|
+
|
|
13
|
+
const summary = {
|
|
14
|
+
appletId: "u1abc.todo",
|
|
15
|
+
displayName: "Todo",
|
|
16
|
+
status: "published" as const,
|
|
17
|
+
currentGenerationId: "generation-2",
|
|
18
|
+
tools: ["add_todo"],
|
|
19
|
+
createdAt: "2026-09-03T00:00:00.000Z",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A fake transport, so the client is proven without a backend on the branch. */
|
|
23
|
+
function transportOf(
|
|
24
|
+
answers: Record<string, unknown>,
|
|
25
|
+
recorded: Array<{ path: string; method?: string; body?: string }> = [],
|
|
26
|
+
): AppletsHostedRequest {
|
|
27
|
+
return (path, method, body) => {
|
|
28
|
+
recorded.push({
|
|
29
|
+
path,
|
|
30
|
+
...(method ? { method } : {}),
|
|
31
|
+
...(body ? { body } : {}),
|
|
32
|
+
});
|
|
33
|
+
if (!(path in answers))
|
|
34
|
+
return Promise.reject(new Error(`no route ${path}`));
|
|
35
|
+
return Promise.resolve(answers[path]);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("Applet routes", () => {
|
|
40
|
+
test("reads the User's Applets through the strict decoder", async () => {
|
|
41
|
+
const request = transportOf({
|
|
42
|
+
"/api/applets": { schemaVersion: 1, applets: [summary] },
|
|
43
|
+
});
|
|
44
|
+
expect(await readAppletList(request)).toEqual([summary]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("refuses a list the decoder does not accept", () => {
|
|
48
|
+
const request = transportOf({ "/api/applets": { applets: [summary] } });
|
|
49
|
+
expect(readAppletList(request)).rejects.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the focus write reads back what the backend recorded", async () => {
|
|
53
|
+
const recorded: Array<{ path: string; method?: string; body?: string }> =
|
|
54
|
+
[];
|
|
55
|
+
const request = transportOf(
|
|
56
|
+
{ "/api/bots/bot-1/applets/focus": { appletId: "u1abc.todo" } },
|
|
57
|
+
recorded,
|
|
58
|
+
);
|
|
59
|
+
// The click asked for one Applet; the answer is the one that was recorded.
|
|
60
|
+
expect(await writeFocusedAppletId(request, "bot-1", "u1abc.other")).toBe(
|
|
61
|
+
"u1abc.todo",
|
|
62
|
+
);
|
|
63
|
+
expect(recorded[0]).toEqual({
|
|
64
|
+
path: "/api/bots/bot-1/applets/focus",
|
|
65
|
+
method: "POST",
|
|
66
|
+
body: JSON.stringify({ schemaVersion: 1, appletId: "u1abc.other" }),
|
|
67
|
+
});
|
|
68
|
+
expect(await readFocusedAppletId(request, "bot-1")).toBe("u1abc.todo");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("no focus is a value, not a missing read", async () => {
|
|
72
|
+
const request = transportOf({
|
|
73
|
+
"/api/bots/bot-1/applets/focus": { appletId: null },
|
|
74
|
+
});
|
|
75
|
+
expect(await readFocusedAppletId(request, "bot-1")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the source read is bot-scoped and decoded", async () => {
|
|
79
|
+
const view = {
|
|
80
|
+
appletId: "u1abc.todo",
|
|
81
|
+
files: [
|
|
82
|
+
{
|
|
83
|
+
path: "server.ts",
|
|
84
|
+
text: "export class A {}",
|
|
85
|
+
generationId: "w-1",
|
|
86
|
+
changedAt: "2026-09-03T00:01:00.000Z",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
truncated: false,
|
|
90
|
+
};
|
|
91
|
+
const request = transportOf({
|
|
92
|
+
"/api/bots/bot-1/applets/u1abc.todo/source": view,
|
|
93
|
+
});
|
|
94
|
+
expect(await readAppletSource(request, "bot-1", "u1abc.todo")).toEqual(
|
|
95
|
+
view,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("the code view follows the most recently changed file", () => {
|
|
100
|
+
expect(mostRecentlyChangedFileV1(undefined)).toBeUndefined();
|
|
101
|
+
expect(
|
|
102
|
+
mostRecentlyChangedFileV1({
|
|
103
|
+
appletId: "u1abc.todo",
|
|
104
|
+
truncated: false,
|
|
105
|
+
files: [
|
|
106
|
+
{
|
|
107
|
+
path: "server.ts",
|
|
108
|
+
text: "",
|
|
109
|
+
generationId: "w-1",
|
|
110
|
+
changedAt: "2026-09-03T00:01:00.000Z",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
path: "ui.tsx",
|
|
114
|
+
text: "",
|
|
115
|
+
generationId: "w-2",
|
|
116
|
+
changedAt: "2026-09-03T00:05:00.000Z",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
).toBe("ui.tsx");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("the applets feed a page receives", () => {
|
|
125
|
+
const web = {
|
|
126
|
+
applets: [summary],
|
|
127
|
+
focusedAppletId: "u1abc.todo",
|
|
128
|
+
appletViewer: {
|
|
129
|
+
appletId: "u1abc.todo",
|
|
130
|
+
token: "signed",
|
|
131
|
+
expiresAt: "2026-09-03T00:15:00.000Z",
|
|
132
|
+
socketUrl: "wss://bot.example.com/api/applets/u1abc.todo/socket",
|
|
133
|
+
uiUrl: "https://ui.example.com/packages/aa.html",
|
|
134
|
+
generationId: "generation-2",
|
|
135
|
+
},
|
|
136
|
+
appletSource: undefined,
|
|
137
|
+
appletBuild: undefined,
|
|
138
|
+
} satisfies Pick<
|
|
139
|
+
FrockBotWebData,
|
|
140
|
+
| "applets"
|
|
141
|
+
| "focusedAppletId"
|
|
142
|
+
| "appletViewer"
|
|
143
|
+
| "appletSource"
|
|
144
|
+
| "appletBuild"
|
|
145
|
+
>;
|
|
146
|
+
|
|
147
|
+
test("carries the focused Applet, the list, and the viewer", () => {
|
|
148
|
+
const state = appletsBridgeStateV2(web);
|
|
149
|
+
expect(state.focused).toEqual(summary);
|
|
150
|
+
expect(state.list).toEqual([summary]);
|
|
151
|
+
expect(state.viewer?.generationId).toBe("generation-2");
|
|
152
|
+
expect(state.viewer).not.toHaveProperty("expiresAt");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a viewer credential minted for another Applet is not handed over", () => {
|
|
156
|
+
const state = appletsBridgeStateV2({
|
|
157
|
+
...web,
|
|
158
|
+
focusedAppletId: "u1abc.other",
|
|
159
|
+
});
|
|
160
|
+
expect(state.viewer).toBeNull();
|
|
161
|
+
expect(state.focused).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("Applets are available only when a Package declares the focus tool", () => {
|
|
165
|
+
const page = {
|
|
166
|
+
id: "canvas",
|
|
167
|
+
artifact: {
|
|
168
|
+
contentHash: "a".repeat(64),
|
|
169
|
+
size: 1,
|
|
170
|
+
mediaType: "text/html" as const,
|
|
171
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
172
|
+
},
|
|
173
|
+
mounts: [{ slot: "frockbot.right-panel" }],
|
|
174
|
+
};
|
|
175
|
+
const catalog = (declaredTools: string[]) => ({
|
|
176
|
+
schemaVersion: 1 as const,
|
|
177
|
+
botId: "bot-1",
|
|
178
|
+
generationId: "generation-1",
|
|
179
|
+
artifactOrigin: "https://ui.example.com",
|
|
180
|
+
contributions: [
|
|
181
|
+
{
|
|
182
|
+
packageId: "applets",
|
|
183
|
+
displayName: "Applets",
|
|
184
|
+
provenance: "Bot-authored" as const,
|
|
185
|
+
pages: [page],
|
|
186
|
+
entries: [],
|
|
187
|
+
declaredTools,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
// Derived from a manifest fact, never from a Package id: a deployment
|
|
192
|
+
// without Applets asks for no Applet route at all.
|
|
193
|
+
expect(appletsAvailableV1(catalog(["applet_focus"]))).toBe(true);
|
|
194
|
+
expect(appletsAvailableV1(catalog(["weather_lookup"]))).toBe(false);
|
|
195
|
+
expect(appletsAvailableV1(undefined)).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("no focus is null rather than an absent field", () => {
|
|
199
|
+
const state = appletsBridgeStateV2({ ...web, focusedAppletId: null });
|
|
200
|
+
expect(state.focused).toBeNull();
|
|
201
|
+
expect(state.viewer).toBeNull();
|
|
202
|
+
expect(state.list).toEqual([summary]);
|
|
203
|
+
});
|
|
204
|
+
});
|