@cyberart-io/engine 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -12
- package/dist/headless.d.ts +602 -4
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +835 -7
- package/dist/index.js +1 -1
- package/docs/asset-resolver.md +4 -4
- package/docs/browser-harness.md +126 -0
- package/docs/capability-manifest.md +95 -0
- package/docs/compositor.md +103 -0
- package/docs/events.md +27 -22
- package/docs/headless-harness.md +46 -16
- package/docs/normalized-geometry.md +88 -0
- package/docs/presentation-adapter.md +10 -10
- package/docs/presentation-cue.md +1 -1
- package/docs/replay-inspector.md +88 -0
- package/docs/runtime-group.md +122 -0
- package/package.json +2 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Browser / DOM host harness
|
|
2
|
+
|
|
3
|
+
Opt-in harness that mounts a caller-supplied host into a real viewport, optionally with a runtime group and compositor. It sets viewport / DPR / reduced-motion / input modality, dispatches pointer and keyboard through DOM coordinates, steps a deterministic clock, and captures composed frames plus accessibility HTML.
|
|
4
|
+
|
|
5
|
+
Import `createBrowserHarness` from **`@cyberart-io/engine`**. It does not load `node:fs`. In Vitest/jsdom, call `installHeadlessCanvas()` from **`@cyberart-io/engine/headless`** first so `Canvas2D` pixels exist. Production Player / kaleidoscope should not call this harness.
|
|
6
|
+
|
|
7
|
+
Related: [normalized geometry](normalized-geometry.md), [presentation adapter](presentation-adapter.md), [compositor](compositor.md), [headless harness](headless-harness.md).
|
|
8
|
+
|
|
9
|
+
Back to the [package README](../README.md).
|
|
10
|
+
|
|
11
|
+
## One-command reproduce (this repo)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-63-browser-harness.repro.spec.ts
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Why this exists
|
|
18
|
+
|
|
19
|
+
`createHeadlessHarness` injects canvas-pixel clicks. It cannot prove a host's DOM overlay, CSS geometry, resize/DPR anchors, focus, or scroll. This harness lets a host mount overlay controls, run its own reducer, route through carts, and compose with `createCompositor` / `captureComposedFrame`. jsdom software Canvas2D is the headless composition path; a Playwright-compatible adapter maps the same scenario API for a later Chromium swap.
|
|
20
|
+
|
|
21
|
+
Hosts drive the same API with their own overlay markup and event types; those names stay in the host, not in this module.
|
|
22
|
+
|
|
23
|
+
## `createBrowserHarness(options)`
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createBrowserHarness } from '@cyberart-io/engine';
|
|
27
|
+
import { installHeadlessCanvas } from '@cyberart-io/engine/headless';
|
|
28
|
+
|
|
29
|
+
installHeadlessCanvas();
|
|
30
|
+
|
|
31
|
+
const harness = createBrowserHarness({
|
|
32
|
+
seed: `0x${'00'.repeat(32)}`,
|
|
33
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
|
|
34
|
+
geometry,
|
|
35
|
+
contentWidth: 1920,
|
|
36
|
+
contentHeight: 1080,
|
|
37
|
+
mount(ctx) {
|
|
38
|
+
const hotspot = document.createElement('button');
|
|
39
|
+
hotspot.id = 'hotspot';
|
|
40
|
+
ctx.placeRegion('hotspot', hotspot);
|
|
41
|
+
ctx.root.append(hotspot);
|
|
42
|
+
return {
|
|
43
|
+
participants: [overlayA, overlayB],
|
|
44
|
+
compositor: { layers: [{ id: 'overlay-a', order: 0 }, { id: 'overlay-b', order: 1 }] },
|
|
45
|
+
ready(runtime) {
|
|
46
|
+
hotspot.addEventListener('click', () => {
|
|
47
|
+
runtime.publish({ type: 'host.intent.select', kind: 'intent', payload: { id: 'hotspot' } });
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
relayout() {
|
|
51
|
+
ctx.placeRegion('hotspot', hotspot);
|
|
52
|
+
},
|
|
53
|
+
destroy() {
|
|
54
|
+
hotspot.remove();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
harness.goto();
|
|
61
|
+
harness.click('#hotspot');
|
|
62
|
+
await harness.step(1);
|
|
63
|
+
const shot = harness.screenshot();
|
|
64
|
+
const a11y = harness.accessibilitySnapshot();
|
|
65
|
+
const meta = harness.reproduction();
|
|
66
|
+
harness.destroy();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The host owns overlay markup, reducer state, and event type names. The harness owns viewport metrics, DOM input dispatch, the optional group/compositor lifecycle, and cleanup.
|
|
70
|
+
|
|
71
|
+
### Options
|
|
72
|
+
|
|
73
|
+
| Option | Default | Meaning |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `seed` | `0x00…` | Default participant seed when a cart omits one. |
|
|
76
|
+
| `viewport.width` / `height` / `deviceScaleFactor` | `320` / `180` / `1` | CSS viewport and DPR. |
|
|
77
|
+
| `reducedMotion` | `false` | Stubs `matchMedia('(prefers-reduced-motion: reduce)')`. |
|
|
78
|
+
| `inputModality` | `'pointer'` | Recorded on the root (`data-input-modality`). |
|
|
79
|
+
| `geometry` | none | Optional geometry document for `placeRegion` / CSS hit-testing. |
|
|
80
|
+
| `contentWidth` / `contentHeight` / `fit` | viewport / `contain` | Intrinsic box for `createPresentationLayout`. |
|
|
81
|
+
| `mount` | none | Host builds DOM and returns carts / compositor / cleanup. |
|
|
82
|
+
|
|
83
|
+
### Handle (Playwright-shaped names)
|
|
84
|
+
|
|
85
|
+
| Member | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `goto()` | No-op ready check (fixture is already mounted). |
|
|
88
|
+
| `setViewport(width, height, dpr?)` | Resize root, group canvases, compositor; call host `relayout`. |
|
|
89
|
+
| `click(selector)` / `click(x, y)` | Dispatch `pointerdown` / `pointerup` / `click` on DOM. |
|
|
90
|
+
| `key` / `focus` | Keyboard through the focused element. |
|
|
91
|
+
| `step` / `advance` | Deterministic group clock. |
|
|
92
|
+
| `screenshot` | `captureComposedFrame()` plus `root.outerHTML` when a compositor is mounted. |
|
|
93
|
+
| `accessibilitySnapshot` | Focus, `aria-live` text, HTML. |
|
|
94
|
+
| `reproduction` | Seed, viewport, DPR, reduced-motion, modality, action tape. |
|
|
95
|
+
| `destroy` | Host cleanup, group, compositor, listeners; blur focus; restore `matchMedia` / DPR. |
|
|
96
|
+
|
|
97
|
+
## Playwright-compatible adapter (no Playwright dependency)
|
|
98
|
+
|
|
99
|
+
`@playwright/test` is **not** an engine dependency. CI can wrap the same harness:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createBrowserHarness, createPlaywrightCompatibleAdapter } from '@cyberart-io/engine';
|
|
103
|
+
|
|
104
|
+
const harness = createBrowserHarness({ viewport: { width: 390, height: 844 }, mount });
|
|
105
|
+
const page = createPlaywrightCompatibleAdapter(harness);
|
|
106
|
+
|
|
107
|
+
await page.goto();
|
|
108
|
+
await page.setViewportSize({ width: 390, height: 844, deviceScaleFactor: 2 });
|
|
109
|
+
await page.click('#hotspot');
|
|
110
|
+
const shot = await page.screenshot();
|
|
111
|
+
await page.close();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| Playwright `Page` | This adapter |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `goto(url)` | `harness.goto()` (host already mounted) |
|
|
117
|
+
| `setViewportSize` | `harness.setViewport` |
|
|
118
|
+
| `click(selector)` | DOM click at the element's CSS box |
|
|
119
|
+
| `screenshot()` | Composed `ImageData` + HTML (not a Chromium PNG) |
|
|
120
|
+
| `keyboard.press` | `harness.key` |
|
|
121
|
+
| `locator(sel).click` | Same DOM click |
|
|
122
|
+
| `close()` | `harness.destroy()` |
|
|
123
|
+
|
|
124
|
+
To run the same scenario in Chromium later, keep these method names and swap the adapter body for `chromium.launch()` + `page.goto(hostUrl)` without changing tests.
|
|
125
|
+
|
|
126
|
+
Visual diffs stay on `assertPixelsEqual` / `compareImageData`. Do not add a second screenshot library.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Capability manifest
|
|
2
|
+
|
|
3
|
+
Versioned JSON for what a cart needs from the host: runtime contract, lifecycle phases, managers, asset kinds, event patterns, permissions, and integrations. Related: [events](events.md) (emit / subscribe patterns), [asset resolver](asset-resolver.md) (kinds only — this file does not resolve URLs).
|
|
4
|
+
|
|
5
|
+
Back to the [package README](../README.md).
|
|
6
|
+
|
|
7
|
+
## One-command reproduce (this repo)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-25-capability-manifest.repro.spec.ts
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Shape (`CAPABILITY_MANIFEST_VERSION = 1`)
|
|
14
|
+
|
|
15
|
+
`defineCapabilityManifest` / `parseCapabilityManifest` return `{ ok: true, manifest }` or `{ ok: false, errors }`. `validateCapabilityManifest(manifest, host)` checks the host can actually run it. The manifest is a plain object: `JSON.parse(JSON.stringify(m))` equals `toJSON(m)`.
|
|
16
|
+
|
|
17
|
+
| Field | Meaning |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `id` | Cart / module id. |
|
|
20
|
+
| `runtime.minContractVersion` | Lowest host contract version that may load this cart. |
|
|
21
|
+
| `runtime.features` | Feature tags the host must list (e.g. `router`, `assets`). |
|
|
22
|
+
| `phases` | Presentation phases used: `loading` \| `ready` \| `error` \| `unsupported`. |
|
|
23
|
+
| `managers` | Required host managers: `keyboard`, `pointer`, `audio`, `assets`, `hostChannel`. |
|
|
24
|
+
| `assets.kinds` | Asset kinds the cart declares (`image`, `audio`, `font`, `spritesheet`). |
|
|
25
|
+
| `assets.declarations` | `{ id, kind }` summary only — not a resolver. |
|
|
26
|
+
| `acceptedEvents` / `emittedEvents` | Dotted type patterns (`*` = one segment). |
|
|
27
|
+
| `permissions` | `emit` / `subscribe` patterns; optional `authoritative`. |
|
|
28
|
+
| `integrations` | Required host libraries: `tone` \| `midi`. |
|
|
29
|
+
|
|
30
|
+
Emitted types must be covered by `permissions.emit`; accepted types by `permissions.subscribe`.
|
|
31
|
+
|
|
32
|
+
## Host check
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import {
|
|
36
|
+
defineCapabilityManifest,
|
|
37
|
+
parseCapabilityManifest,
|
|
38
|
+
validateCapabilityManifest,
|
|
39
|
+
} from '@cyberart-io/engine';
|
|
40
|
+
|
|
41
|
+
const defined = defineCapabilityManifest({
|
|
42
|
+
id: 'host.presentation',
|
|
43
|
+
runtime: { minContractVersion: 1, features: ['router'] },
|
|
44
|
+
phases: ['loading', 'ready', 'error', 'unsupported'],
|
|
45
|
+
managers: ['pointer', 'hostChannel'],
|
|
46
|
+
assets: { kinds: ['image'], declarations: [{ id: 'backdrop', kind: 'image' }] },
|
|
47
|
+
acceptedEvents: ['host.state.*'],
|
|
48
|
+
emittedEvents: ['host.intent.exit-requested'],
|
|
49
|
+
permissions: { emit: ['host.intent.*'], subscribe: ['host.state.*'] },
|
|
50
|
+
integrations: ['tone'],
|
|
51
|
+
});
|
|
52
|
+
if (!defined.ok) throw new Error(defined.errors.map((e) => e.detail).join('; '));
|
|
53
|
+
|
|
54
|
+
const host = {
|
|
55
|
+
contractVersion: 1,
|
|
56
|
+
features: ['router'],
|
|
57
|
+
integrations: ['tone'] as const,
|
|
58
|
+
emit: ['host.intent.*'],
|
|
59
|
+
subscribe: ['host.state.*'],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const check = validateCapabilityManifest(defined.manifest, host);
|
|
63
|
+
// check.ok === true
|
|
64
|
+
const parsed = parseCapabilityManifest(JSON.stringify(defined.manifest));
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`host.managers`, `host.emit`, and `host.subscribe` are optional. When omitted, `missing-manager` and `unknown-permission` are not checked. Pass them when the host wants to enforce those allowlists.
|
|
68
|
+
|
|
69
|
+
| Diagnostic `code` | When |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `unknown-field` | Extra key on the manifest or host object. |
|
|
72
|
+
| `invalid-json` | `parseCapabilityManifest` received unparseable text. |
|
|
73
|
+
| `invalid-manifest` / `invalid-host` | Root value is not a JSON object. |
|
|
74
|
+
| `invalid-id` / `invalid-runtime` / `invalid-assets` / `invalid-permissions` | Required object missing or malformed. |
|
|
75
|
+
| `invalid-field` | Array/string field has the wrong shape. |
|
|
76
|
+
| `invalid-phase` | Phase is not loading/ready/error/unsupported. |
|
|
77
|
+
| `invalid-pattern` | Event pattern is empty or malformed. |
|
|
78
|
+
| `invalid-feature` | Feature tag is empty or malformed. |
|
|
79
|
+
| `undeclared-permission` | An accepted/emitted pattern is not covered by cart permissions. |
|
|
80
|
+
| `unsupported-runtime` | Host contract version is below `minContractVersion`. |
|
|
81
|
+
| `unsupported-feature` | A required feature tag is missing on the host. |
|
|
82
|
+
| `missing-integration` | Host does not provide `tone` / `midi` as required. |
|
|
83
|
+
| `missing-manager` | Host listed `managers` but omitted one the cart requires. |
|
|
84
|
+
| `unknown-permission` | Cart emit/subscribe permission is not on the host allowlist (only if host listed `emit` / `subscribe`). |
|
|
85
|
+
| `invalid-surface` / `invalid-clear-policy` / `invalid-blend` | Cart or host `surface` / `layers` values are the wrong shape or not in the allowlist. |
|
|
86
|
+
| `unsupported-surface` / `unsupported-layer` | Host omitted `surface.alpha` / `clearPolicy` or compositor blend modes the cart requires. |
|
|
87
|
+
|
|
88
|
+
Optional additive keys (omitted on existing carts):
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
surface: { alpha: true, clearPolicy: 'transparent' }
|
|
92
|
+
layers: { compositor: true, blend: ['source-over', 'screen'] }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
See [compositor](compositor.md).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Compositor
|
|
2
|
+
|
|
3
|
+
Transparent multi-cart compositor. Reads each runtime-group canvas with `getImageData`, blends onto a host image (or a transparent clear), and records declared layer order for headless capture. Hosts should not invent CSS `screen` hacks for black-backed carts.
|
|
4
|
+
|
|
5
|
+
Related: [runtime group](runtime-group.md), [headless harness](headless-harness.md), [capability manifest](capability-manifest.md).
|
|
6
|
+
|
|
7
|
+
Back to the [package README](../README.md).
|
|
8
|
+
|
|
9
|
+
## One-command reproduce (this repo)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-64-compositor.repro.spec.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
| Surface | Import | Use |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Production host | `createCompositor` from `@cyberart-io/engine` | Stack a host image under participant canvases (effects, overlays, debug). |
|
|
20
|
+
| Vitest / jsdom | same compositor after `installHeadlessCanvas()` | Headless `captureComposedFrame()` / `writeComposedFrame`. |
|
|
21
|
+
|
|
22
|
+
Browser vs headless pixel agreement is covered by the [browser harness](browser-harness.md). This module still emits a headless capture that includes `declaredOrder`.
|
|
23
|
+
|
|
24
|
+
## `createCompositor(options)`
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createCompositor, createRuntimeGroup } from '@cyberart-io/engine';
|
|
28
|
+
|
|
29
|
+
const group = createRuntimeGroup({
|
|
30
|
+
participants: [baseCart, overlayCart],
|
|
31
|
+
});
|
|
32
|
+
const compositor = createCompositor({
|
|
33
|
+
group,
|
|
34
|
+
width: 320,
|
|
35
|
+
height: 180,
|
|
36
|
+
dpr: 1,
|
|
37
|
+
host: { image: backdropImageData },
|
|
38
|
+
clearPolicy: 'transparent',
|
|
39
|
+
layers: [
|
|
40
|
+
{ id: 'overlay-a', order: 0, blend: 'source-over' },
|
|
41
|
+
{ id: 'overlay-b', order: 1, blend: 'screen' },
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await group.step(1);
|
|
46
|
+
const frame = compositor.captureComposedFrame();
|
|
47
|
+
// frame.declaredOrder is the compose list; frame.imageData is the stack.
|
|
48
|
+
compositor.destroy();
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Typical stack: host image, then opaque overlay carts (`source-over`), then additive layers (`screen` so RGB `0,0,0` backing is identity unless `clearPolicy: 'transparent'` knocks it out). Later `order` values draw on top.
|
|
52
|
+
|
|
53
|
+
### Options
|
|
54
|
+
|
|
55
|
+
| Option | Default | Meaning |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `group` | required | `createRuntimeGroup` handle. |
|
|
58
|
+
| `layers` | required | One entry per participant id. Sorted by `order`, then id. |
|
|
59
|
+
| `width` / `height` / `dpr` | `320` / `180` / `1` | Shared viewport. `resize` updates the group canvases. |
|
|
60
|
+
| `host.image` / `host.color` | none | Back layer. Color is `#rgb` / `#rrggbb`. |
|
|
61
|
+
| `clearPolicy` | `transparent` | Host surface starts clear (never filled black). Per-layer transparent policy knocks out RGB `0,0,0` backing pixels on opaque cart canvases. |
|
|
62
|
+
| `offscreen` | `true` | Compose into a compositor-owned canvas when `target` is omitted. `false` requires `target`. A provided `target` is never destroyed. |
|
|
63
|
+
|
|
64
|
+
### Layer fields
|
|
65
|
+
|
|
66
|
+
| Field | Default | Meaning |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `id` | required | Runtime-group participant id. |
|
|
69
|
+
| `order` | required | Lower draws first. |
|
|
70
|
+
| `visible` | `true` | Skip compose and pointer hits when false. |
|
|
71
|
+
| `opacity` | `1` | 0..1. |
|
|
72
|
+
| `blend` | `source-over` | Also `screen` (in-engine; same modes as `CAPABILITY_BLEND_MODES`). |
|
|
73
|
+
| `clip` | none | Pixel rect; compose and pointer hits are clipped. |
|
|
74
|
+
| `pointerEvents` | `auto` | `none` skips hit testing and sets `canvas.style.pointerEvents`. |
|
|
75
|
+
| `clearPolicy` | compositor default | `transparent` knocks out black backing. |
|
|
76
|
+
|
|
77
|
+
### Handle
|
|
78
|
+
|
|
79
|
+
| Member | Meaning |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `compose()` | Blend host + visible layers; returns `ImageData`. |
|
|
82
|
+
| `captureComposedFrame()` | `{ imageData, pngDataUrl, declaredOrder, width, height }`. |
|
|
83
|
+
| `resize(w, h, dpr?)` | Shared viewport; calls `group.resize` on the buffer size. Order/blend survive `group.step`. Carts that cache `DimensionContext` should `group.reset()` after a size change. |
|
|
84
|
+
| `setLayer` / `layer` / `layers` | Mutate / inspect declarative settings. |
|
|
85
|
+
| `pointerTarget(x, y)` | Top-most visible layer with `pointerEvents: 'auto'` under the pixel. |
|
|
86
|
+
| `unmountLayer(id)` | `group.detach(id)` without clearing sibling canvases. |
|
|
87
|
+
| `inspect()` | Viewport, clear policy, layers, `declaredOrder` ids. |
|
|
88
|
+
| `destroy()` | Drops the host canvas only if the compositor created it. Does not destroy the group. |
|
|
89
|
+
|
|
90
|
+
`group.step` isolates per-cart update/render throws so a failing layer does not blank siblings.
|
|
91
|
+
|
|
92
|
+
Node tests that need a PNG file: `writeComposedFrame(compositor, path)` from `@cyberart-io/engine/headless`.
|
|
93
|
+
|
|
94
|
+
## Capability keys (additive)
|
|
95
|
+
|
|
96
|
+
Optional manifest fields, omitted on existing carts:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
surface: { alpha: true, clearPolicy: 'transparent' }
|
|
100
|
+
layers: { compositor: true, blend: ['source-over', 'screen'] }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`validateCapabilityManifest` reports `unsupported-surface` / `unsupported-layer` when the host does not list matching `surface` / `layers`. Carts that omit the keys keep the previous host check.
|
package/docs/events.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Events and router
|
|
2
2
|
|
|
3
|
-
Mailbox (one runtime) and `createEventRouter` (many carts). Carts never receive the router object or another cart’s `HostChannel`. Related: [deterministic mode](deterministic-mode.md) (`now` / `createId` / `turn` per `step`), [presentation adapter](presentation-adapter.md) (`presentation.state.model`), [presentation cue](presentation-cue.md) (frame-local lifecycle, not routed unless you define a contract).
|
|
3
|
+
Mailbox (one runtime) and `createEventRouter` (many carts). Prefer `createRuntimeGroup` when several carts should share one router and lockstep clock. Carts never receive the router object or another cart’s `HostChannel`. Related: [deterministic mode](deterministic-mode.md) (`now` / `createId` / `turn` per `step`), [presentation adapter](presentation-adapter.md) (`presentation.state.model`), [presentation cue](presentation-cue.md) (frame-local lifecycle, not routed unless you define a contract), [runtime group](runtime-group.md), [capability manifest](capability-manifest.md), [replay inspector](replay-inspector.md).
|
|
4
4
|
|
|
5
5
|
Back to the [package README](../README.md).
|
|
6
6
|
|
|
@@ -10,14 +10,16 @@ Back to the [package README](../README.md).
|
|
|
10
10
|
pnpm exec vitest run packages/engine/src/canvas/cyb-59-event-contract.repro.spec.ts
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
## When to use which
|
|
14
15
|
|
|
15
16
|
| Path | Use |
|
|
16
17
|
|---|---|
|
|
17
18
|
| Mailbox only | One cart, host `dispatch` / cart `emit`. No permissions, hops, or loop checks. |
|
|
18
|
-
| Router | Several carts, host reducer in the middle,
|
|
19
|
+
| Router | Several carts, host reducer in the middle, Dotted-name intent vs state. |
|
|
20
|
+
| Runtime group | Same router plus lockstep `step`, pause/reset/teardown, and a routed trace. |
|
|
19
21
|
|
|
20
|
-
`createRuntime` does **not** attach a router. Attach `runtime.hostChannel` yourself. Cart unload clears the inbound queue only; router listeners stay until `runtime.destroy()
|
|
22
|
+
`createRuntime` does **not** attach a router. Attach `runtime.hostChannel` yourself, or use `createRuntimeGroup()` which calls `router.attach(id, runtime.hostChannel, options)` for each participant. Cart unload clears the inbound queue only; router listeners stay until `runtime.destroy()` (or `group.destroy()`). If the channel is attached to a router, use `router.subscribe` for host logic — a second `mount({ onEvent })` will see every cart emit twice.
|
|
21
23
|
|
|
22
24
|
## Mailbox (`HostChannel`)
|
|
23
25
|
|
|
@@ -51,7 +53,7 @@ Routed events are normalized to `EventEnvelope` (`EVENT_ENVELOPE_VERSION = 1`).
|
|
|
51
53
|
| Field | Set by | Meaning |
|
|
52
54
|
|---|---|---|
|
|
53
55
|
| `schemaVersion` | router | Always `1`. Other versions are `malformed`. |
|
|
54
|
-
| `type` | emitter | Dotted name, e.g. `
|
|
56
|
+
| `type` | emitter | Dotted name, e.g. `host.intent.exit-requested`. |
|
|
55
57
|
| `kind` | explicit or inferred | `intent` \| `state` \| `diagnostic`. |
|
|
56
58
|
| `source` | router | Participant id (or `host` / `router`). Claimed `source` is overwritten. |
|
|
57
59
|
| `target` | emitter | Optional **participant id**, not a domain object (put the exit id in `payload`). |
|
|
@@ -66,8 +68,8 @@ Routed events are normalized to `EventEnvelope` (`EVENT_ENVELOPE_VERSION = 1`).
|
|
|
66
68
|
`kind` on the input wins. Otherwise:
|
|
67
69
|
|
|
68
70
|
- `cyberart.state.save` / `cyberart.state.load` are **intents** (historical names).
|
|
69
|
-
- Else the first dotted segment of `type` that is `intent`, `state`, or `diagnostic` (not necessarily the second segment — `
|
|
70
|
-
- Else `malformed` (`cannot infer kind`). `
|
|
71
|
+
- Else the first dotted segment of `type` that is `intent`, `state`, or `diagnostic` (not necessarily the second segment — `host.presentation.intent.cue-started` is an intent).
|
|
72
|
+
- Else `malformed` (`cannot infer kind`). `host.presentation.cue.started` does not infer a kind; define it with `defineIntent` / `defineStateEvent` / `defineDiagnostic` so the kind is in the name.
|
|
71
73
|
|
|
72
74
|
Only the host (`publish`) or an `authoritative` participant may emit `state` / `diagnostic`. Carts emit allowed `intent` patterns. Domain data belongs in `payload`.
|
|
73
75
|
|
|
@@ -84,11 +86,11 @@ import {
|
|
|
84
86
|
createEventRouter,
|
|
85
87
|
} from '@cyberart-io/engine';
|
|
86
88
|
|
|
87
|
-
const exit = defineIntent('
|
|
89
|
+
const exit = defineIntent('host.intent.exit-requested', {
|
|
88
90
|
version: 1,
|
|
89
91
|
fields: { exitId: { type: 'string' } },
|
|
90
92
|
});
|
|
91
|
-
const room = defineStateEvent('
|
|
93
|
+
const room = defineStateEvent('host.state.room-changed', {
|
|
92
94
|
version: 1,
|
|
93
95
|
fields: { roomId: { type: 'string' } },
|
|
94
96
|
});
|
|
@@ -101,7 +103,7 @@ const router = createEventRouter({ validate: registry.asRouterValidate });
|
|
|
101
103
|
router.attach('presentation', runtime.hostChannel, deriveAttachOptions(contracts, 'cart'));
|
|
102
104
|
```
|
|
103
105
|
|
|
104
|
-
`
|
|
106
|
+
`host.presentation.cue.started` has no `intent` / `state` / `diagnostic` segment, so `inferEventKind` returns undefined and `defineIntent` returns `{ ok: false, errors }` with `kind-mismatch`. Use `host.presentation.intent.cue-started` (kind anywhere after the namespace, not only the second segment). Cue timeline names (`cue.started`, …) are **not** routed envelopes until you wrap them in a contract.
|
|
105
107
|
|
|
106
108
|
| Helper | Kind stamped | Name rule |
|
|
107
109
|
|---|---|---|
|
|
@@ -117,9 +119,9 @@ router.attach('presentation', runtime.hostChannel, deriveAttachOptions(contracts
|
|
|
117
119
|
| `deriveAttachOptions(contracts, 'cart' \| 'authoritative')` | Cart: emit intent family patterns only. Authoritative: emit every contract family. Subscribe to non-intent families. |
|
|
118
120
|
| `verifyAttachOptions(options, contracts)` | `{ ok: true }` or unauthorized-emit errors when a non-authoritative attach can emit a `state` / `diagnostic` contract |
|
|
119
121
|
| `comparePayloadSchemas(from, to)` | `'identical'` / `'backward-compatible'` / `'breaking'` |
|
|
120
|
-
| `kindSegmentInType(type)` / `familyPatternForType(type)` | First kind segment; last-segment `*` glob (`
|
|
122
|
+
| `kindSegmentInType(type)` / `familyPatternForType(type)` | First kind segment; last-segment `*` glob (`host.presentation.intent.*`) |
|
|
121
123
|
|
|
122
|
-
Default router `emit: ['*.intent.*']` is **three** segments. Four-segment names such as `
|
|
124
|
+
Default router `emit: ['*.intent.*']` is **three** segments. Four-segment names such as `host.presentation.intent.cue-started` need `deriveAttachOptions` (or an explicit four-segment pattern). Duplicate `type`s in a registry last-win.
|
|
123
125
|
|
|
124
126
|
### Payload versioning (`comparePayloadSchemas`)
|
|
125
127
|
|
|
@@ -141,13 +143,13 @@ const router = createEventRouter({
|
|
|
141
143
|
});
|
|
142
144
|
|
|
143
145
|
router.attach('presentation', runtime.hostChannel, {
|
|
144
|
-
emit: ['
|
|
145
|
-
subscribe: ['
|
|
146
|
+
emit: ['host.intent.*'],
|
|
147
|
+
subscribe: ['host.state.*', 'cyberart.diagnostic.rejected'],
|
|
146
148
|
});
|
|
147
149
|
|
|
148
|
-
router.subscribe(['
|
|
150
|
+
router.subscribe(['host.intent.*'], (event) => {
|
|
149
151
|
router.publish(
|
|
150
|
-
{ type: '
|
|
152
|
+
{ type: 'host.state.room-changed', kind: 'state', payload: { sceneId: 'beta' } },
|
|
151
153
|
{ cause: event },
|
|
152
154
|
);
|
|
153
155
|
});
|
|
@@ -170,6 +172,7 @@ router.turn(); // once per step / frame
|
|
|
170
172
|
| `maxHops` | `DEFAULT_MAX_HOPS` (`8`) | Remaining hops on a new root. A caused follow-up gets `cause.hops - 1`. At 0 → `hop-limit`. |
|
|
171
173
|
| `maxCorrelationPerTurn` | `16` | Events sharing a `correlationId` per turn → `storm-detected`. |
|
|
172
174
|
| `maxIndex` | `256` | Bound on causation / idempotency maps (oldest keys dropped). |
|
|
175
|
+
| `maxDecisions` | `256` | Bound on `inspectDecisions()` (oldest dropped). |
|
|
173
176
|
|
|
174
177
|
### Router methods (`EventRouter`)
|
|
175
178
|
|
|
@@ -179,8 +182,13 @@ router.turn(); // once per step / frame
|
|
|
179
182
|
| `detach(id)` | Unbind. In-flight targeted emits to this id fail `unknown-target`. |
|
|
180
183
|
| `publish(event, extras?)` | Host emit. `extras.cause` sets causation and inherited idempotency/correlation. Returns the envelope, or `undefined` if rejected. |
|
|
181
184
|
| `subscribe(patterns, listener)` | Host listener (not a cart). Returns unsubscribe. |
|
|
185
|
+
| `subscribeDecision(listener)` | Routing decisions: accepted, rejected, duplicate. |
|
|
186
|
+
| `inspectParticipants()` | Attached id / emit / subscribe / authoritative. |
|
|
187
|
+
| `inspectDecisions()` | Copy of the bounded decision log. |
|
|
182
188
|
| `turn()` | Reset per-turn budgets and the auto-link causation cursor. Idempotency records **survive**. Call once per `step`. |
|
|
183
189
|
|
|
190
|
+
Causation trees and tape replay: [replay inspector](replay-inspector.md).
|
|
191
|
+
|
|
184
192
|
### `AttachOptions`
|
|
185
193
|
|
|
186
194
|
| Option | Default | Meaning |
|
|
@@ -189,7 +197,7 @@ router.turn(); // once per step / frame
|
|
|
189
197
|
| `subscribe` | `[]` | Types delivered to this channel. |
|
|
190
198
|
| `authoritative` | `false` | If true, may emit `state` / `diagnostic` **when those types are also in `emit`**. |
|
|
191
199
|
|
|
192
|
-
Patterns are dotted segments; `*` matches one segment. `
|
|
200
|
+
Patterns are dotted segments; `*` matches one segment. `host.intent.*` matches `host.intent.exit-requested`, not `host.intent.exit.requested`.
|
|
193
201
|
|
|
194
202
|
## Delivery and ordering
|
|
195
203
|
|
|
@@ -202,7 +210,7 @@ Cart-to-cart still goes through the router:
|
|
|
202
210
|
|
|
203
211
|
```ts
|
|
204
212
|
presentationChannel.emit({
|
|
205
|
-
type: '
|
|
213
|
+
type: 'host.intent.remark',
|
|
206
214
|
target: 'npc',
|
|
207
215
|
payload: { text: 'Anyone there?' },
|
|
208
216
|
idempotencyKey: 'hello-1',
|
|
@@ -247,14 +255,11 @@ Type `REJECTED_EVENT_TYPE` (`cyberart.diagnostic.rejected`). Payload (`Rejection
|
|
|
247
255
|
| `EVENT_ENVELOPE_VERSION` | `1`. |
|
|
248
256
|
| `DEFAULT_MAX_HOPS` | `8`. |
|
|
249
257
|
| `REJECTED_EVENT_TYPE` | `'cyberart.diagnostic.rejected'`. |
|
|
250
|
-
| `inferEventKind(input)` | Kind from `kind` or
|
|
258
|
+
| `inferEventKind(input)` | Kind from `kind` or type name. |
|
|
251
259
|
| `matchEventPattern(pattern, type)` | One-segment `*` glob. |
|
|
252
260
|
| `normalizeEvent(input, context)` | Build an envelope or `{ ok: false, reason: 'malformed', detail }`. The router calls this; hosts rarely need it. |
|
|
253
|
-
| `defineIntent` / `defineStateEvent` / `defineDiagnostic` | Contract constructors (see Typed contracts). |
|
|
254
|
-
| `createContractRegistry` / `deriveAttachOptions` / `verifyAttachOptions` / `comparePayloadSchemas` | Registry, permissions, schema compatibility. |
|
|
255
|
-
| `kindSegmentInType` / `familyPatternForType` | Name helpers used by contracts. |
|
|
256
261
|
|
|
257
|
-
Types: `EventEnvelope`, `EventInput`, `EventKind`, `NormalizeContext`, `NormalizeResult`, `RejectionPayload`, `RejectionReason`, `AttachOptions`, `EventRouter`, `EventRouterOptions`, `PublishExtras`, `ValidateResult
|
|
262
|
+
Types: `EventEnvelope`, `EventInput`, `EventKind`, `NormalizeContext`, `NormalizeResult`, `RejectionPayload`, `RejectionReason`, `AttachOptions`, `EventRouter`, `EventRouterOptions`, `PublishExtras`, `ValidateResult`.
|
|
258
263
|
|
|
259
264
|
`matchesAnyPattern` / `clonePayload` / `isEventKind` are not on the public package surface.
|
|
260
265
|
|
package/docs/headless-harness.md
CHANGED
|
@@ -4,7 +4,7 @@ CI / agent wrapper around production `createRuntime({ deterministic })`. Not a s
|
|
|
4
4
|
|
|
5
5
|
Import from **`@cyberart-io/engine/headless`**. Production carts and browser hosts keep using `@cyberart-io/engine` so Vite never resolves `node:fs/promises`.
|
|
6
6
|
|
|
7
|
-
Depends on [deterministic mode](deterministic-mode.md). Host reducers: [events](events.md). Presentation overlay: [presentation adapter](presentation-adapter.md).
|
|
7
|
+
Depends on [deterministic mode](deterministic-mode.md). Host reducers: [events](events.md). Presentation overlay: [presentation adapter](presentation-adapter.md). Several carts: [runtime group](runtime-group.md) (`createHeadlessMultiCartHarness`).
|
|
8
8
|
|
|
9
9
|
Back to the [package README](../README.md).
|
|
10
10
|
|
|
@@ -12,8 +12,8 @@ Back to the [package README](../README.md).
|
|
|
12
12
|
|
|
13
13
|
| Surface | Import |
|
|
14
14
|
|---|---|
|
|
15
|
-
| Carts, Player, kaleidoscope, `/art` | `@cyberart-io/engine` (`createRuntime`, events, contracts, assets, presentation adapter, presentation cue) |
|
|
16
|
-
| Vitest / jsdom / CI capture | `@cyberart-io/engine/headless` (`createHeadlessHarness`, `installHeadlessCanvas`, `captureFrame`) |
|
|
15
|
+
| Carts, Player, kaleidoscope, `/art` | `@cyberart-io/engine` (`createRuntime`, events, contracts, assets, presentation adapter, presentation cue, capability manifest, geometry, runtime group) |
|
|
16
|
+
| Vitest / jsdom / CI capture | `@cyberart-io/engine/headless` (`createHeadlessHarness`, `createHeadlessMultiCartHarness`, `installHeadlessCanvas`, `captureFrame`) |
|
|
17
17
|
|
|
18
18
|
The main export does not re-export the harness. A browser bundle that only imports `@cyberart-io/engine` must not warn about `node:fs/promises`.
|
|
19
19
|
|
|
@@ -38,10 +38,38 @@ import {
|
|
|
38
38
|
installHeadlessCanvas();
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Mutates `HTMLCanvasElement.prototype`: `ImageData` polyfill (width/height
|
|
41
|
+
Mutates `HTMLCanvasElement.prototype`: `ImageData` polyfill (width/height and `ImageData(data, w, h)`), software `getContext('2d')` (paths, arcs, text via a glyph atlas, transforms, clip, alpha, source-over, linear/radial gradients, `drawImage` from ImageData / headless canvas / `createImageFixture`), `toDataURL` encodes those pixels as PNG. `HEADLESS_PNG_DATA_URL` remains a 1×1 compatibility export for callers that never attached a context. Idempotent. **Do not call from production playback.**
|
|
42
42
|
|
|
43
43
|
`createHeadlessHarness` calls this for you. Default container size is `DEFAULT_HEADLESS_WIDTH` × `DEFAULT_HEADLESS_HEIGHT` (320×180).
|
|
44
44
|
|
|
45
|
+
Coverage is binary (pixel-center inside path / nearest-neighbor images). No antialiasing, no `Math.random`. Identical seeded harness runs produce identical `ImageData`. Unsupported operations throw `HeadlessUnsupportedOperationError` with the operation name (never silent no-ops). `createPattern`, non-empty `setLineDash`, `filter`, shadows, HTMLImageElement `drawImage`, and unknown context methods are in that set.
|
|
46
|
+
|
|
47
|
+
Font/image fixtures: pass `{ glyphAtlas }` to `installHeadlessCanvas`, call `setDefaultGlyphAtlas`, or `ctx.setGlyphAtlas`. `fillText` never loads system fonts. `drawImage` accepts `ImageData`, another headless canvas, or `createImageFixture(width, height, pixels)`.
|
|
48
|
+
|
|
49
|
+
## Visual assertions
|
|
50
|
+
|
|
51
|
+
Import `compareImageData`, `assertPixelsEqual`, `assertPngDataUrlsEqual`, and `writeVisualArtifacts` from `@cyberart-io/engine/headless`.
|
|
52
|
+
|
|
53
|
+
- Exact match: `tolerance` defaults to 0 (every RGBA byte).
|
|
54
|
+
- Perceptual / slack: `tolerance` (max per-channel delta) and/or `perceptualThreshold` (weighted RGB distance with luma weights 0.299 / 0.587 / 0.114).
|
|
55
|
+
- On mismatch the result includes in-memory `expectedPng` / `actualPng` / `diffPng` (magenta where pixels differ) plus data URLs. `writeVisualArtifacts(dir, artifacts)` writes those PNGs (Node `fs` only; same rule as `captureFrame(path)`).
|
|
56
|
+
- Golden update: set `CYBERART_UPDATE_GOLDEN=1` (or `true`) or pass `{ updateGolden: true }`. The compare then treats actual as expected so CI can rewrite goldens. Engine-encoded 8-bit RGBA PNGs round-trip through `decodePng`.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { compareImageData, writeVisualArtifacts } from '@cyberart-io/engine/headless';
|
|
60
|
+
|
|
61
|
+
const result = compareImageData(actual, expected, { perceptualThreshold: 2 });
|
|
62
|
+
if (!result.match) {
|
|
63
|
+
await writeVisualArtifacts('/tmp/cyb-62-diff', result.artifacts);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
One-command visual reproduce in this repo:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-62-headless-canvas2d.repro.spec.ts
|
|
71
|
+
```
|
|
72
|
+
|
|
45
73
|
## `createHeadlessHarness(options)`
|
|
46
74
|
|
|
47
75
|
```ts
|
|
@@ -54,7 +82,7 @@ const harness = createHeadlessHarness({
|
|
|
54
82
|
height: 180,
|
|
55
83
|
origin: 0,
|
|
56
84
|
actions: [{ type: 'asset', atFrame: 0, id: 'room-map', status: 'ready' }],
|
|
57
|
-
initialState: {
|
|
85
|
+
initialState: { scene: 'alpha' },
|
|
58
86
|
gameManager: hostAdapter, // site injection; presentation overlay is attachPresentationAdapter
|
|
59
87
|
onEvent: (event) => {},
|
|
60
88
|
onError: (error, info) => {},
|
|
@@ -92,7 +120,7 @@ Always sets `deterministic: { origin, actions }`.
|
|
|
92
120
|
| `click(x, y)` | Pointer-**down** at `getClock().framesElapsed` (next tick). Canvas pixels, not CSS. Use `schedule` for `move` / `up`. |
|
|
93
121
|
| `key(key)` | Same next-frame `schedule` for a key. Cart must `registerAction`. |
|
|
94
122
|
| `inspect()` | `{ state, events, errors, replay, clock }`. `state` is **exported**, not live `getCartState()`. |
|
|
95
|
-
| `captureFrame(path?)` | `cart.snapshot()
|
|
123
|
+
| `captureFrame(path?)` | `cart.snapshot()` (`canvas.toDataURL('image/png')` on the production buffer). Optional `path` writes PNG bytes (Node only). |
|
|
96
124
|
| `remount(options?)` | `runtime.mount` again while alive. Constructor `actions` replay; prior `click`/`key`/`schedule` dropped. `'onEvent' in options` replaces the listener; `{ onEvent: undefined }` clears it. Invalid after `destroy()`. |
|
|
97
125
|
| `destroy()` | Unload cart, destroy runtime, remove container. Idempotent. Always removes the container even if unload throws. |
|
|
98
126
|
|
|
@@ -118,9 +146,11 @@ First frame throw: `{ phase: 'update', consecutive: 1, stopped: false }`. See [d
|
|
|
118
146
|
|
|
119
147
|
Returns `CartSnapshot`: `{ seed, metadata?, pngDataUrl }`.
|
|
120
148
|
|
|
121
|
-
- Omit `path` for in-memory snapshot (`pngDataUrl` is
|
|
149
|
+
- Omit `path` for in-memory snapshot (`pngDataUrl` is the software Canvas2D frame, not the 1×1 stub).
|
|
122
150
|
- With `path`: Node `fs.promises.writeFile` of decoded PNG bytes. Throws a harness message if not Node, if `node:fs/promises` cannot load, or if the write fails (missing directory, etc.). Write under `os.tmpdir()`, not the repo.
|
|
123
151
|
|
|
152
|
+
Composed stacks use `createCompositor` plus `writeComposedFrame(compositor, path)` from the same headless entry. See [compositor](compositor.md).
|
|
153
|
+
|
|
124
154
|
## Interaction loop (agent recipe)
|
|
125
155
|
|
|
126
156
|
```ts
|
|
@@ -142,17 +172,17 @@ adapter.present({
|
|
|
142
172
|
contractVersion: 1,
|
|
143
173
|
phase: 'ready',
|
|
144
174
|
view: {
|
|
145
|
-
title: '
|
|
175
|
+
title: 'Alpha',
|
|
146
176
|
regions: [
|
|
147
177
|
{
|
|
148
|
-
id: 'north
|
|
178
|
+
id: 'region-north',
|
|
149
179
|
x: 0,
|
|
150
180
|
y: 0,
|
|
151
181
|
width: canvas.width,
|
|
152
182
|
height: canvas.height * 0.25,
|
|
153
183
|
intent: {
|
|
154
|
-
type: '
|
|
155
|
-
payload: { exitId: '
|
|
184
|
+
type: 'host.intent.exit-requested',
|
|
185
|
+
payload: { exitId: 'beta' },
|
|
156
186
|
},
|
|
157
187
|
},
|
|
158
188
|
],
|
|
@@ -163,24 +193,24 @@ harness.click(canvas.width / 2, canvas.height * 0.1);
|
|
|
163
193
|
await harness.step(1);
|
|
164
194
|
|
|
165
195
|
const { events } = await harness.inspect();
|
|
166
|
-
// assert exactly one
|
|
196
|
+
// assert exactly one host.intent.exit-requested; cart state is still Alpha
|
|
167
197
|
|
|
168
198
|
adapter.present({
|
|
169
199
|
contractVersion: 1,
|
|
170
200
|
phase: 'ready',
|
|
171
|
-
view: { title: '
|
|
201
|
+
view: { title: 'Overlook', regions: [] },
|
|
172
202
|
});
|
|
173
203
|
await harness.step(1);
|
|
174
204
|
|
|
175
|
-
await harness.captureFrame('/tmp/
|
|
205
|
+
await harness.captureFrame('/tmp/alpha.png');
|
|
176
206
|
adapter.destroy();
|
|
177
207
|
harness.destroy();
|
|
178
208
|
```
|
|
179
209
|
|
|
180
210
|
Cleanup: cart `teardown` runs on unload; after `destroy`, `step` throws and the container is gone. `afterEach(() => harness.destroy())` so a failed assertion does not leak nodes. `attachPresentationAdapter(harness)` then `adapter.destroy()` tears down the harness too; `attachPresentationAdapter(harness.cart)` only unloads the cart.
|
|
181
211
|
|
|
182
|
-
Host-owned
|
|
212
|
+
Host-owned scenes / hotspots use this adapter (`present` + `host.intent.*`). `gameManager` is site multiplayer, not this contract.
|
|
183
213
|
|
|
184
214
|
## Types
|
|
185
215
|
|
|
186
|
-
`CreateHeadlessHarnessOptions`, `HeadlessHarness`, `HeadlessInspect`, `HeadlessFrameError
|
|
216
|
+
`CreateHeadlessHarnessOptions`, `HeadlessHarness`, `HeadlessInspect`, `HeadlessFrameError`, `InstallHeadlessCanvasOptions`, `HeadlessUnsupportedOperationError`, visual-assert types.
|