@tapcue/extension-sdk 0.1.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,2330 @@
1
+ /**
2
+ * A deterministic, in-process stand-in for the Tapcue extension host.
3
+ *
4
+ * It is not a simulator of V8: it is a faithful implementation of the *policy*
5
+ * the real host enforces at the op boundary — effective grants, result
6
+ * validation, scope-type namespacing, subject round-tripping, batch
7
+ * reconciliation, cancellation and stale generations, quotas. Anything the real
8
+ * host would reject, this rejects.
9
+ *
10
+ * Nothing here is a security boundary. It exists so an extension's tests can
11
+ * prove it behaves correctly when a permission is denied, a query is cancelled,
12
+ * or a service misbehaves.
13
+ */
14
+
15
+ import type {
16
+ BridgeCapability,
17
+ BridgeValue,
18
+ CacheCapability,
19
+ ClipboardCapability,
20
+ ClipboardImage,
21
+ ExecCapability,
22
+ ExecResult,
23
+ FileEntry,
24
+ FilesCapability,
25
+ QueriesCapability,
26
+ QueryRow,
27
+ QueryValue,
28
+ McpSession,
29
+ McpTool,
30
+ Color,
31
+ EnvironmentSnapshot,
32
+ HttpCapability,
33
+ LocationSnapshot,
34
+ NativeActionsCapability,
35
+ ImageHandle,
36
+ Layer,
37
+ OverlayEvent,
38
+ OverlaySession,
39
+ OverlaySpec,
40
+ Point,
41
+ Rect,
42
+ SaveFile,
43
+ ScreenCapability,
44
+ SvgNode,
45
+ FontFamily,
46
+ InvocationContextCapability,
47
+ ProcessInfo,
48
+ SettingsSnapshot,
49
+ StorageCapability,
50
+ StorageHandle,
51
+ WritableProcessManager,
52
+ WritableSettings,
53
+ } from "../capabilities.js";
54
+ import type {
55
+ DefinedScope,
56
+ ExtensionDefinition,
57
+ ProvisionHandlers,
58
+ WorkspaceRoot,
59
+ } from "../define-extension.js";
60
+ import { ExtensionError, cancelledError } from "../errors.js";
61
+ import {
62
+ type LocaleCatalogs,
63
+ type Translate,
64
+ createTranslator,
65
+ resolveCatalog,
66
+ } from "../i18n.js";
67
+ import type { JsonValue } from "../json.js";
68
+ import {
69
+ DEFAULT_ACTION_ID,
70
+ SLOT_TYPES,
71
+ type ExtensionManifest,
72
+ type ExecGrant,
73
+ type QueryGrant,
74
+ type FileReadGrant,
75
+ type ManifestCommand,
76
+ type ManifestContribution,
77
+ type ManifestProvision,
78
+ type ManifestPermissions,
79
+ coerceSetting,
80
+ effectiveGrants,
81
+ iconProblem,
82
+ namespacedItemId,
83
+ permissionUnits,
84
+ scopeTypePrefix,
85
+ settingsDefaults,
86
+ validateManifest,
87
+ } from "../manifest.js";
88
+ import { PERMISSION_UNITS } from "../permission-units.js";
89
+ import type { Chart, Scene, SceneAction, SceneBlock, WebViewScene } from "../scene.js";
90
+ import { applyAction, driveView, renderView } from "../view-runtime.js";
91
+ import { type Signal, applySettingChange, settingSignal } from "../reactive.js";
92
+ import type { ActionPatch, DataModel, PatchOp, ViewResult } from "../view.js";
93
+ import {
94
+ CONTRIBUTABLE_SCOPE_TYPES,
95
+ CONTRIBUTION_MATCH_FIELDS,
96
+ NATIVE_SCOPE_TYPES,
97
+ type ActionResult,
98
+ type ContributableScopeType,
99
+ type ContributionHandlers,
100
+ type NativeSubjects,
101
+ type CommandItem,
102
+ type ActionContext,
103
+ type ItemScope,
104
+ type NavigationCapability,
105
+ type QueryResult,
106
+ type RecentsCapability,
107
+ } from "../types.js";
108
+ import { colorHex } from "../capabilities.js";
109
+ import { FakeHttpBroker, type HttpRoute, type RecordedRequest, redact } from "./http-fake.js";
110
+
111
+ /**
112
+ * Every field of the subject Tapcue projects for a contributable native scope — the shape
113
+ * `NativeSubjects` declares, as data, so the fake can check a test's fixture against it.
114
+ *
115
+ * Wider than `CONTRIBUTION_MATCH_FIELDS`: an extension *receives* the whole subject (a file's
116
+ * `path` included, which is what a contribution is usually for) while a `when` predicate may only
117
+ * match on a field naming a *kind* of subject. Reading a path you were handed and declaring
118
+ * interest in every path there is are different asks.
119
+ */
120
+ const NATIVE_SUBJECT_FIELDS: Readonly<Record<ContributableScopeType, readonly string[]>> =
121
+ Object.freeze({
122
+ application: ["bundleId", "name"],
123
+ file: ["path", "name", "extension", "isDirectory"],
124
+ "file.folder": ["path", "name", "extension", "isDirectory"],
125
+ });
126
+
127
+ export interface HostLimits {
128
+ /**
129
+ * Rows in one batch (architecture §14). A `query` that returns an array returns one batch, so
130
+ * this is also the cap on the simple case.
131
+ */
132
+ maxItemsPerBatch: number;
133
+ /**
134
+ * Rows across every batch of one invocation (architecture §14) — **five times the batch cap**,
135
+ * and it used to be equal to it. That made the deterministic host stricter than the host it
136
+ * stands in for: a catalog bigger than one batch (a design system's 242 swatches) was rejected
137
+ * here and accepted in production, which is the wrong direction for a policy fake to be wrong in.
138
+ */
139
+ maxItemsPerInvocation: number;
140
+ maxResultBytes: number;
141
+ maxScopeSubjectBytes: number;
142
+ maxRedirects: number;
143
+ maxConcurrentRequests: number;
144
+ maxResponseBytes: number;
145
+ /** Query and detail wall time (architecture §14). */
146
+ defaultDeadlineMs: number;
147
+ /** User-action wall time: an action waits on a person, a query waits on nobody. */
148
+ defaultActionDeadlineMs: number;
149
+ }
150
+
151
+ export const DEFAULT_LIMITS: HostLimits = {
152
+ maxItemsPerBatch: 200,
153
+ maxItemsPerInvocation: 1_000,
154
+ maxResultBytes: 8 * 1024 * 1024,
155
+ maxScopeSubjectBytes: 4 * 1024,
156
+ maxRedirects: 4,
157
+ maxConcurrentRequests: 4,
158
+ maxResponseBytes: 8 * 1024 * 1024,
159
+ defaultDeadlineMs: 2_000,
160
+ defaultActionDeadlineMs: 30_000,
161
+ };
162
+
163
+ /** A file the host would find under one `files.read` grant. */
164
+ export interface FakeFile {
165
+ name: string;
166
+ content: string;
167
+ modifiedAt?: number;
168
+ }
169
+
170
+ /**
171
+ * What a declared `queries` entry returns when it is run.
172
+ *
173
+ * A function rather than only rows, because the values that fill the `?` holes are the half of a
174
+ * stored query a test most needs to pin: a statement whose `LIMIT ?` is ignored looks identical to
175
+ * one that honours it until a fixture can see the binding.
176
+ */
177
+ export type FakeQuery = QueryRow[] | ((values: QueryValue[]) => QueryRow[]);
178
+
179
+ /** What a declared `exec` entry does when it is run. */
180
+ export interface FakeExec {
181
+ /**
182
+ * For an argv entry: given the *filled* argv, what it writes.
183
+ *
184
+ * Return a string for the ordinary case, or `{ stderr, exitCode }` to model a program that ran
185
+ * and *refused* — a CLI that is installed but not signed in, say. **Throwing is a different
186
+ * thing**: it models the binary not being runnable at all, which is what `run` rejects on.
187
+ */
188
+ run?(argv: string[]): string | { stdout?: string; stderr?: string; exitCode?: number };
189
+ /** For a `protocol: "mcp"` entry: the tools it offers and what each returns. */
190
+ tools?: McpTool[];
191
+ call?(name: string, args: Record<string, JsonValue>): JsonValue;
192
+ }
193
+
194
+ export interface TestHostOptions {
195
+ manifest: ExtensionManifest;
196
+ extension: ExtensionDefinition;
197
+ /**
198
+ * The files each `files.read` grant finds, keyed by the grant's index in the manifest.
199
+ *
200
+ * Keyed by index rather than by path on purpose: the index is the whole of what the guest can
201
+ * name, so a test that had to invent a path would be testing an interface the extension does not
202
+ * have.
203
+ */
204
+ files?: Record<number, FakeFile[]>;
205
+ /** What each declared `exec` id does, keyed by its id. */
206
+ exec?: Record<string, FakeExec>;
207
+ /** What each declared `queries` id returns, keyed by its id. */
208
+ queries?: Record<string, FakeQuery>;
209
+ /**
210
+ * Which of the manifest's *optional* permissions the user approved. Defaults to
211
+ * all of them. Required permissions are always granted: denying one means the
212
+ * extension is not enabled, which is an install-time outcome, not a runtime one.
213
+ */
214
+ approvedOptional?: ManifestPermissions;
215
+ locales?: LocaleCatalogs;
216
+ http?: readonly HttpRoute[];
217
+ location?: LocationSnapshot | null;
218
+ /**
219
+ * The machine's installed fonts, as the host would report them. The fake sorts them the way the
220
+ * real host must (§ Local Font Access): install order is not something a guest may observe.
221
+ */
222
+ fonts?: readonly FontFamily[];
223
+ /**
224
+ * The processes running on the machine, as the host would report them. The fake sorts them by
225
+ * pid (launch order must not leak) and strips each `command` (argv) unless `process.command` is
226
+ * granted — the same policy the real host enforces. A test that wants a live list re-reads
227
+ * `list()` after mutating this via `advanceTime`-style fixtures; here it is a fixed snapshot.
228
+ */
229
+ processes?: readonly ProcessInfo[];
230
+ /**
231
+ * The fake desktop an overlay is drawn over, and what the user does on it.
232
+ *
233
+ * `colorAt` is the screen's contents — a function, so a test can paint whatever it
234
+ * needs (a gradient, one flat colour, a checkerboard). `events` is what the user does,
235
+ * in order; the stream ends with `cancelled` after the last one, because a user who
236
+ * walks away is a case an overlay must survive.
237
+ */
238
+ screen?: {
239
+ bounds?: Rect;
240
+ colorAt?: (point: Point) => Color;
241
+ events?: readonly OverlayEvent[];
242
+ /**
243
+ * The user closing a `coach` card. Without it a card stays up until the extension closes it,
244
+ * which is what one does on a real screen.
245
+ */
246
+ dismissCoach?: boolean;
247
+ };
248
+ /**
249
+ * The live WebView surface a `canvas` scope presents. `respond` is what the WebView's own
250
+ * code replies when the isolate asks it something over `ctx.bridge` — a test scripts it (a
251
+ * PNG for an "export" request, say). It is deliberately a separate, untrusted-looking
252
+ * object: the fake returns exactly what `respond` gives back, so a test proves the isolate
253
+ * validates the reply rather than trusting it.
254
+ */
255
+ surface?: {
256
+ respond?(message: BridgeValue): BridgeValue | Promise<BridgeValue>;
257
+ };
258
+ settings?: Record<string, JsonValue>;
259
+ /** How Tapcue writes a `Color` to the clipboard — the *user's* preference, not the extension's. */
260
+ colorFormat?: "hex" | "rgb" | "hsl";
261
+ environment?: Partial<EnvironmentSnapshot>;
262
+ limits?: Partial<HostLimits>;
263
+ /** Deterministic clock, in ms. Advance it with `host.advanceTime`. */
264
+ now?: number;
265
+ }
266
+
267
+ /**
268
+ * Which handler is running. Interactive capabilities — ones that take over the
269
+ * screen or the keyboard — exist only in `invoke`: a `query` runs on a keystroke,
270
+ * and a keystroke must never be able to hand an extension the whole display.
271
+ */
272
+ export type InvocationKind = "query" | "detail" | "invoke";
273
+
274
+ export interface Diagnostic {
275
+ level: "info" | "warn" | "error";
276
+ message: string;
277
+ }
278
+
279
+ export interface QueryOptions {
280
+ query?: string;
281
+ arguments?: Record<string, string>;
282
+ limit?: number;
283
+ }
284
+
285
+ export interface ScopeSession {
286
+ type: string;
287
+ scopeId: string;
288
+ items(options?: { query?: string; limit?: number }): Promise<CommandItem[]>;
289
+ detail(itemId: string): Promise<Scene | null>;
290
+ invoke(itemId: string, actionId: string): Promise<ActionResult>;
291
+ /**
292
+ * Present a `canvas` scope's WebView surface, exactly as entering the scope does. Returns
293
+ * the `WebViewScene` (or a loading/error scene). While it is up, `ctx.bridge` is reachable
294
+ * from the footer actions — so call this before `act`, as the shell would.
295
+ */
296
+ surface(): Promise<Scene>;
297
+ /** Run a `canvas` scope's footer action. No row, so no itemId — that is the whole point. */
298
+ act(actionId: string): Promise<ActionResult>;
299
+ /**
300
+ * Render a scope's interactive `view` detail for the selected row, exactly as selecting it
301
+ * does (docs/architecture/extension-ui-interactive.md). Returns the initial
302
+ * `{view, model, actions}`; drive interaction with `viewAction`.
303
+ */
304
+ view(itemId: string): Promise<ViewResult>;
305
+ /**
306
+ * Fire one of the view's declared actions with the model the shell would hand back — the
307
+ * initial model with any two-way-bound field (a `Form` value) updated to what the user chose.
308
+ * Returns the model patch the action produced.
309
+ */
310
+ viewAction(
311
+ itemId: string,
312
+ actionId: string,
313
+ args: JsonValue[],
314
+ model: DataModel,
315
+ ): Promise<ActionPatch>;
316
+ /**
317
+ * Present a view and keep it alive, as showing its surface does. Renders the initial frame and
318
+ * starts whatever its `whileVisible` setups registered; each signal write those make streams a
319
+ * patch into `patches`. `stop()` ends the visible period as hiding the surface does, running the
320
+ * cleanups. A view with no setups is finished the moment it returns.
321
+ */
322
+ liveView(itemId: string): Promise<LiveViewSession>;
323
+ }
324
+
325
+ /** A presented view: its initial render, and the patches its signal writes have streamed. */
326
+ export interface LiveViewSession {
327
+ readonly initial: ViewResult;
328
+ /** Every streamed patch, in order — one entry per microtask batch of signal writes. */
329
+ readonly patches: PatchOp[][];
330
+ /** End the visible period, as hiding the surface does; the setups' cleanups run. */
331
+ stop(): Promise<void>;
332
+ }
333
+
334
+ /**
335
+ * **Standing inside a native scope, the way the user does** — and asking what this extension
336
+ * adds to it (architecture §11.3).
337
+ *
338
+ * The subject is Tapcue's here, not the extension's: the fake projects and validates it exactly
339
+ * as the shell does, then applies each `contributes[].when` predicate *before* calling anything.
340
+ * That order is the policy, not an optimization — a contribution that did not match is never told
341
+ * the subject exists, so a test can prove an extension learns nothing about apps it never named.
342
+ */
343
+ export interface NativeScopeSession {
344
+ type: ContributableScopeType;
345
+ scopeId: string;
346
+ /**
347
+ * The contribution ids whose `when` admits this subject — the gate, readable. Empty means the
348
+ * extension is not called at all for this scope, which is the normal case for most subjects.
349
+ */
350
+ readonly contributions: string[];
351
+ /** Rows this contribution adds. Throws if its `when` does not admit the session's subject. */
352
+ items(
353
+ contributionId: string,
354
+ options?: { query?: string; limit?: number },
355
+ ): Promise<CommandItem[]>;
356
+ invoke(contributionId: string, itemId: string, actionId: string): Promise<ActionResult>;
357
+ }
358
+
359
+ /** What `enterNative` needs to stand somewhere: which scope, and what it is about. */
360
+ export interface NativeScopeEntry {
361
+ type: ContributableScopeType;
362
+ /** The scope's subject — `NativeSubjects[type]`. Validated against the declared shape. */
363
+ subject: Record<string, JsonValue>;
364
+ /**
365
+ * What the scope's **owner** does with the rows a contributor returns, which is the owner's
366
+ * decision and not the contributor's. Defaults to `remote`, because that is what every
367
+ * contributable native scope is today: their own sources take the query and narrow on it, so
368
+ * the query is forwarded and the rows are shown as returned. Pass `catalog` to prove an
369
+ * extension still behaves when it lands in a scope that filters locally — there the query
370
+ * arrives **empty**, which is the trap worth having a test for.
371
+ */
372
+ queryMode?: "catalog" | "remote";
373
+ /** Override the scope id the shell would assign. Rarely needed; it is only a cache key. */
374
+ scopeId?: string;
375
+ }
376
+
377
+ export interface TestHost {
378
+ query(commandId: string, options?: QueryOptions): Promise<CommandItem[]>;
379
+ invoke(commandId: string, itemId: string, actionId: string): Promise<ActionResult>;
380
+ /**
381
+ * Run a `no-view` command's own row, as pressing Enter on it does: no item, and
382
+ * the reserved `default` action id.
383
+ */
384
+ run(commandId: string): Promise<ActionResult>;
385
+ /** Enter an item's scope, exactly as pressing Enter on it would. */
386
+ enter(item: CommandItem): ScopeSession;
387
+ /**
388
+ * Stand inside a **native** scope — an app's, a file's — as the user does when they Tab into
389
+ * it, and exercise whatever this extension contributes there (architecture §11.3).
390
+ *
391
+ * The counterpart to `enter`, which refuses a native scope for a good reason: entering one
392
+ * *as a subject you produced* is Tapcue's business to render. This is the other direction —
393
+ * the scope already exists, the user is already in it, and the only thing under test is the
394
+ * extension's own rows.
395
+ */
396
+ enterNative(scope: NativeScopeEntry): NativeScopeSession;
397
+ /** Abort the in-flight invocation (user typed again, panel closed, revocation). */
398
+ cancel(): void;
399
+ /** Revoke an optional permission while the extension is installed and running. */
400
+ revoke(unit: string): void;
401
+ advanceTime(ms: number): void;
402
+ /** Replace the process table `ctx.process.list()` reads, so a test can move the world between
403
+ * two `list()` calls and assert what the extension made of the change. */
404
+ setProcesses(processes: readonly ProcessInfo[]): void;
405
+ /** Every batch the last query yielded, in order. One entry for an array return. */
406
+ readonly batches: CommandItem[][];
407
+ readonly requests: RecordedRequest[];
408
+ readonly diagnostics: Diagnostic[];
409
+ readonly clipboard: string[];
410
+ /** Every image the extension wrote to the clipboard (text/colour writes stay in `clipboard`). */
411
+ readonly clipboardImages: ClipboardImage[];
412
+ /**
413
+ * Every secret the extension wrote — deliberately *not* in `clipboard`. A secret carries the
414
+ * "do not remember this" pasteboard markers, and Tapcue's own clipboard history honours them,
415
+ * so the value never enters the history `clipboard` stands for.
416
+ */
417
+ readonly clipboardSecrets: string[];
418
+ readonly opened: string[];
419
+ /** Every binary run, as the *filled* argv the host would have spawned. No shell, so no string. */
420
+ readonly ran: { id: string; argv: string[] }[];
421
+ /** Every stored query run, with the values bound to its `?` holes, in order. */
422
+ readonly queried: { id: string; values: QueryValue[] }[];
423
+ /**
424
+ * **Ask the `workspace.roots` slot**, as Tapcue does when the launcher opens.
425
+ *
426
+ * Models the gate as well as the call: without the `workspace.roots` grant the host never asks,
427
+ * so this answers empty *without running the handler* — the absence of a capability is the check
428
+ * (§7.1), and a test that only saw an empty array could not tell a denied slot from an extension
429
+ * that found no projects.
430
+ */
431
+ workspaceRoots(options?: { provisionId?: string; limit?: number }): Promise<WorkspaceRoot[]>;
432
+ readonly notifications: { title: string; body?: string }[];
433
+ /** Every file the extension asked the host to save. The host owns where it lands. */
434
+ readonly saves: RecordedSave[];
435
+ /** Every WebView surface a `canvas` scope presented, in order. */
436
+ readonly surfaces: RecordedSurface[];
437
+ /** Every message the extension sent a live surface over `ctx.bridge`. */
438
+ readonly bridgeMessages: BridgeValue[];
439
+ /** Every overlay the extension opened, with every frame it drew on it. */
440
+ readonly overlays: RecordedOverlay[];
441
+ /**
442
+ * Every pid the extension asked the host to kill / to bring to the front, in order. Both are
443
+ * fire-and-forget effects, so a test asserts what was *requested* — the host owns the confirm.
444
+ */
445
+ readonly kills: number[];
446
+ readonly activated: number[];
447
+ /**
448
+ * Every scope the extension asked the host to enter, in order — the items it handed
449
+ * `ctx.navigate.enter`. Fire-and-forget like the two above: the *host* pushes the scope, so a
450
+ * test asserts what was asked for, then `host.enter(...)` that item to walk into it.
451
+ */
452
+ readonly navigations: CommandItem[];
453
+ /**
454
+ * Every setting write, in order, as `[key, value]`. The value has already landed in
455
+ * `settingsValues` — read it back with `ctx.settings.get` or assert the trail here.
456
+ */
457
+ readonly settingsWrites: [string, JsonValue][];
458
+ readonly generation: number;
459
+ }
460
+
461
+ /** A file the extension asked the host to save. The host sanitizes `name` and owns the path. */
462
+ export interface RecordedSave {
463
+ name: string;
464
+ type: string;
465
+ data: Uint8Array;
466
+ }
467
+
468
+ /** A WebView surface a `canvas` scope presented. */
469
+ export interface RecordedSurface {
470
+ entry: string;
471
+ title?: string;
472
+ actions: SceneAction[];
473
+ }
474
+
475
+ export function createTestHost(options: TestHostOptions): TestHost {
476
+ const problems = validateManifest(options.manifest);
477
+ if (problems.length > 0) {
478
+ const detail = problems.map((p) => `${p.path || "<root>"}: ${p.message}`).join("; ");
479
+ throw new Error(`invalid manifest: ${detail}`);
480
+ }
481
+
482
+ const manifest = options.manifest;
483
+ const limits: HostLimits = { ...DEFAULT_LIMITS, ...options.limits };
484
+ let grants = effectiveGrants(manifest, options.approvedOptional);
485
+ const requests: RecordedRequest[] = [];
486
+ const diagnostics: Diagnostic[] = [];
487
+ const clipboard: string[] = [];
488
+ const clipboardImages: ClipboardImage[] = [];
489
+ const clipboardSecrets: string[] = [];
490
+ /** This extension's recents, newest first — the fake for Tapcue's unified Recents. */
491
+ const recentsStore: CommandItem[] = [];
492
+ const opened: string[] = [];
493
+ const ran: { id: string; argv: string[] }[] = [];
494
+ /** Every stored query the extension ran, with the values it bound, in order. */
495
+ const queried: { id: string; values: QueryValue[] }[] = [];
496
+ const notifications: { title: string; body?: string }[] = [];
497
+ const saves: RecordedSave[] = [];
498
+ const surfaces: RecordedSurface[] = [];
499
+ const bridgeMessages: BridgeValue[] = [];
500
+ const overlays: RecordedOverlay[] = [];
501
+ /**
502
+ * Every pid the extension asked the host to kill / to bring to the front, in order. `kill` is
503
+ * fire-and-forget: the host would coalesce these into one confirm dialog and decide the signal
504
+ * with the user, and the extension never learns the outcome — so a test can only assert what it
505
+ * *requested*, which is exactly what these record.
506
+ */
507
+ const kills: number[] = [];
508
+ const activated: number[] = [];
509
+ /** Scopes the extension asked to enter, and settings it asked to change. Both are requests the
510
+ * host carries out, so the fake records the ask — the same shape `kills` takes. */
511
+ const navigations: CommandItem[] = [];
512
+ const settingsWrites: [string, JsonValue][] = [];
513
+ /** The process table `ctx.process.list()` reads, mutable so a test can change the world between
514
+ * two `list()` calls and assert what the extension made of the change. */
515
+ let processesFixture: readonly ProcessInfo[] = options.processes ?? [];
516
+ /**
517
+ * The WebView surface currently up, and the isolate's only handle to it. It is set when a
518
+ * `canvas` scope presents its surface and stays until another scope is entered — modelling
519
+ * the shell owning the surface, not the isolate: `ctx.bridge` reconnects to this rather than
520
+ * the isolate holding it across the eviction that almost certainly happened while drawing.
521
+ */
522
+ let liveSurface: { respond(m: BridgeValue): BridgeValue | Promise<BridgeValue> } | null = null;
523
+ /** The user's colour format, as the native Color provider would supply it. */
524
+ const colorFormat = options.colorFormat ?? "hex";
525
+ let batches: CommandItem[][] = [];
526
+ let currentKind: InvocationKind = "query";
527
+
528
+ /** Guest item id → the command whose query produced it. See `enter`. */
529
+ const itemOrigin = new Map<string, string>();
530
+ /**
531
+ * **Manifest contributions and their handlers, checked against each other at load.**
532
+ *
533
+ * Both directions fail, because both directions are half a contribution: a manifest entry with
534
+ * no handler promises the user rows nobody wrote, and a handler with no manifest entry is code
535
+ * the host will never call — and a handler that is never called is exactly the kind of silence
536
+ * this project has paid for before.
537
+ */
538
+ const contributions = new Map<
539
+ string,
540
+ { manifest: ManifestContribution; handlers: ContributionHandlers }
541
+ >();
542
+ {
543
+ const declared = manifest.contributes ?? [];
544
+ const handlers = options.extension.contributions ?? {};
545
+ for (const entry of declared) {
546
+ const handler = handlers[entry.id];
547
+ if (!handler) {
548
+ throw new Error(
549
+ `contributes["${entry.id}"] is declared in the manifest but has no handler in defineExtension({ contributions })`,
550
+ );
551
+ }
552
+ if (typeof handler.items !== "function") {
553
+ throw new Error(`contribution "${entry.id}" must declare an items handler`);
554
+ }
555
+ if (!(CONTRIBUTABLE_SCOPE_TYPES as readonly string[]).includes(entry.scope)) {
556
+ throw new Error(
557
+ `contribution "${entry.id}" targets "${entry.scope}", which is not a contributable native scope`,
558
+ );
559
+ }
560
+ contributions.set(entry.id, { manifest: entry, handlers: handler });
561
+ }
562
+ for (const id of Object.keys(handlers)) {
563
+ if (!contributions.has(id)) {
564
+ throw new Error(
565
+ `contributions["${id}"] has no matching contributes[] entry in the manifest — nothing would ever call it`,
566
+ );
567
+ }
568
+ }
569
+ }
570
+
571
+ /**
572
+ * **Manifest provisions and their handlers**, checked both directions like contributions, and
573
+ * for the same reason: half a provision is a slot Tapcue will ask about and nothing will answer,
574
+ * or an answer nothing will ever ask for.
575
+ */
576
+ const provisions = new Map<string, { manifest: ManifestProvision; handlers: ProvisionHandlers }>();
577
+ {
578
+ const declared = manifest.provides ?? [];
579
+ const handlers = options.extension.provisions ?? {};
580
+ for (const entry of declared) {
581
+ const handler = handlers[entry.id];
582
+ if (!handler) {
583
+ throw new Error(
584
+ `provides["${entry.id}"] is declared in the manifest but has no handler in defineExtension({ provisions })`,
585
+ );
586
+ }
587
+ if (!(SLOT_TYPES as readonly string[]).includes(entry.slot)) {
588
+ throw new Error(`provision "${entry.id}" fills "${entry.slot}", which is not a slot`);
589
+ }
590
+ if (entry.slot === "workspace.roots" && typeof handler.roots !== "function") {
591
+ throw new Error(`provision "${entry.id}" fills workspace.roots and must declare a roots handler`);
592
+ }
593
+ provisions.set(entry.id, { manifest: entry, handlers: handler });
594
+ }
595
+ for (const id of Object.keys(handlers)) {
596
+ if (!provisions.has(id)) {
597
+ throw new Error(
598
+ `provisions["${id}"] has no matching provides[] entry in the manifest — nothing would ever ask it`,
599
+ );
600
+ }
601
+ }
602
+ }
603
+
604
+ const scopes = new Map<string, DefinedScope<JsonValue>>();
605
+ for (const scope of options.extension.scopes) {
606
+ const prefix = scopeTypePrefix(manifest.id);
607
+ if (!scope.type.startsWith(prefix)) {
608
+ throw new Error(
609
+ `scope type "${scope.type}" must be namespaced "${prefix}…" — an extension cannot own a native subject type`,
610
+ );
611
+ }
612
+ // A layout and its source have to agree: a `canvas` scope draws a surface and has no rows,
613
+ // every other layout draws rows and has no surface. A handler the shell will never call is
614
+ // a handler an author will believe in, so the host rejects the mismatch rather than ignore it.
615
+ const layout = scope.spec.scheme.layout;
616
+ const isCanvas = layout === "canvas";
617
+ // A `pane` scope is the third rowless shape: one semantic pane, drawn from `view`/`detail`.
618
+ const isPane = layout === "pane";
619
+ if (isCanvas && !scope.spec.surface) {
620
+ throw new Error(`canvas scope "${scope.type}" must declare a surface handler`);
621
+ }
622
+ if (isCanvas && scope.spec.items) {
623
+ throw new Error(`canvas scope "${scope.type}" has no rows — declare surface, not items`);
624
+ }
625
+ if (isPane && scope.spec.items) {
626
+ throw new Error(`pane scope "${scope.type}" has no rows — declare view or detail, not items`);
627
+ }
628
+ if (isPane && scope.spec.surface) {
629
+ throw new Error(`surface is for a canvas scope; "${scope.type}" is a pane`);
630
+ }
631
+ if (isPane && !scope.spec.view && !scope.spec.detail) {
632
+ throw new Error(`pane scope "${scope.type}" must declare a view or detail handler`);
633
+ }
634
+ if (!isCanvas && !isPane && !scope.spec.items) {
635
+ throw new Error(`scope "${scope.type}" (${layout}) must declare an items handler`);
636
+ }
637
+ if (!isCanvas && scope.spec.surface) {
638
+ throw new Error(`surface is for a canvas scope; "${scope.type}" is ${layout}`);
639
+ }
640
+ scopes.set(scope.type, scope as DefinedScope<JsonValue>);
641
+ }
642
+
643
+ let nowMs = options.now ?? 0;
644
+ let generation = 0;
645
+ let controller = new AbortController();
646
+ let window = new AbortController();
647
+
648
+ const cacheStore = new Map<string, { value: string; expiresAt: number | null }>();
649
+ const storageStore = new Map<string, Map<string, string>>();
650
+ const settingsValues: Record<string, JsonValue> = {
651
+ ...settingsDefaults(manifest),
652
+ ...(options.settings ?? {}),
653
+ };
654
+ const declaredSettingKeys = new Set((manifest.settings ?? []).map((s) => s.key));
655
+
656
+ const broker = new FakeHttpBroker({
657
+ // Read through a getter so a revocation lands on the very next request.
658
+ get allowedHosts() {
659
+ return grants.http?.hosts ?? [];
660
+ },
661
+ routes: options.http ?? [],
662
+ limits,
663
+ record: (request) => requests.push(request),
664
+ });
665
+
666
+ const environment: EnvironmentSnapshot = {
667
+ locale: "en-US",
668
+ timeZone: "Europe/Berlin",
669
+ appearance: "light",
670
+ platform: "macos",
671
+ // Defaults to everything the manifest declared, because the common case a test is written for
672
+ // is "the integration is installed". A test about a *missing* app names the subset it wants.
673
+ installedApps: manifest.integratesWith ?? [],
674
+ ...options.environment,
675
+ };
676
+ const translate: Translate = createTranslator(
677
+ resolveCatalog(options.locales ?? {}, environment.locale, manifest.defaultLocale ?? "en"),
678
+ (key) => diagnostics.push({ level: "warn", message: `missing message catalog key "${key}"` }),
679
+ );
680
+
681
+ /**
682
+ * The two contexts, and the reason there are two: a `query` runs on a keystroke, so nothing
683
+ * that acts on the machine may be reachable from one. That used to be a runtime check. It
684
+ * is a type now — and the fake builds exactly the type the real host would hand over, or a
685
+ * test would prove something the compiler already forbids.
686
+ */
687
+ function makeContext(deadlineMs: number, kind: InvocationKind): ActionContext {
688
+ // The run window, not the invocation. A streaming handler observes `ctx.signal` to know it was
689
+ // told to stop, and `collect` ends a generator by aborting the *window* (`endRunWindow`);
690
+ // cancelling the invocation aborts the window too, so this sees both. Wired to
691
+ // `controller.signal` this used to see only a cancel, and the window machinery was inert.
692
+ const signal = window.signal;
693
+
694
+ const settings: WritableSettings = {
695
+ get<T extends JsonValue = JsonValue>(key: string): T {
696
+ if (!declaredSettingKeys.has(key)) {
697
+ throw new ExtensionError({
698
+ code: "invalid-request",
699
+ message: `setting "${key}" is not declared in the manifest`,
700
+ });
701
+ }
702
+ return settingsValues[key] as T;
703
+ },
704
+ all: () => Object.freeze({ ...settingsValues }),
705
+ // The watchable form. Same declaration check as `get` — a signal following a key the
706
+ // manifest does not name would be a cell nothing can ever write.
707
+ signal<T extends JsonValue = JsonValue>(key: string): Signal<T> {
708
+ if (!declaredSettingKeys.has(key)) {
709
+ throw new ExtensionError({
710
+ code: "invalid-request",
711
+ message: `setting "${key}" is not declared in the manifest`,
712
+ });
713
+ }
714
+ return settingSignal<T>(key, settingsValues[key] as T);
715
+ },
716
+ // The write is action-tier, so a `query` gets a snapshot whose `set` refuses — mirroring
717
+ // the real host, where the method is simply absent from a query's context. Throwing rather
718
+ // than silently dropping is the fake's job: a test that reaches for it from a keystroke
719
+ // should fail loudly, not pass with nothing written.
720
+ set(key: string, value: JsonValue): void {
721
+ if (kind !== "invoke") {
722
+ throw new ExtensionError({
723
+ code: "permission-denied",
724
+ message: "ctx.settings.set is only available while running an action",
725
+ });
726
+ }
727
+ // The manifest decides. An undeclared key is not a new setting and a `select` value that
728
+ // is not one of its options is not a new option — both are dropped, as the host drops them.
729
+ const coerced = coerceSetting(manifest, key, value);
730
+ if (coerced === undefined) {
731
+ diagnostics.push({
732
+ level: "warn",
733
+ message: `settings.set("${key}") ignored: not a declared setting, or the value is not its declared type`,
734
+ });
735
+ return;
736
+ }
737
+ settingsValues[key] = coerced;
738
+ settingsWrites.push([key, coerced]);
739
+ // What the real host's glue does after the op: the value is the isolate's now, so every
740
+ // live view following this key is written and re-armed without a round trip.
741
+ applySettingChange(key, coerced);
742
+ },
743
+ };
744
+
745
+ const http: HttpCapability | undefined = grants.http
746
+ ? {
747
+ fetch: (url, init) => broker.fetch(url, init, signal, {}),
748
+ client: (clientOptions) => ({
749
+ fetch: (url, init) => broker.fetch(url, init, signal, clientOptions?.headers ?? {}),
750
+ }),
751
+ }
752
+ : undefined;
753
+
754
+ const cache: CacheCapability | undefined = grants.cache
755
+ ? {
756
+ async get<T extends JsonValue = JsonValue>(key: string): Promise<T | undefined> {
757
+ const entry = cacheStore.get(key);
758
+ if (!entry) return undefined;
759
+ if (entry.expiresAt !== null && entry.expiresAt <= nowMs) {
760
+ cacheStore.delete(key);
761
+ return undefined;
762
+ }
763
+ return JSON.parse(entry.value) as T;
764
+ },
765
+ async set(key, value, setOptions) {
766
+ const encoded = JSON.stringify(value);
767
+ const quota = grants.cache?.maxBytes ?? 0;
768
+ const used = [...cacheStore.entries()]
769
+ .filter(([existing]) => existing !== key)
770
+ .reduce((total, [, entry]) => total + entry.value.length, 0);
771
+ if (used + encoded.length > quota) {
772
+ throw new ExtensionError({
773
+ code: "quota-exceeded",
774
+ message: `cache quota of ${quota} bytes exceeded`,
775
+ });
776
+ }
777
+ cacheStore.set(key, {
778
+ value: encoded,
779
+ expiresAt: setOptions?.ttlMs === undefined ? null : nowMs + setOptions.ttlMs,
780
+ });
781
+ },
782
+ async delete(key) {
783
+ cacheStore.delete(key);
784
+ },
785
+ }
786
+ : undefined;
787
+
788
+ const storage: StorageCapability | undefined = grants.storage
789
+ ? {
790
+ open(namespace: string): StorageHandle {
791
+ const bucket = storageStore.get(namespace) ?? new Map<string, string>();
792
+ storageStore.set(namespace, bucket);
793
+ return {
794
+ async get<T extends JsonValue = JsonValue>(key: string) {
795
+ const raw = bucket.get(key);
796
+ return raw === undefined ? undefined : (JSON.parse(raw) as T);
797
+ },
798
+ async set(key, value) {
799
+ const encoded = JSON.stringify(value);
800
+ const quota = grants.storage?.maxBytes ?? 0;
801
+ let used = 0;
802
+ for (const entries of storageStore.values()) {
803
+ for (const [existingKey, existing] of entries) {
804
+ if (entries === bucket && existingKey === key) continue;
805
+ used += existing.length;
806
+ }
807
+ }
808
+ if (used + encoded.length > quota) {
809
+ throw new ExtensionError({
810
+ code: "quota-exceeded",
811
+ message: `storage quota of ${quota} bytes exceeded`,
812
+ });
813
+ }
814
+ bucket.set(key, encoded);
815
+ },
816
+ async delete(key) {
817
+ bucket.delete(key);
818
+ },
819
+ async keys() {
820
+ return [...bucket.keys()];
821
+ },
822
+ };
823
+ },
824
+ }
825
+ : undefined;
826
+
827
+ // Each method exists only when its own permission unit is granted: the
828
+ // capability surface *is* the grant, so an extension never has to ask.
829
+ let context: InvocationContextCapability | undefined;
830
+ if (grants.context) {
831
+ context = {};
832
+ if (grants.context.frontmostApp) context.frontmostApp = async () => null;
833
+ if (grants.context.selectedText) context.selectedText = async () => null;
834
+ if (grants.context.selectedFiles) context.selectedFiles = async () => [];
835
+ }
836
+
837
+ // Machine-wide queries. Separate grants from the invocation trio above, because they answer a
838
+ // different question and a user approves them separately.
839
+ const fonts = grants.fonts
840
+ ? {
841
+ list: async () => {
842
+ if (signal.aborted) throw cancelledError();
843
+ // Sorted, always. The real host must not let install order leak, so neither does the
844
+ // fake — a test that relied on insertion order would be proving the wrong thing.
845
+ return [...(options.fonts ?? [])].sort((a, b) => a.family.localeCompare(b.family));
846
+ },
847
+ }
848
+ : undefined;
849
+
850
+ const location = grants.location
851
+ ? {
852
+ current: async () => {
853
+ if (signal.aborted) throw cancelledError();
854
+ return options.location ?? null;
855
+ },
856
+ }
857
+ : undefined;
858
+
859
+ // The processes on this machine. The read (`list`) is a machine fact like `fonts`, present on
860
+ // every context; the effects (`kill`, `activate`) sit on an action only, exactly as
861
+ // `clipboard.write` and `native.*` do. The whole object is keyed on the read being granted,
862
+ // which the manifest validator guarantees whenever `command`/`kill` are — so the type's
863
+ // `WritableProcessManager extends ReadonlyProcessManager` (list always present) holds.
864
+ let process: WritableProcessManager | undefined;
865
+ if (grants.process?.list) {
866
+ const withCommand = Boolean(grants.process.command);
867
+ process = {
868
+ list: async () => {
869
+ if (signal.aborted) throw cancelledError();
870
+ // Host-sorted by pid so launch order never leaks, and argv (`command`) stripped unless
871
+ // the separate `process.command` unit is granted — the sensitive half is its own denial.
872
+ return [...processesFixture]
873
+ .sort((a, b) => a.pid - b.pid)
874
+ .map((p) => (withCommand ? { ...p } : stripCommand(p)));
875
+ },
876
+ };
877
+ // Fire-and-forget: the op re-checks the phase, and the fake mirrors that by attaching the
878
+ // effects only in an action. The extension gets no outcome — the host coalesces and confirms
879
+ // these, and the next `list()` is how a list learns a process is gone.
880
+ if (grants.process.kill && kind === "invoke") {
881
+ process.kill = (pid: number) => {
882
+ kills.push(pid);
883
+ };
884
+ process.activate = (pid: number) => {
885
+ activated.push(pid);
886
+ };
887
+ }
888
+ }
889
+
890
+ /**
891
+ * **`files` — the grant index is the whole interface.**
892
+ *
893
+ * The fake refuses a grant the manifest did not declare and a name the listing did not return,
894
+ * which together are the real host's guarantee: the guest never composes a path, so there is
895
+ * nothing for it to escape with. `readText` is the same read, decoded.
896
+ */
897
+ let filesCapability: FilesCapability | undefined;
898
+ const declaredReads: FileReadGrant[] = grants.files?.read ?? [];
899
+ if (declaredReads.length > 0) {
900
+ const listing = (grant: number): FakeFile[] => {
901
+ if (!Number.isInteger(grant) || grant < 0 || grant >= declaredReads.length) {
902
+ throw new ExtensionError({
903
+ code: "permission-denied",
904
+ message: `files.read grant ${grant} was not declared`,
905
+ });
906
+ }
907
+ return options.files?.[grant] ?? [];
908
+ };
909
+ const bytesOf = (grant: number, name: string): Uint8Array => {
910
+ const file = listing(grant).find((candidate) => candidate.name === name);
911
+ if (!file) {
912
+ // Not "missing file": the guest may only name what the host listed, so an unknown name is
913
+ // an attempt to reach outside the grant, whether or not it meant to be.
914
+ throw new ExtensionError({
915
+ code: "permission-denied",
916
+ message: `"${name}" is not a file this grant matches`,
917
+ });
918
+ }
919
+ return new TextEncoder().encode(file.content);
920
+ };
921
+ filesCapability = {
922
+ async list(grant) {
923
+ return listing(grant).map(
924
+ (file): FileEntry => ({
925
+ name: file.name,
926
+ size: new TextEncoder().encode(file.content).length,
927
+ modifiedAt: file.modifiedAt ?? 0,
928
+ }),
929
+ );
930
+ },
931
+ async read(grant, name) {
932
+ return bytesOf(grant, name);
933
+ },
934
+ async readText(grant, name) {
935
+ return new TextDecoder().decode(bytesOf(grant, name));
936
+ },
937
+ };
938
+ }
939
+
940
+ /**
941
+ * **`queries` — the statement is fixed, the values are not.**
942
+ *
943
+ * The fake refuses an id the manifest did not declare and counts the `?` holes against the
944
+ * values supplied, which is the whole of what a guest could otherwise reshape. It cannot check
945
+ * the SQL itself — that is the real host's job, against a real read-only connection — so a test
946
+ * that wants to model a moved schema throws from its fixture.
947
+ */
948
+ let queriesCapability: QueriesCapability | undefined;
949
+ const declaredQueries: QueryGrant[] = grants.queries ?? [];
950
+ if (declaredQueries.length > 0) {
951
+ queriesCapability = {
952
+ async run(id, values = []) {
953
+ const grant = declaredQueries.find((candidate) => candidate.id === id);
954
+ if (!grant) {
955
+ throw new ExtensionError({
956
+ code: "permission-denied",
957
+ message: `query "${id}" was not declared`,
958
+ });
959
+ }
960
+ const holes = (grant.sql.match(/\?/g) ?? []).length;
961
+ if (values.length !== holes) {
962
+ throw new ExtensionError({
963
+ code: "invalid-argument",
964
+ message: `query "${id}" takes ${holes} value(s), got ${values.length}`,
965
+ });
966
+ }
967
+ const supplied = [...values];
968
+ queried.push({ id, values: supplied });
969
+ const fake = options.queries?.[id];
970
+ const rows = typeof fake === "function" ? fake(supplied) : (fake ?? []);
971
+ return rows.slice(0, limits.maxItemsPerBatch);
972
+ },
973
+ };
974
+ }
975
+
976
+ /**
977
+ * **`exec` — the template is the interface.**
978
+ *
979
+ * The fake fills the declared `{}` holes positionally and refuses a call whose argument count
980
+ * does not match, because that mismatch is the only way a guest could otherwise reshape argv.
981
+ * It records the *filled* argv, so a test asserts what would actually have been spawned.
982
+ */
983
+ let execCapability: ExecCapability | undefined;
984
+ const declaredExec: ExecGrant[] = grants.exec ?? [];
985
+ if (declaredExec.length > 0) {
986
+ const declaration = (id: string): ExecGrant => {
987
+ const found = declaredExec.find((grant) => grant.id === id);
988
+ if (!found) {
989
+ throw new ExtensionError({
990
+ code: "permission-denied",
991
+ message: `exec "${id}" was not declared`,
992
+ });
993
+ }
994
+ return found;
995
+ };
996
+ execCapability = {
997
+ async run(id, args = []) {
998
+ const grant = declaration(id);
999
+ if (grant.protocol === "mcp") {
1000
+ throw new ExtensionError({
1001
+ code: "invalid-argument",
1002
+ message: `exec "${id}" speaks mcp — use ctx.exec.mcp(), not run()`,
1003
+ });
1004
+ }
1005
+ const template = grant.args ?? [];
1006
+ const holes = template.filter((part) => part === "{}").length;
1007
+ if (args.length !== holes) {
1008
+ throw new ExtensionError({
1009
+ code: "invalid-argument",
1010
+ message: `exec "${id}" takes ${holes} argument(s), got ${args.length}`,
1011
+ });
1012
+ }
1013
+ let next = 0;
1014
+ const argv = template.map((part) => (part === "{}" ? args[next++] : part));
1015
+ ran.push({ id, argv });
1016
+ const fake = options.exec?.[id];
1017
+ const produced = fake?.run?.(argv) ?? "";
1018
+ const result: ExecResult =
1019
+ typeof produced === "string"
1020
+ ? { stdout: produced, stderr: "", exitCode: 0, truncated: false }
1021
+ : {
1022
+ stdout: produced.stdout ?? "",
1023
+ stderr: produced.stderr ?? "",
1024
+ exitCode: produced.exitCode ?? 0,
1025
+ truncated: false,
1026
+ };
1027
+ if (result.stdout.length > limits.maxResultBytes) {
1028
+ return { ...result, stdout: result.stdout.slice(0, limits.maxResultBytes), truncated: true };
1029
+ }
1030
+ return result;
1031
+ },
1032
+ async mcp(id) {
1033
+ const grant = declaration(id);
1034
+ if (grant.protocol !== "mcp") {
1035
+ throw new ExtensionError({
1036
+ code: "invalid-argument",
1037
+ message: `exec "${id}" is argv-invoked — use ctx.exec.run(), not mcp()`,
1038
+ });
1039
+ }
1040
+ const fake = options.exec?.[id];
1041
+ const session: McpSession = {
1042
+ async listTools() {
1043
+ return fake?.tools ?? [];
1044
+ },
1045
+ async callTool(name, args = {}) {
1046
+ const tools = fake?.tools ?? [];
1047
+ if (!tools.some((tool) => tool.name === name)) {
1048
+ // The server's own refusal, modelled: a tool it does not offer is not callable, and
1049
+ // `listTools` is how an extension is supposed to find that out.
1050
+ throw new ExtensionError({
1051
+ code: "invalid-argument",
1052
+ message: `mcp server "${id}" offers no tool "${name}"`,
1053
+ });
1054
+ }
1055
+ ran.push({ id, argv: ["mcp", name] });
1056
+ return fake?.call?.(name, args) ?? null;
1057
+ },
1058
+ };
1059
+ return session;
1060
+ },
1061
+ };
1062
+ }
1063
+
1064
+ let clipboardCapability: ClipboardCapability | undefined;
1065
+ if (grants.clipboard) {
1066
+ clipboardCapability = {};
1067
+ if (grants.clipboard.read) clipboardCapability.read = async () => clipboard.at(-1) ?? null;
1068
+ // The write is an effect, so it exists only where effects are allowed.
1069
+ if (grants.clipboard.write && kind === "invoke") {
1070
+ clipboardCapability.write = async (value) => {
1071
+ if (typeof value === "string") {
1072
+ clipboard.push(value);
1073
+ } else if ("secret" in value) {
1074
+ // **The policy, not a convenience.** A secret is written with the markers that ask
1075
+ // every clipboard manager — Tapcue's own history first — not to remember it, so it
1076
+ // must not appear in `clipboard`, which is what stands in for that history here. A
1077
+ // test asserting a password manager copied a password should be able to assert the
1078
+ // password is *not* in the history, and that only works if the fake keeps them apart.
1079
+ clipboardSecrets.push(value.secret);
1080
+ } else if ("png" in value) {
1081
+ // An image says *what*, not *how*: the host owns which pasteboard flavours a PNG
1082
+ // becomes, exactly as it owns a colour's format. The fake just records the bytes.
1083
+ clipboardImages.push(value);
1084
+ } else {
1085
+ // Tapcue formats a Color by the user's preference, not the extension's. The fake
1086
+ // honours the same rule so a test cannot accidentally prove the wrong thing.
1087
+ clipboard.push(formatColor(value, colorFormat));
1088
+ }
1089
+ };
1090
+ }
1091
+ }
1092
+
1093
+ // Recents: `list` reads this extension's own records (a read, on every context); `record`
1094
+ // and `remove` are effects, so action-only. The store is in-memory and per-host, so a test
1095
+ // can pick a colour and read it straight back.
1096
+ let recentsCapability: RecentsCapability | undefined;
1097
+ if (grants.recents) {
1098
+ recentsCapability = {};
1099
+ if (grants.recents.list) {
1100
+ recentsCapability.list = async () => recentsStore.slice();
1101
+ }
1102
+ if (kind === "invoke") {
1103
+ if (grants.recents.record) {
1104
+ recentsCapability.record = async (item) => {
1105
+ const rest = recentsStore.filter((existing) => existing.id !== item.id);
1106
+ recentsStore.length = 0;
1107
+ recentsStore.push(item, ...rest);
1108
+ };
1109
+ }
1110
+ if (grants.recents.remove) {
1111
+ recentsCapability.remove = async (id) => {
1112
+ const kept = recentsStore.filter((existing) => existing.id !== id);
1113
+ recentsStore.length = 0;
1114
+ recentsStore.push(...kept);
1115
+ };
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ // Native effects. `open` and `reveal` are **ambient** — action-only, no grant — so any action
1121
+ // has them; the host restricts `open` to http/https, and the fake records the URL. `notify` and
1122
+ // `save` still need their units.
1123
+ let native: NativeActionsCapability | undefined;
1124
+ if (kind === "invoke") {
1125
+ native = {
1126
+ open: async (url) => {
1127
+ opened.push(url);
1128
+ },
1129
+ reveal: async () => {},
1130
+ };
1131
+ if (grants.native?.notify) {
1132
+ native.notify = async (notification) => {
1133
+ notifications.push(notification);
1134
+ };
1135
+ }
1136
+ if (grants.native?.save) {
1137
+ native.save = async (file) => {
1138
+ // The host names and places the file; the fake records what it was handed. A real
1139
+ // host strips any directory part of `name` — the fake keeps it verbatim so a test
1140
+ // can prove the extension did not smuggle a path in, which is the host's to reject.
1141
+ saves.push({ name: file.name, type: file.type, data: file.data });
1142
+ };
1143
+ }
1144
+ }
1145
+
1146
+ // Pickers are their own group, not a fourth `native.*` verb: they take the
1147
+ // screen, wait on a person, and return a value — none of which `open`, `reveal`,
1148
+ // or `notify` do. The whole group is absent outside an action, and the ops
1149
+ // re-check the invocation kind on every call rather than trusting that absence:
1150
+ // a guest that stashed one from an earlier action gets `permission-denied`.
1151
+ // The screen is an *action's* capability. A query runs on a keystroke, and a
1152
+ // keystroke must never be able to cover the display. The ops re-check the invocation
1153
+ // kind on every call rather than trusting that absence: a guest that stashed the
1154
+ // function from an earlier action and calls it during a query gets permission-denied.
1155
+ let screen: ScreenCapability | undefined;
1156
+ if (grants.screen?.overlay && kind === "invoke") {
1157
+ screen = {
1158
+ createOverlay: async (spec?: OverlaySpec) => {
1159
+ if (!grants.screen?.overlay) {
1160
+ throw new ExtensionError({
1161
+ code: "permission-denied",
1162
+ message: "screen.overlay is not granted",
1163
+ });
1164
+ }
1165
+ if (currentKind !== "invoke") {
1166
+ throw new ExtensionError({
1167
+ code: "permission-denied",
1168
+ message: "screen.overlay is only available while running an action",
1169
+ });
1170
+ }
1171
+ if (signal.aborted) throw cancelledError();
1172
+ return openOverlay(spec, signal);
1173
+ },
1174
+ };
1175
+ }
1176
+
1177
+ // The bridge to a live WebView surface. Present only in an action, only with `ui.webview`,
1178
+ // and only while a surface is actually up (`liveSurface`) — the isolate that presented the
1179
+ // surface was very likely evicted while the user drew, so a fresh action reconnects to the
1180
+ // shell-owned surface. The reply is whatever the surface's own code returned: untrusted, so
1181
+ // the extension validates it. The op would re-check the kind on every call; the fake mirrors
1182
+ // that by rebuilding the capability per invocation rather than trusting a stashed reference.
1183
+ let bridge: BridgeCapability | undefined;
1184
+ if (grants.ui?.webview && kind === "invoke" && liveSurface) {
1185
+ bridge = {
1186
+ request: async (message) => {
1187
+ if (currentKind !== "invoke") {
1188
+ throw new ExtensionError({
1189
+ code: "permission-denied",
1190
+ message: "ctx.bridge is only available while running an action",
1191
+ });
1192
+ }
1193
+ if (!liveSurface) {
1194
+ throw new ExtensionError({
1195
+ code: "invalid-request",
1196
+ message: "no WebView surface is up to receive a bridge message",
1197
+ });
1198
+ }
1199
+ if (signal.aborted) throw cancelledError();
1200
+ bridgeMessages.push(message);
1201
+ return liveSurface.respond(message);
1202
+ },
1203
+ };
1204
+ }
1205
+
1206
+ /**
1207
+ * Entering a scope is the host's move, so the fake records the ask and performs nothing —
1208
+ * `host.navigations` is what a test asserts, and `host.enter(item)` is how it walks in. The
1209
+ * guard mirrors the host's: an item that enters a scope outside this extension's namespace,
1210
+ * or no scope at all, is refused rather than quietly followed.
1211
+ */
1212
+ const navigate: NavigationCapability | undefined =
1213
+ kind === "invoke"
1214
+ ? {
1215
+ enter(item: CommandItem): void {
1216
+ const type = item.scope?.type;
1217
+ if (type === undefined || !type.startsWith(scopeTypePrefix(manifest.id))) {
1218
+ throw new ExtensionError({
1219
+ code: "invalid-request",
1220
+ message: "navigate.enter takes an item produced by this extension's scope.entry()",
1221
+ });
1222
+ }
1223
+ navigations.push(item);
1224
+ },
1225
+ }
1226
+ : undefined;
1227
+
1228
+ return {
1229
+ signal,
1230
+ deadlineMs,
1231
+ settings,
1232
+ environment,
1233
+ t: translate,
1234
+ http,
1235
+ cache,
1236
+ storage,
1237
+ context,
1238
+ fonts,
1239
+ location,
1240
+ process,
1241
+ clipboard: clipboardCapability,
1242
+ files: filesCapability,
1243
+ exec: execCapability,
1244
+ queries: queriesCapability,
1245
+ recents: recentsCapability,
1246
+ native,
1247
+ screen: kind === "invoke" ? screen : undefined,
1248
+ bridge: kind === "invoke" ? bridge : undefined,
1249
+ navigate,
1250
+ };
1251
+ }
1252
+
1253
+ /**
1254
+ * Every invocation runs against the generation that started it. A result that
1255
+ * lands after the generation moved on is dropped, exactly as the shell drops a
1256
+ * late scene update — including a batch from a stream that is still running.
1257
+ */
1258
+ async function run<T>(
1259
+ deadlineMs: number,
1260
+ kind: InvocationKind,
1261
+ body: (ctx: ActionContext) => T | Promise<T>,
1262
+ ): Promise<T> {
1263
+ const startedAt = generation;
1264
+ // The handler sees the run window; the checks below see the invocation. Cancelling the
1265
+ // invocation ends the window with it.
1266
+ const invocation = controller.signal;
1267
+ const signal = window.signal;
1268
+ const endWindow = () => window.abort();
1269
+ invocation.addEventListener("abort", endWindow, { once: true });
1270
+ currentKind = kind;
1271
+ const ctx = makeContext(deadlineMs, kind);
1272
+ const aborted = new Promise<never>((_resolve, reject) => {
1273
+ if (invocation.aborted) reject(cancelledError());
1274
+ invocation.addEventListener("abort", () => reject(cancelledError()), { once: true });
1275
+ });
1276
+ const result = await Promise.race([body(ctx), aborted]);
1277
+ if (generation !== startedAt || invocation.aborted) throw cancelledError();
1278
+ return result;
1279
+ }
1280
+
1281
+ function startInvocation(): void {
1282
+ generation += 1;
1283
+ controller = new AbortController();
1284
+ window = new AbortController();
1285
+ batches = [];
1286
+ }
1287
+
1288
+ /// Ending a stream is not cancelling the call.
1289
+ ///
1290
+ /// A generator that keeps yielding has to be told to stop, and what a handler observes when it
1291
+ /// is told is `ctx.signal` — the same thing it observes when the user cancels. The difference is
1292
+ /// what the *caller* sees: a cancelled invocation throws, while a stream that was asked to stop
1293
+ /// returns the rows it produced. So the window aborts on its own, and cancelling the invocation
1294
+ /// aborts it too.
1295
+ function endRunWindow(): void {
1296
+ window.abort();
1297
+ }
1298
+
1299
+ /**
1300
+ * Collect a possibly-streamed result. Each batch is validated on arrival, then
1301
+ * reconciled into the accumulated list by item id — same id updates in place,
1302
+ * a new id appends — which is exactly what core does with a provider's stream.
1303
+ *
1304
+ * A generator that keeps yielding needs a stopping rule, because the real shell's is "the scope
1305
+ * stopped being shown" and a test has no scope on a screen. The rule here is **take what is
1306
+ * ready**: batches are collected while they arrive without the generator waiting on anything,
1307
+ * and the first time it awaits — a timer, a socket — the run window is aborted, which is exactly
1308
+ * what the shell does on teardown and what runs the generator's `finally`. A `top` that refreshes
1309
+ * on a clock therefore yields its first snapshot to a test and stops, rather than hanging it.
1310
+ */
1311
+ async function collect(
1312
+ result: QueryResult,
1313
+ commandId: string,
1314
+ limit: number,
1315
+ startedAt: number,
1316
+ ): Promise<CommandItem[]> {
1317
+ if (Array.isArray(result)) {
1318
+ const validated = validateItems(result, commandId, limit);
1319
+ batches.push(validated);
1320
+ return validated;
1321
+ }
1322
+
1323
+ let merged: CommandItem[] = [];
1324
+ let index = new Map<string, number>();
1325
+ const iterator = result[Symbol.asyncIterator]();
1326
+ const pending = Symbol("pending");
1327
+ while (true) {
1328
+ // Race the next batch against a turn of the event loop: a generator with a batch in hand
1329
+ // wins it, one that has settled into `await sleep(…)` does not.
1330
+ const next = await Promise.race([
1331
+ iterator.next(),
1332
+ new Promise<typeof pending>((resolve) => setTimeout(() => resolve(pending), 0)),
1333
+ ]);
1334
+ if (next === pending) {
1335
+ endRunWindow();
1336
+ await iterator.return?.(undefined);
1337
+ break;
1338
+ }
1339
+ if (next.done) break;
1340
+ const batch = next.value;
1341
+ if (generation !== startedAt || controller.signal.aborted) throw cancelledError();
1342
+ const replaces = !Array.isArray(batch);
1343
+ const validated = validateItems(replaces ? batch.replace : batch, commandId, limit);
1344
+ batches.push(validated);
1345
+ // `{ replace }` states the whole set: ids missing from it are gone, which upserting alone
1346
+ // can never say. A bare array adds to what is already shown.
1347
+ if (replaces) {
1348
+ merged = [];
1349
+ index = new Map();
1350
+ }
1351
+ for (const item of validated) {
1352
+ const at = index.get(item.id);
1353
+ if (at === undefined) {
1354
+ index.set(item.id, merged.length);
1355
+ merged.push(item);
1356
+ } else {
1357
+ merged[at] = item;
1358
+ }
1359
+ }
1360
+ if (merged.length > limits.maxItemsPerInvocation) {
1361
+ throw new ExtensionError({
1362
+ code: "invalid-result",
1363
+ message: `more than ${limits.maxItemsPerInvocation} items across batches`,
1364
+ });
1365
+ }
1366
+ }
1367
+ return merged;
1368
+ }
1369
+
1370
+ function commandOf(commandId: string): ManifestCommand {
1371
+ const command = manifest.commands.find((c) => c.id === commandId);
1372
+ if (!command) throw new Error(`command "${commandId}" is not declared in the manifest`);
1373
+ return command;
1374
+ }
1375
+
1376
+ /** A command may narrow the host's deadline, never widen it. */
1377
+ function deadlineOf(command: ManifestCommand, kind: InvocationKind): number {
1378
+ const host = kind === "invoke" ? limits.defaultActionDeadlineMs : limits.defaultDeadlineMs;
1379
+ const asked = command.activation?.deadlineMs;
1380
+ return asked === undefined ? host : Math.min(asked, host);
1381
+ }
1382
+
1383
+ function validateItems(items: CommandItem[], commandId: string, limit: number): CommandItem[] {
1384
+ const encoded = JSON.stringify(items);
1385
+ if (encoded.length > limits.maxResultBytes) {
1386
+ throw new ExtensionError({
1387
+ code: "invalid-result",
1388
+ message: `result exceeds ${limits.maxResultBytes} encoded bytes`,
1389
+ });
1390
+ }
1391
+ const seen = new Set<string>();
1392
+ const facetTitles = new Map<string, string>();
1393
+ const kept: CommandItem[] = [];
1394
+ for (let item of items) {
1395
+ if (!item.id || !item.title) {
1396
+ throw new ExtensionError({
1397
+ code: "invalid-result",
1398
+ message: "every item needs a non-empty id and title",
1399
+ });
1400
+ }
1401
+ if (seen.has(item.id)) {
1402
+ throw new ExtensionError({
1403
+ code: "invalid-result",
1404
+ message: `duplicate item id "${item.id}"`,
1405
+ });
1406
+ }
1407
+ seen.add(item.id);
1408
+ if (item.facet) {
1409
+ const known = facetTitles.get(item.facet.id);
1410
+ if (known !== undefined && known !== item.facet.title) {
1411
+ throw new ExtensionError({
1412
+ code: "invalid-result",
1413
+ message: `facet "${item.facet.id}" has conflicting titles "${known}" and "${item.facet.title}"`,
1414
+ });
1415
+ }
1416
+ facetTitles.set(item.facet.id, item.facet.title);
1417
+ }
1418
+ // Core's decision, not the extension's good manners: an action whose unit was not
1419
+ // granted is not offered. The extension declared the dependency; this honours it.
1420
+ if (item.requires && !permissionUnits(grants).has(item.requires)) {
1421
+ item = { ...item, defaultActionId: undefined };
1422
+ }
1423
+ if (item.icon) validateIcon(item.icon);
1424
+ if (item.scope) validateScope(item.scope);
1425
+ // The host namespaces the guest id before it reaches core state.
1426
+ namespacedItemId(manifest.id, commandId, item.id);
1427
+ itemOrigin.set(item.id, commandId);
1428
+ kept.push(item);
1429
+ }
1430
+ const capped = Math.min(limit, limits.maxItemsPerBatch);
1431
+ if (kept.length > capped) {
1432
+ diagnostics.push({ level: "warn", message: `truncated ${kept.length} items to ${capped}` });
1433
+ return kept.slice(0, capped);
1434
+ }
1435
+ return kept;
1436
+ }
1437
+
1438
+ function validateScope(scope: ItemScope<JsonValue>): void {
1439
+ // A native subject type is Tapcue's to own and anyone's to produce. The host still
1440
+ // checks the subject really *is* one — a row claiming to be a colour and carrying a
1441
+ // string would enter the colour scope and render nothing.
1442
+ if (isNativeScopeType(scope.type)) {
1443
+ if (!isColor(scope.subject)) {
1444
+ throw new ExtensionError({
1445
+ code: "invalid-result",
1446
+ message: `item enters native scope "${scope.type}" with a subject that is not a Color`,
1447
+ });
1448
+ }
1449
+ return;
1450
+ }
1451
+ if (!scopes.has(scope.type)) {
1452
+ throw new ExtensionError({
1453
+ code: "invalid-result",
1454
+ message: `item enters unregistered scope "${scope.type}"`,
1455
+ });
1456
+ }
1457
+ const encoded = JSON.stringify(scope.subject ?? null);
1458
+ if (encoded.length > limits.maxScopeSubjectBytes) {
1459
+ throw new ExtensionError({
1460
+ code: "invalid-result",
1461
+ message: `scope subject exceeds ${limits.maxScopeSubjectBytes} bytes`,
1462
+ });
1463
+ }
1464
+ }
1465
+
1466
+ /** The same grammar the manifest validator applies — one rule, one place. */
1467
+ function validateIcon(icon: string): void {
1468
+ const problem = iconProblem(icon);
1469
+ if (problem) {
1470
+ throw new ExtensionError({
1471
+ code: "invalid-result",
1472
+ message: `icon "${icon}": ${problem}`,
1473
+ });
1474
+ }
1475
+ }
1476
+
1477
+ function validateScene(scene: Scene | null): Scene | null {
1478
+ if (!scene) return scene;
1479
+ const granted = permissionUnits(grants);
1480
+ if (scene.kind === "error" || scene.kind === "detail" || scene.kind === "webview") {
1481
+ for (const action of scene.actions ?? []) {
1482
+ if (action.icon) validateIcon(action.icon);
1483
+ }
1484
+ }
1485
+ if (scene.kind === "webview") {
1486
+ // Presenting a WebView is the one place an extension renders rather than describes, so it
1487
+ // is the one scene that needs a grant to exist at all. A surface without `ui.webview` is
1488
+ // not a degraded scene, it is a scene the extension was never allowed to return.
1489
+ if (!granted.has("ui.webview")) {
1490
+ throw new ExtensionError({
1491
+ code: "permission-denied",
1492
+ message: "a webview scene requires the ui.webview permission",
1493
+ });
1494
+ }
1495
+ const problem = assetPathProblem(scene.entry);
1496
+ if (problem) {
1497
+ throw new ExtensionError({ code: "invalid-result", message: `webview entry ${problem}` });
1498
+ }
1499
+ // Same footer rule as a row's actions: an action Tapcue knows it cannot run is not drawn.
1500
+ return {
1501
+ ...scene,
1502
+ actions: (scene.actions ?? []).filter((a) => !a.requires || granted.has(a.requires)),
1503
+ };
1504
+ }
1505
+ if (scene.kind !== "detail") return scene;
1506
+ if (scene.icon) validateIcon(scene.icon);
1507
+ for (const block of scene.body ?? []) validateBlock(block);
1508
+ // Same rule in the detail pane: an action Tapcue knows will fail is not drawn.
1509
+ return {
1510
+ ...scene,
1511
+ actions: (scene.actions ?? []).filter((a) => !a.requires || granted.has(a.requires)),
1512
+ };
1513
+ }
1514
+
1515
+ /** A WebView `entry`, like an `asset:` icon minus the prefix: package-relative, no traversal. */
1516
+ function assetPathProblem(path: string): string | undefined {
1517
+ if (typeof path !== "string" || path === "") return "must be a non-empty package-relative path";
1518
+ if (path.startsWith("/") || path.split("/").includes("..")) {
1519
+ return "must be package-relative and must not traverse";
1520
+ }
1521
+ return undefined;
1522
+ }
1523
+
1524
+ function validateBlock(block: SceneBlock): void {
1525
+ if (block.kind !== "chart") return;
1526
+ const chart: Chart = block.chart;
1527
+ for (const series of chart.series) {
1528
+ if (series.values.length !== chart.labels.length) {
1529
+ throw new ExtensionError({
1530
+ code: "invalid-result",
1531
+ message: `chart series "${series.id}" has ${series.values.length} values for ${chart.labels.length} labels`,
1532
+ });
1533
+ }
1534
+ }
1535
+ for (const marker of chart.markers ?? []) {
1536
+ if (marker.at < 0 || marker.at >= chart.labels.length) {
1537
+ throw new ExtensionError({
1538
+ code: "invalid-result",
1539
+ message: `chart marker at ${marker.at} is out of range`,
1540
+ });
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ const DEFAULT_BOUNDS: Rect = { x: 0, y: 0, width: 1920, height: 1080 };
1546
+ const BLACK: Color = { red: 0, green: 0, blue: 0, colorSpace: "srgb" };
1547
+
1548
+ /**
1549
+ * A faithful stand-in for the real overlay: the same lifecycle, the same rules. It
1550
+ * hands out the scripted events, records every frame, and dies on cancellation with
1551
+ * the same finality the shell's Esc has.
1552
+ */
1553
+ /** `ScreenOverlayService.Session.cardSize`, less the chrome the host draws around it. */
1554
+ const CARD_BOUNDS: Rect = { x: 0, y: 0, width: 428, height: 86 };
1555
+
1556
+ function openOverlay(spec: OverlaySpec | undefined, signal: AbortSignal): OverlaySession {
1557
+ const coach = spec?.mode === "coach";
1558
+ // A card is told how much room it has and nothing about where that room is, so the fake hands
1559
+ // back the host's card size rather than the desktop — a test that asserts a card fits its
1560
+ // contents must fail here for the same reason it would fail on screen.
1561
+ const bounds = coach ? CARD_BOUNDS : (options.screen?.bounds ?? DEFAULT_BOUNDS);
1562
+ const colorAt = options.screen?.colorAt ?? (() => BLACK);
1563
+ const recorded: RecordedOverlay = { spec, frames: [], captures: [], closed: false };
1564
+ overlays.push(recorded);
1565
+ let open = true;
1566
+ /** Resolves when the card goes away, so a coach card's stream ends when it really ends. */
1567
+ let closed: () => void = () => {};
1568
+ const untilClosed = new Promise<void>((resolve) => {
1569
+ closed = resolve;
1570
+ });
1571
+
1572
+ const die = (): never => {
1573
+ open = false;
1574
+ recorded.closed = true;
1575
+ throw new ExtensionError({
1576
+ code: "invalid-request",
1577
+ message: "the overlay is closed",
1578
+ });
1579
+ };
1580
+ const alive = () => {
1581
+ if (!open || signal.aborted) die();
1582
+ };
1583
+
1584
+ function layerFor(nodes: SvgNode[]): Layer {
1585
+ const index = recorded.frames.push(nodes) - 1;
1586
+ return {
1587
+ move(to: Point) {
1588
+ alive();
1589
+ // `move` re-transforms what is already drawn rather than re-serializing it —
1590
+ // the reason a ruler that follows the cursor costs nothing.
1591
+ recorded.frames.push(
1592
+ recorded.frames[index].map((node) =>
1593
+ node.tag === "g"
1594
+ ? { ...node, transform: `translate(${to.x} ${to.y})` }
1595
+ : node,
1596
+ ),
1597
+ );
1598
+ },
1599
+ update(next: SvgNode[]) {
1600
+ alive();
1601
+ recorded.frames.push(next);
1602
+ },
1603
+ remove() {
1604
+ alive();
1605
+ recorded.frames.push([]);
1606
+ },
1607
+ };
1608
+ }
1609
+
1610
+ return {
1611
+ bounds,
1612
+ draw(nodes) {
1613
+ alive();
1614
+ return layerFor(nodes);
1615
+ },
1616
+ capture: grants.screen?.capture
1617
+ ? async (rect: Rect) => {
1618
+ alive();
1619
+ if (!grants.screen?.capture) {
1620
+ throw new ExtensionError({
1621
+ code: "permission-denied",
1622
+ message: "screen.capture is not granted",
1623
+ });
1624
+ }
1625
+ recorded.captures.push(rect);
1626
+ return makeImage(rect, colorAt);
1627
+ }
1628
+ : undefined,
1629
+ async *events() {
1630
+ if (coach) {
1631
+ // A coach card receives no pointer events at all — whatever the fake desktop was
1632
+ // scripted to do, the user was doing it in *another app*, which is the whole point.
1633
+ //
1634
+ // And its stream ends when the card does, not one tick after it opens. An extension
1635
+ // watching for the user to give up races that stream against its own work, so a fake
1636
+ // that cancels immediately would make every such race a foregone conclusion.
1637
+ if (!options.screen?.dismissCoach) await untilClosed;
1638
+ yield { kind: "cancelled" } as OverlayEvent;
1639
+ return;
1640
+ }
1641
+ for (const event of options.screen?.events ?? []) {
1642
+ if (!open || signal.aborted) {
1643
+ yield { kind: "cancelled" } as OverlayEvent;
1644
+ return;
1645
+ }
1646
+ yield event;
1647
+ }
1648
+ // The user walked away. An overlay must survive it, so the fake always says so
1649
+ // rather than leaving the stream hanging.
1650
+ yield { kind: "cancelled" } as OverlayEvent;
1651
+ },
1652
+ close() {
1653
+ open = false;
1654
+ recorded.closed = true;
1655
+ closed();
1656
+ },
1657
+ };
1658
+ }
1659
+
1660
+ /**
1661
+ * The subject shape check the shell performs before a contribution is ever called.
1662
+ *
1663
+ * `NativeSubjects` is the shape both directions travel: an extension producing an item that
1664
+ * *enters* `application` is validated against it, and a contribution receiving one is handed
1665
+ * exactly it. A test that hands over `{ bundle: "..." }` would otherwise be testing an
1666
+ * extension against a subject Tapcue never produces.
1667
+ */
1668
+ function validateNativeSubject(
1669
+ type: ContributableScopeType,
1670
+ subject: Record<string, JsonValue>,
1671
+ ): NativeSubjects[ContributableScopeType] & Record<string, JsonValue> {
1672
+ const fields = NATIVE_SUBJECT_FIELDS[type];
1673
+ // Unknown fields first: the common mistake is a misspelling, and reporting `bundleId` as
1674
+ // *missing* when the fixture plainly wrote `bundle` names the wrong half of the problem.
1675
+ for (const field of Object.keys(subject)) {
1676
+ if (!fields.includes(field)) {
1677
+ throw new Error(`a "${type}" subject has no "${field}"; it carries ${fields.join(", ")}`);
1678
+ }
1679
+ }
1680
+ for (const field of fields) {
1681
+ if (!(field in subject)) {
1682
+ throw new Error(`a "${type}" subject needs ${fields.join(", ")}; "${field}" is missing`);
1683
+ }
1684
+ }
1685
+ // The loops above are the check the cast rests on: every declared field present, nothing
1686
+ // else. A fixture that failed either one threw before reaching here.
1687
+ return subject as NativeSubjects[ContributableScopeType] & Record<string, JsonValue>;
1688
+ }
1689
+
1690
+ /**
1691
+ * `when` — every named field must match one of its values, compared case-insensitively as whole
1692
+ * strings. Several fields is an AND, several values in one field is an OR.
1693
+ *
1694
+ * An unknown field cannot get here: the manifest validator rejects it. That order matters —
1695
+ * treated as "no opinion" instead, an unknown field would make the predicate match *everything*,
1696
+ * which is the failure mode this whole mechanism has to be built to refuse.
1697
+ */
1698
+ function whenAdmits(entry: ManifestContribution, subject: Record<string, JsonValue>): boolean {
1699
+ const matchable = CONTRIBUTION_MATCH_FIELDS[entry.scope];
1700
+ for (const [field, values] of Object.entries(entry.when)) {
1701
+ if (!matchable.includes(field)) return false;
1702
+ const actual = subject[field];
1703
+ if (typeof actual !== "string") return false;
1704
+ const lowered = actual.toLowerCase();
1705
+ if (!values.some((value) => value.toLowerCase() === lowered)) return false;
1706
+ }
1707
+ return true;
1708
+ }
1709
+
1710
+ let invocationCounter = 0;
1711
+ const nextInvocationId = () => `inv-${(invocationCounter += 1)}`;
1712
+
1713
+ function runAction(
1714
+ commandId: string,
1715
+ actionId: string,
1716
+ itemId: string | undefined,
1717
+ ): Promise<ActionResult> {
1718
+ const command = commandOf(commandId);
1719
+ const handlers = options.extension.commands[commandId];
1720
+ if (!handlers?.invoke) throw new Error(`command "${commandId}" has no invoke handler`);
1721
+ startInvocation();
1722
+ const startedAt = generation;
1723
+ const invocationId = nextInvocationId();
1724
+ return run(deadlineOf(command, "invoke"), "invoke", (ctx) =>
1725
+ handlers.invoke!(
1726
+ {
1727
+ invocationId,
1728
+ sessionId: "session-1",
1729
+ generation: startedAt,
1730
+ commandId,
1731
+ actionId,
1732
+ itemId,
1733
+ },
1734
+ ctx,
1735
+ ),
1736
+ );
1737
+ }
1738
+
1739
+ const host: TestHost = {
1740
+ async query(commandId, queryOptions) {
1741
+ const command = commandOf(commandId);
1742
+ const handlers = options.extension.commands[commandId];
1743
+ if (!handlers?.query) throw new Error(`command "${commandId}" has no query handler`);
1744
+ startInvocation();
1745
+ const startedAt = generation;
1746
+ const limit = Math.min(
1747
+ queryOptions?.limit ?? command.activation?.maxResults ?? 50,
1748
+ limits.maxItemsPerInvocation,
1749
+ );
1750
+ const invocationId = nextInvocationId();
1751
+ return run(deadlineOf(command, "query"), "query", async (ctx) => {
1752
+ const result = await handlers.query!(
1753
+ {
1754
+ invocationId,
1755
+ sessionId: "session-1",
1756
+ generation: startedAt,
1757
+ commandId,
1758
+ query: queryOptions?.query ?? "",
1759
+ arguments: Object.freeze({ ...(queryOptions?.arguments ?? {}) }),
1760
+ limit,
1761
+ },
1762
+ ctx,
1763
+ );
1764
+ return collect(result, commandId, limit, startedAt);
1765
+ });
1766
+ },
1767
+
1768
+ invoke(commandId, itemId, actionId) {
1769
+ return runAction(commandId, actionId, itemId);
1770
+ },
1771
+
1772
+ /**
1773
+ * A `no-view` command's row carries no item, so there is no
1774
+ * `CommandItem.defaultActionId` to name the action: core routes Enter on the
1775
+ * manifest row to the reserved `default` id.
1776
+ */
1777
+ run(commandId) {
1778
+ const command = commandOf(commandId);
1779
+ if (command.mode !== "no-view") {
1780
+ throw new Error(
1781
+ `"${commandId}" is a view command: run its query and invoke an item, not the command`,
1782
+ );
1783
+ }
1784
+ return runAction(commandId, DEFAULT_ACTION_ID, undefined);
1785
+ },
1786
+
1787
+ enterNative(entry) {
1788
+ if (!(CONTRIBUTABLE_SCOPE_TYPES as readonly string[]).includes(entry.type)) {
1789
+ throw new Error(
1790
+ `"${entry.type}" is not a contributable native scope — one of ${CONTRIBUTABLE_SCOPE_TYPES.join(", ")}`,
1791
+ );
1792
+ }
1793
+ const subject = validateNativeSubject(entry.type, entry.subject);
1794
+ const queryMode = entry.queryMode ?? "remote";
1795
+ // The native scope-id forms the shell uses. Only ever a cache key to the extension, but a
1796
+ // fake that invented a shape would let a test depend on one Tapcue does not produce.
1797
+ const scopeId =
1798
+ entry.scopeId ??
1799
+ (entry.type === "application"
1800
+ ? `app:${String(subject.bundleId)}`
1801
+ : `file:${String(subject.path)}`);
1802
+ // Leaving a scope tears down whatever surface the last one had up — same rule as `enter`.
1803
+ liveSurface = null;
1804
+
1805
+ const admitted = [...contributions.values()]
1806
+ .filter((c) => c.manifest.scope === entry.type && whenAdmits(c.manifest, subject))
1807
+ .map((c) => c.manifest.id);
1808
+
1809
+ const resolve = (contributionId: string) => {
1810
+ const found = contributions.get(contributionId);
1811
+ if (!found) {
1812
+ throw new Error(`"${contributionId}" is not a declared contribution`);
1813
+ }
1814
+ if (found.manifest.scope !== entry.type) {
1815
+ throw new Error(
1816
+ `contribution "${contributionId}" targets "${found.manifest.scope}", not "${entry.type}"`,
1817
+ );
1818
+ }
1819
+ if (!admitted.includes(contributionId)) {
1820
+ // The gate, stated as the shell states it: not "returned nothing", but "was never
1821
+ // asked". The extension does not learn the subject exists.
1822
+ throw new Error(
1823
+ `contribution "${contributionId}" does not apply to this subject — its when predicate did not match, so Tapcue would never call it`,
1824
+ );
1825
+ }
1826
+ return found;
1827
+ };
1828
+
1829
+ return {
1830
+ type: entry.type,
1831
+ scopeId,
1832
+ contributions: admitted,
1833
+ async items(contributionId, sessionOptions) {
1834
+ const { manifest: declared, handlers } = resolve(contributionId);
1835
+ startInvocation();
1836
+ const startedAt = generation;
1837
+ const limit = Math.min(
1838
+ sessionOptions?.limit ?? limits.maxItemsPerInvocation,
1839
+ limits.maxItemsPerInvocation,
1840
+ );
1841
+ const invocationId = nextInvocationId();
1842
+ // **A catalog scope hands its contributors an empty query**, exactly as
1843
+ // `ScopeController` does (`pullQuery = isCatalog ? "" : query`) — so an extension that
1844
+ // only filters when it is given something to filter on shows everything there, and a
1845
+ // test can see it happen instead of discovering it in the launcher.
1846
+ const query = queryMode === "catalog" ? "" : (sessionOptions?.query ?? "");
1847
+ return run(limits.defaultDeadlineMs, "query", async (ctx) => {
1848
+ const result = await handlers.items(
1849
+ {
1850
+ invocationId,
1851
+ sessionId: "session-1",
1852
+ generation: startedAt,
1853
+ contributionId: declared.id,
1854
+ scopeType: entry.type,
1855
+ scopeId,
1856
+ subject,
1857
+ queryMode,
1858
+ query,
1859
+ limit,
1860
+ },
1861
+ ctx,
1862
+ );
1863
+ return collect(result, declared.id, limit, startedAt);
1864
+ });
1865
+ },
1866
+ async invoke(contributionId, itemId, actionId) {
1867
+ const { manifest: declared, handlers } = resolve(contributionId);
1868
+ if (!handlers.invoke) {
1869
+ throw new Error(`contribution "${contributionId}" has no invoke handler`);
1870
+ }
1871
+ startInvocation();
1872
+ const startedAt = generation;
1873
+ const invocationId = nextInvocationId();
1874
+ return run(limits.defaultActionDeadlineMs, "invoke", (ctx) =>
1875
+ handlers.invoke!(
1876
+ {
1877
+ invocationId,
1878
+ sessionId: "session-1",
1879
+ generation: startedAt,
1880
+ contributionId: declared.id,
1881
+ scopeType: entry.type,
1882
+ scopeId,
1883
+ subject,
1884
+ itemId,
1885
+ actionId,
1886
+ },
1887
+ ctx,
1888
+ ),
1889
+ );
1890
+ },
1891
+ };
1892
+ },
1893
+
1894
+ enter(item) {
1895
+ if (!item.scope) throw new Error(`item "${item.id}" carries no scope to enter`);
1896
+ if (isNativeScopeType(item.scope.type)) {
1897
+ // Deliberately not simulatable. Tapcue renders a native scope — its rows, its
1898
+ // formats, its actions are core's, and an extension test that asserted them
1899
+ // would be asserting code the extension does not own. Assert the subject you
1900
+ // handed over; what Tapcue does with a colour is Tapcue's test to write.
1901
+ throw new Error(
1902
+ `"${item.scope.type}" is a native scope: Tapcue renders it, not your extension. ` +
1903
+ `Assert item.scope.subject instead.`,
1904
+ );
1905
+ }
1906
+ const scope = scopes.get(item.scope.type);
1907
+ if (!scope) throw new Error(`no registered scope "${item.scope.type}"`);
1908
+ // The adapter keeps the subject on the native Scope's payload and hands it
1909
+ // back on every call into the scope — the isolate may have been evicted in
1910
+ // between, so guest memory is never where this lives.
1911
+ const subject = (item.scope.subject ?? null) as JsonValue;
1912
+ const scopeId = `extension:${manifest.id}:${item.scope.type}:${item.id}`;
1913
+ // The command whose query produced this item — not `commands[0]`. An action
1914
+ // inside the scope is still routed to (extension, command, item, action), and
1915
+ // an extension with two commands would otherwise be told the wrong one.
1916
+ const commandId = itemOrigin.get(item.id) ?? manifest.commands[0].id;
1917
+ // Entering a scope tears down whatever surface the previous one had up. The isolate does
1918
+ // not own the surface, the shell does, and leaving one scope ends it — so the bridge from
1919
+ // a stale surface is gone before the next scope's actions can reach for it.
1920
+ liveSurface = null;
1921
+
1922
+ return {
1923
+ type: scope.type,
1924
+ scopeId,
1925
+ async items(sessionOptions) {
1926
+ if (!scope.spec.items) {
1927
+ throw new Error(`rowless scope "${scope.type}" has no rows — call surface()/view(), not items()`);
1928
+ }
1929
+ startInvocation();
1930
+ const startedAt = generation;
1931
+ const limit = Math.min(
1932
+ sessionOptions?.limit ?? limits.maxItemsPerInvocation,
1933
+ limits.maxItemsPerInvocation,
1934
+ );
1935
+ const invocationId = nextInvocationId();
1936
+ return run(limits.defaultDeadlineMs, "query", async (ctx) => {
1937
+ const result = await scope.spec.items!(
1938
+ {
1939
+ invocationId,
1940
+ sessionId: "session-1",
1941
+ generation: startedAt,
1942
+ scopeType: scope.type,
1943
+ scopeId,
1944
+ subject,
1945
+ query: sessionOptions?.query ?? "",
1946
+ limit,
1947
+ },
1948
+ ctx,
1949
+ );
1950
+ return collect(result, commandId, limit, startedAt);
1951
+ });
1952
+ },
1953
+ async surface() {
1954
+ if (!scope.spec.surface) {
1955
+ throw new Error(`scope "${scope.type}" is not a canvas scope — it has no surface()`);
1956
+ }
1957
+ startInvocation();
1958
+ const startedAt = generation;
1959
+ const invocationId = nextInvocationId();
1960
+ const scene = await run(limits.defaultDeadlineMs, "detail", (ctx) =>
1961
+ scope.spec.surface!(
1962
+ {
1963
+ invocationId,
1964
+ sessionId: "session-1",
1965
+ generation: startedAt,
1966
+ scopeType: scope.type,
1967
+ scopeId,
1968
+ subject,
1969
+ },
1970
+ ctx,
1971
+ ),
1972
+ );
1973
+ const validated = validateScene(scene);
1974
+ // The shell now owns a live surface; the isolate's only handle to it is `ctx.bridge`.
1975
+ if (validated?.kind === "webview") {
1976
+ surfaces.push({
1977
+ entry: validated.entry,
1978
+ title: validated.title,
1979
+ actions: validated.actions ?? [],
1980
+ });
1981
+ liveSurface = { respond: options.surface?.respond ?? ((m) => m) };
1982
+ }
1983
+ return validated as Scene;
1984
+ },
1985
+ async act(actionId) {
1986
+ if (!scope.spec.invoke) throw new Error(`scope "${scope.type}" has no invoke handler`);
1987
+ startInvocation();
1988
+ const startedAt = generation;
1989
+ const invocationId = nextInvocationId();
1990
+ return run(limits.defaultActionDeadlineMs, "invoke", (ctx) =>
1991
+ scope.spec.invoke!(
1992
+ {
1993
+ invocationId,
1994
+ sessionId: "session-1",
1995
+ generation: startedAt,
1996
+ commandId,
1997
+ actionId,
1998
+ scopeType: scope.type,
1999
+ subject,
2000
+ },
2001
+ ctx,
2002
+ ),
2003
+ );
2004
+ },
2005
+ async view(itemId) {
2006
+ if (!scope.spec.view) throw new Error(`scope "${scope.type}" has no view handler`);
2007
+ startInvocation();
2008
+ const startedAt = generation;
2009
+ const invocationId = nextInvocationId();
2010
+ return run(limits.defaultDeadlineMs, "detail", (ctx) =>
2011
+ renderView(
2012
+ scope.spec.view!,
2013
+ {
2014
+ invocationId,
2015
+ sessionId: "session-1",
2016
+ generation: startedAt,
2017
+ scopeType: scope.type,
2018
+ scopeId,
2019
+ subject,
2020
+ itemId,
2021
+ },
2022
+ ctx,
2023
+ ),
2024
+ );
2025
+ },
2026
+ async liveView(itemId): Promise<LiveViewSession> {
2027
+ if (!scope.spec.view) throw new Error(`scope "${scope.type}" has no view handler`);
2028
+ startInvocation();
2029
+ const startedAt = generation;
2030
+ const invocationId = nextInvocationId();
2031
+ const abort = new AbortController();
2032
+ const patches: PatchOp[][] = [];
2033
+ let initial: ViewResult | undefined;
2034
+ let markReady: () => void = () => {};
2035
+ const ready = new Promise<void>((resolve) => (markReady = resolve));
2036
+
2037
+ currentKind = "detail";
2038
+ const ctx = makeContext(limits.defaultDeadlineMs, "detail");
2039
+ const finished = driveView(
2040
+ scope.spec.view,
2041
+ {
2042
+ invocationId,
2043
+ sessionId: "session-1",
2044
+ generation: startedAt,
2045
+ scopeType: scope.type,
2046
+ scopeId,
2047
+ subject,
2048
+ itemId,
2049
+ },
2050
+ ctx,
2051
+ (result) => {
2052
+ initial = result;
2053
+ },
2054
+ { signal: abort.signal, onPatch: (ops) => patches.push(ops), onReady: () => markReady() },
2055
+ );
2056
+
2057
+ // `driveView` emits before it signals ready on both of its paths, so past this point
2058
+ // the initial frame is always in hand.
2059
+ await ready;
2060
+ return {
2061
+ initial: initial as ViewResult,
2062
+ patches,
2063
+ async stop() {
2064
+ abort.abort();
2065
+ await finished;
2066
+ },
2067
+ };
2068
+ },
2069
+ async viewAction(itemId, actionId, args, model) {
2070
+ if (!scope.spec.view) throw new Error(`scope "${scope.type}" has no view handler`);
2071
+ startInvocation();
2072
+ const startedAt = generation;
2073
+ const invocationId = nextInvocationId();
2074
+ return run(limits.defaultActionDeadlineMs, "invoke", (ctx) =>
2075
+ applyAction(
2076
+ scope.spec.view!,
2077
+ {
2078
+ invocationId,
2079
+ sessionId: "session-1",
2080
+ generation: startedAt,
2081
+ scopeType: scope.type,
2082
+ scopeId,
2083
+ subject,
2084
+ itemId,
2085
+ },
2086
+ ctx,
2087
+ actionId,
2088
+ args,
2089
+ model,
2090
+ ),
2091
+ );
2092
+ },
2093
+ async detail(itemId) {
2094
+ if (!scope.spec.detail) throw new Error(`scope "${scope.type}" has no detail handler`);
2095
+ startInvocation();
2096
+ const startedAt = generation;
2097
+ const invocationId = nextInvocationId();
2098
+ const scene = await run(limits.defaultDeadlineMs, "detail", (ctx) =>
2099
+ scope.spec.detail!(
2100
+ {
2101
+ invocationId,
2102
+ sessionId: "session-1",
2103
+ generation: startedAt,
2104
+ scopeType: scope.type,
2105
+ scopeId,
2106
+ subject,
2107
+ itemId,
2108
+ },
2109
+ ctx,
2110
+ ),
2111
+ );
2112
+ return validateScene(scene);
2113
+ },
2114
+ async invoke(itemId, actionId) {
2115
+ if (!scope.spec.invoke) throw new Error(`scope "${scope.type}" has no invoke handler`);
2116
+ startInvocation();
2117
+ const startedAt = generation;
2118
+ const invocationId = nextInvocationId();
2119
+ return run(limits.defaultActionDeadlineMs, "invoke", (ctx) =>
2120
+ scope.spec.invoke!(
2121
+ {
2122
+ invocationId,
2123
+ sessionId: "session-1",
2124
+ generation: startedAt,
2125
+ commandId,
2126
+ actionId,
2127
+ itemId,
2128
+ scopeType: scope.type,
2129
+ subject,
2130
+ },
2131
+ ctx,
2132
+ ),
2133
+ );
2134
+ },
2135
+ };
2136
+ },
2137
+
2138
+ cancel() {
2139
+ generation += 1;
2140
+ controller.abort();
2141
+ },
2142
+
2143
+ revoke(unit) {
2144
+ if (permissionUnits(manifest.permissions).has(unit)) {
2145
+ throw new Error(
2146
+ `"${unit}" is a required permission: revoking it disables the extension, it does not degrade it`,
2147
+ );
2148
+ }
2149
+ const spec = PERMISSION_UNITS.find((candidate) => candidate.unit === unit);
2150
+ if (!spec) throw new Error(`unknown permission unit "${unit}"`);
2151
+
2152
+ // Walked off the unit table rather than switched over by hand, which is how this last
2153
+ // managed to omit `native.save`, `ui.webview` and `background` — three units a manifest can
2154
+ // declare optional and no test could then take away.
2155
+ const remaining = { ...(manifest.optionalPermissions ?? {}) } as Record<string, any>;
2156
+ const listValued = spec.kind === "paths" || spec.kind === "binaries";
2157
+ if (spec.key !== null && !listValued) {
2158
+ remaining[spec.group] = { ...remaining[spec.group], [spec.key]: false };
2159
+ } else if (spec.kind === "boolean") {
2160
+ remaining[spec.group] = false;
2161
+ } else {
2162
+ // The value-carrying units (hosts, quotas, secret names, precision, and the two lists
2163
+ // spec 016 added) are revoked by removing the block: there is no "false" to write into a
2164
+ // list, and writing one would leave `files: { read: false }` for the resolver to trip on.
2165
+ delete remaining[spec.group];
2166
+ }
2167
+ grants = effectiveGrants(manifest, remaining as ManifestPermissions);
2168
+ },
2169
+
2170
+ advanceTime(ms) {
2171
+ nowMs += ms;
2172
+ },
2173
+
2174
+ setProcesses(next) {
2175
+ processesFixture = next;
2176
+ },
2177
+
2178
+ get batches() {
2179
+ return batches;
2180
+ },
2181
+ requests,
2182
+ diagnostics,
2183
+ clipboard,
2184
+ clipboardSecrets,
2185
+ clipboardImages,
2186
+ opened,
2187
+ ran,
2188
+ queried,
2189
+ async workspaceRoots(ask = {}) {
2190
+ // The gate first, and it is the same gate the host applies: a slot nobody granted is a slot
2191
+ // nobody is asked about, so the handler must not run.
2192
+ if (grants.workspace?.roots !== true) return [];
2193
+ const limit = ask.limit ?? limits.maxItemsPerBatch;
2194
+ const chosen = ask.provisionId
2195
+ ? [provisions.get(ask.provisionId)].filter(Boolean)
2196
+ : [...provisions.values()].filter((entry) => entry!.manifest.slot === "workspace.roots");
2197
+ if (ask.provisionId && chosen.length === 0) {
2198
+ throw new Error(`"${ask.provisionId}" is not a declared provision`);
2199
+ }
2200
+ const out: WorkspaceRoot[] = [];
2201
+ for (const entry of chosen) {
2202
+ const answered = await entry!.handlers.roots!(
2203
+ { provisionId: entry!.manifest.id, slot: entry!.manifest.slot, limit },
2204
+ makeContext(limits.defaultDeadlineMs, "query"),
2205
+ );
2206
+ // Absolute paths only. The real host also checks each one exists, is a directory and sits
2207
+ // under the user's home — facts a deterministic host has no filesystem to know — but the
2208
+ // shape rule is pure and belongs on both sides.
2209
+ out.push(...answered.filter((root) => typeof root.path === "string" && root.path.startsWith("/")));
2210
+ }
2211
+ return out.slice(0, limit);
2212
+ },
2213
+ notifications,
2214
+ saves,
2215
+ surfaces,
2216
+ bridgeMessages,
2217
+ overlays,
2218
+ kills,
2219
+ activated,
2220
+ navigations,
2221
+ settingsWrites,
2222
+ get generation() {
2223
+ return generation;
2224
+ },
2225
+ };
2226
+
2227
+ return host;
2228
+ }
2229
+
2230
+ /** Drop `command` (argv) from a `ProcessInfo` — the sensitive read the host withholds without
2231
+ * the `process.command` grant, exactly as the real host does. */
2232
+ function stripCommand(process: ProcessInfo): ProcessInfo {
2233
+ const { command: _command, ...rest } = process;
2234
+ return rest;
2235
+ }
2236
+
2237
+ export { redact };
2238
+ export type { HttpRoute, RecordedRequest };
2239
+
2240
+ function isNativeScopeType(type: string): boolean {
2241
+ return (NATIVE_SCOPE_TYPES as readonly string[]).includes(type);
2242
+ }
2243
+
2244
+ /** A row claiming to be a colour and carrying a string would enter the colour scope and
2245
+ * render nothing. The host checks the subject really is what the type says it is. */
2246
+ function isColor(value: unknown): value is Color {
2247
+ if (typeof value !== "object" || value === null) return false;
2248
+ const color = value as Record<string, unknown>;
2249
+ return (
2250
+ typeof color.red === "number" &&
2251
+ typeof color.green === "number" &&
2252
+ typeof color.blue === "number" &&
2253
+ (color.colorSpace === "srgb" || color.colorSpace === "display-p3")
2254
+ );
2255
+ }
2256
+
2257
+ /** Every frame an extension drew on an overlay, for a test to look at. */
2258
+ export interface RecordedOverlay {
2259
+ spec: OverlaySpec | undefined;
2260
+ frames: SvgNode[][];
2261
+ captures: Rect[];
2262
+ closed: boolean;
2263
+ }
2264
+
2265
+ /**
2266
+ * A capture. The bytes are synthesized from the fake desktop, so a test can assert on
2267
+ * the colour an extension read without ever owning a bitmap.
2268
+ */
2269
+ function makeImage(rect: Rect, colorAt: (point: Point) => Color): ImageHandle {
2270
+ return {
2271
+ width: rect.width,
2272
+ height: rect.height,
2273
+ __brand: "tapcue.image",
2274
+ async colorAt(point: Point) {
2275
+ // Image coordinates, not screen coordinates — the same trap the real one has.
2276
+ return colorAt({ x: rect.x + point.x, y: rect.y + point.y });
2277
+ },
2278
+ async bytes() {
2279
+ const out = new Uint8Array(rect.width * rect.height * 4);
2280
+ for (let y = 0; y < rect.height; y += 1) {
2281
+ for (let x = 0; x < rect.width; x += 1) {
2282
+ const color = colorAt({ x: rect.x + x, y: rect.y + y });
2283
+ const at = (y * rect.width + x) * 4;
2284
+ out[at] = Math.round(color.red * 255);
2285
+ out[at + 1] = Math.round(color.green * 255);
2286
+ out[at + 2] = Math.round(color.blue * 255);
2287
+ out[at + 3] = 255;
2288
+ }
2289
+ }
2290
+ return out;
2291
+ },
2292
+ };
2293
+ }
2294
+
2295
+ /**
2296
+ * The colour formats Tapcue writes. This is the *host's* job, and the fake does it the
2297
+ * host's way — an extension that formatted its own would be overriding a preference that
2298
+ * was never its to make.
2299
+ */
2300
+ function formatColor(color: Color, format: "hex" | "rgb" | "hsl"): string {
2301
+ const { red, green, blue } = toSrgb8(color);
2302
+ switch (format) {
2303
+ case "rgb":
2304
+ return `rgb(${red}, ${green}, ${blue})`;
2305
+ case "hsl": {
2306
+ const [r, g, b] = [red / 255, green / 255, blue / 255];
2307
+ const max = Math.max(r, g, b);
2308
+ const min = Math.min(r, g, b);
2309
+ const span = max - min;
2310
+ const lightness = (max + min) / 2;
2311
+ if (span === 0) return `hsl(0, 0%, ${Math.round(lightness * 100)}%)`;
2312
+ const saturation = span / (1 - Math.abs(2 * lightness - 1));
2313
+ const sector =
2314
+ max === r ? ((g - b) / span) % 6 : max === g ? (b - r) / span + 2 : (r - g) / span + 4;
2315
+ const hue = ((sector * 60) % 360 + 360) % 360;
2316
+ return `hsl(${Math.round(hue)}, ${Math.round(saturation * 100)}%, ${Math.round(lightness * 100)}%)`;
2317
+ }
2318
+ default:
2319
+ return colorHex(color).toUpperCase();
2320
+ }
2321
+ }
2322
+
2323
+ function toSrgb8(color: Color): { red: number; green: number; blue: number } {
2324
+ const hex = colorHex(color);
2325
+ return {
2326
+ red: Number.parseInt(hex.slice(1, 3), 16),
2327
+ green: Number.parseInt(hex.slice(3, 5), 16),
2328
+ blue: Number.parseInt(hex.slice(5, 7), 16),
2329
+ };
2330
+ }