@markii/host 0.0.0-stage → 0.17.0-next.2

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.
Files changed (193) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -2
  3. package/dist/browser-worker.js +639 -0
  4. package/dist/browser.d.ts +57 -0
  5. package/dist/browser.js +167 -0
  6. package/dist/conformance/host/01-plain-open/expected.json +5 -0
  7. package/dist/conformance/host/01-plain-open/note.mk.md +8 -0
  8. package/dist/conformance/host/01-plain-open/scenario.json +6 -0
  9. package/dist/conformance/host/02-run-manual-grant-accepted/expected.json +16 -0
  10. package/dist/conformance/host/02-run-manual-grant-accepted/note.mk.md +5 -0
  11. package/dist/conformance/host/02-run-manual-grant-accepted/scenario.json +14 -0
  12. package/dist/conformance/host/03-run-manual-grant-refused/expected.json +18 -0
  13. package/dist/conformance/host/03-run-manual-grant-refused/note.mk.md +5 -0
  14. package/dist/conformance/host/03-run-manual-grant-refused/scenario.json +14 -0
  15. package/dist/conformance/host/04-run-auto-write-denied/expected.json +16 -0
  16. package/dist/conformance/host/04-run-auto-write-denied/note.mk.md +10 -0
  17. package/dist/conformance/host/04-run-auto-write-denied/scenario.json +13 -0
  18. package/dist/conformance/host/05-export-html-pack-directive/expected.json +14 -0
  19. package/dist/conformance/host/05-export-html-pack-directive/note.mk.md +8 -0
  20. package/dist/conformance/host/05-export-html-pack-directive/scenario.json +6 -0
  21. package/dist/conformance/host/06-install-pack-then-render/expected.json +18 -0
  22. package/dist/conformance/host/06-install-pack-then-render/files/ana-1.0.0/pack.json +13 -0
  23. package/dist/conformance/host/06-install-pack-then-render/files/ana-1.0.0/webview.js +1 -0
  24. package/dist/conformance/host/06-install-pack-then-render/note.mk.md +5 -0
  25. package/dist/conformance/host/06-install-pack-then-render/scenario.json +15 -0
  26. package/dist/conformance/host/07-complete-in-directive/expected.json +14 -0
  27. package/dist/conformance/host/07-complete-in-directive/note.mk.md +3 -0
  28. package/dist/conformance/host/07-complete-in-directive/scenario.json +6 -0
  29. package/dist/conformance/host/08-hover-standard-component/expected.json +5 -0
  30. package/dist/conformance/host/08-hover-standard-component/note.mk.md +5 -0
  31. package/dist/conformance/host/08-hover-standard-component/scenario.json +6 -0
  32. package/dist/conformance/host/09-insert-skeleton/expected.json +5 -0
  33. package/dist/conformance/host/09-insert-skeleton/note.mk.md +4 -0
  34. package/dist/conformance/host/09-insert-skeleton/scenario.json +6 -0
  35. package/dist/conformance/host/10-scripts-disabled/expected.json +11 -0
  36. package/dist/conformance/host/10-scripts-disabled/note.mk.md +5 -0
  37. package/dist/conformance/host/10-scripts-disabled/scenario.json +9 -0
  38. package/dist/conformance/host/11-complete-closing-fence/expected.json +5 -0
  39. package/dist/conformance/host/11-complete-closing-fence/note.mk.md +5 -0
  40. package/dist/conformance/host/11-complete-closing-fence/scenario.json +12 -0
  41. package/dist/conformance/host/12-complete-nested-container/expected.json +5 -0
  42. package/dist/conformance/host/12-complete-nested-container/note.mk.md +5 -0
  43. package/dist/conformance/host/12-complete-nested-container/scenario.json +12 -0
  44. package/dist/conformance/host/13-install-pack-declined/expected.json +12 -0
  45. package/dist/conformance/host/13-install-pack-declined/files/ana-1.0.0/pack.json +13 -0
  46. package/dist/conformance/host/13-install-pack-declined/files/ana-1.0.0/webview.js +1 -0
  47. package/dist/conformance/host/13-install-pack-declined/note.mk.md +5 -0
  48. package/dist/conformance/host/13-install-pack-declined/scenario.json +10 -0
  49. package/dist/conformance/host/14-remove-pack/expected.json +14 -0
  50. package/dist/conformance/host/14-remove-pack/files/ana-1.0.0/pack.json +13 -0
  51. package/dist/conformance/host/14-remove-pack/files/ana-1.0.0/webview.js +1 -0
  52. package/dist/conformance/host/14-remove-pack/note.mk.md +5 -0
  53. package/dist/conformance/host/14-remove-pack/scenario.json +11 -0
  54. package/dist/conformance/host/15-load-packs-engine-views/expected.json +19 -0
  55. package/dist/conformance/host/15-load-packs-engine-views/files/duo-1.0.0/pack.json +9 -0
  56. package/dist/conformance/host/15-load-packs-engine-views/files/duo-1.0.0/webview.js +1 -0
  57. package/dist/conformance/host/15-load-packs-engine-views/note.mk.md +9 -0
  58. package/dist/conformance/host/15-load-packs-engine-views/scenario.json +10 -0
  59. package/dist/conformance/host/16-pack-trust/expected.json +37 -0
  60. package/dist/conformance/host/16-pack-trust/files/ana-1.0.0/pack.json +12 -0
  61. package/dist/conformance/host/16-pack-trust/files/ana-1.0.0/webview.js +1 -0
  62. package/dist/conformance/host/16-pack-trust/files/sneaky-1.0.0/pack.json +12 -0
  63. package/dist/conformance/host/16-pack-trust/files/sneaky-1.0.0/webview.js +1 -0
  64. package/dist/conformance/host/16-pack-trust/note.mk.md +5 -0
  65. package/dist/conformance/host/16-pack-trust/scenario.json +26 -0
  66. package/dist/conformance/host/README.md +221 -0
  67. package/dist/conformance.d.ts +2 -0
  68. package/dist/conformance.js +5 -0
  69. package/dist/diagnostics/render-diagnostics.d.ts +41 -0
  70. package/dist/diagnostics/render-diagnostics.js +46 -0
  71. package/dist/export/cascade.d.ts +98 -0
  72. package/dist/export/cascade.js +159 -0
  73. package/dist/export/export-zip.d.ts +19 -0
  74. package/dist/export/export-zip.js +38 -0
  75. package/dist/export/image-embed.d.ts +126 -0
  76. package/dist/export/image-embed.js +274 -0
  77. package/dist/export/note-export.d.ts +332 -0
  78. package/dist/export/note-export.js +366 -0
  79. package/dist/export/note-links.d.ts +83 -0
  80. package/dist/export/note-links.js +241 -0
  81. package/dist/glue.wasm +0 -0
  82. package/dist/host/adapter.d.ts +223 -0
  83. package/dist/host/adapter.js +9 -0
  84. package/dist/host/cascade-export-behavior.d.ts +120 -0
  85. package/dist/host/cascade-export-behavior.js +151 -0
  86. package/dist/host/conformance-runner.d.ts +156 -0
  87. package/dist/host/conformance-runner.js +634 -0
  88. package/dist/host/create-host.d.ts +253 -0
  89. package/dist/host/create-host.js +329 -0
  90. package/dist/host/editor-behavior.d.ts +157 -0
  91. package/dist/host/editor-behavior.js +227 -0
  92. package/dist/host/export/md-plain.d.ts +15 -0
  93. package/dist/host/export/md-plain.js +125 -0
  94. package/dist/host/export-behavior.d.ts +113 -0
  95. package/dist/host/export-behavior.js +257 -0
  96. package/dist/host/labels.d.ts +24 -0
  97. package/dist/host/labels.js +32 -0
  98. package/dist/host/pack-archive.d.ts +30 -0
  99. package/dist/host/pack-archive.js +68 -0
  100. package/dist/host/pack-install.d.ts +88 -0
  101. package/dist/host/pack-install.js +189 -0
  102. package/dist/host/pack-load.d.ts +72 -0
  103. package/dist/host/pack-load.js +57 -0
  104. package/dist/host/pack-remove.d.ts +21 -0
  105. package/dist/host/pack-remove.js +65 -0
  106. package/dist/host/pack-trust-behavior.d.ts +79 -0
  107. package/dist/host/pack-trust-behavior.js +262 -0
  108. package/dist/host/refresh-interval.d.ts +39 -0
  109. package/dist/host/refresh-interval.js +61 -0
  110. package/dist/host/run-behavior.d.ts +89 -0
  111. package/dist/host/run-behavior.js +168 -0
  112. package/dist/host/run-diagnostics.d.ts +26 -0
  113. package/dist/host/run-diagnostics.js +43 -0
  114. package/dist/host/script-execution.d.ts +72 -0
  115. package/dist/host/script-execution.js +68 -0
  116. package/dist/host/terminal-packs.d.ts +39 -0
  117. package/dist/host/terminal-packs.js +51 -0
  118. package/dist/html-engine.d.ts +2 -0
  119. package/dist/html-engine.js +29 -0
  120. package/dist/index.d.ts +95 -0
  121. package/dist/index.js +70 -0
  122. package/dist/insert/component-catalog.d.ts +23 -0
  123. package/dist/insert/component-catalog.js +96 -0
  124. package/dist/node-fs.d.ts +14 -0
  125. package/dist/node-fs.js +70 -0
  126. package/dist/node.d.ts +5 -0
  127. package/dist/node.js +13 -0
  128. package/dist/package.json +3 -0
  129. package/dist/packs/discover.d.ts +119 -0
  130. package/dist/packs/discover.js +266 -0
  131. package/dist/packs/pack-diagnostics.d.ts +81 -0
  132. package/dist/packs/pack-diagnostics.js +105 -0
  133. package/dist/packs/pack-paths.d.ts +53 -0
  134. package/dist/packs/pack-paths.js +106 -0
  135. package/dist/packs/pack-render-registry.d.ts +83 -0
  136. package/dist/packs/pack-render-registry.js +182 -0
  137. package/dist/packs/pack-scripts.d.ts +20 -0
  138. package/dist/packs/pack-scripts.js +96 -0
  139. package/dist/packs/pack-trust.d.ts +57 -0
  140. package/dist/packs/pack-trust.js +111 -0
  141. package/dist/packs/prebuilt.d.ts +49 -0
  142. package/dist/packs/prebuilt.js +97 -0
  143. package/dist/path-util.d.ts +24 -0
  144. package/dist/path-util.js +82 -0
  145. package/dist/run/browser-isolate.d.ts +45 -0
  146. package/dist/run/browser-isolate.js +81 -0
  147. package/dist/run/bundle-document.d.ts +29 -0
  148. package/dist/run/bundle-document.js +40 -0
  149. package/dist/run/bundle-run.d.ts +188 -0
  150. package/dist/run/bundle-run.js +232 -0
  151. package/dist/run/grant-flow.d.ts +232 -0
  152. package/dist/run/grant-flow.js +390 -0
  153. package/dist/run/ip-address.d.ts +41 -0
  154. package/dist/run/ip-address.js +255 -0
  155. package/dist/run/isolate.d.ts +57 -0
  156. package/dist/run/isolate.js +22 -0
  157. package/dist/run/lua-resolver.d.ts +21 -0
  158. package/dist/run/lua-resolver.js +63 -0
  159. package/dist/run/net-bridge-worker.d.ts +26 -0
  160. package/dist/run/net-bridge-worker.js +66 -0
  161. package/dist/run/net-bridge.d.ts +56 -0
  162. package/dist/run/net-bridge.js +59 -0
  163. package/dist/run/net-pinning.d.ts +82 -0
  164. package/dist/run/net-pinning.js +137 -0
  165. package/dist/run/net-provider.d.ts +70 -0
  166. package/dist/run/net-provider.js +289 -0
  167. package/dist/run/node-isolate.d.ts +30 -0
  168. package/dist/run/node-isolate.js +117 -0
  169. package/dist/run/run-flow.d.ts +187 -0
  170. package/dist/run/run-flow.js +323 -0
  171. package/dist/run/run-host.d.ts +107 -0
  172. package/dist/run/run-host.js +241 -0
  173. package/dist/run/run-job.d.ts +130 -0
  174. package/dist/run/run-job.js +279 -0
  175. package/dist/run/run-progress.d.ts +55 -0
  176. package/dist/run/run-progress.js +42 -0
  177. package/dist/run/run-trace.d.ts +44 -0
  178. package/dist/run/run-trace.js +36 -0
  179. package/dist/run/script-requirements.d.ts +98 -0
  180. package/dist/run/script-requirements.js +212 -0
  181. package/dist/run/snapshot-storage.d.ts +25 -0
  182. package/dist/run/snapshot-storage.js +31 -0
  183. package/dist/run/stale-values.d.ts +25 -0
  184. package/dist/run/stale-values.js +32 -0
  185. package/dist/run/worker-entry-browser.d.ts +42 -0
  186. package/dist/run/worker-entry-browser.js +77 -0
  187. package/dist/run/worker-entry.d.ts +1 -0
  188. package/dist/run/worker-entry.js +96 -0
  189. package/dist/values-failure.d.ts +17 -0
  190. package/dist/values-failure.js +1 -0
  191. package/dist/values-merge.d.ts +39 -0
  192. package/dist/values-merge.js +33 -0
  193. package/package.json +84 -4
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Executes one `conformance/host/<scenario>` case (batch 11 Phase 3,
3
+ * `tmp/W11-corpus-design.md`) against any real `HostAdapter`, so a single
4
+ * plain-data corpus proves the three apps' adapters behave the same.
5
+ *
6
+ * `conformance/host/` itself stays plain data (no TypeScript, matching the
7
+ * repo's existing corpora): a `note.mk.md`, a `scenario.json` (the ordered
8
+ * actions), an `expected.json` (the outcomes), and an optional `files/`
9
+ * directory. This module is the one place that reads that data, drives it
10
+ * through a `MarkiiHost`, and reports what happened.
11
+ *
12
+ * THE CAPABILITY RULE (the design's crux): `ACTION_CAPABILITY` is the one
13
+ * static map from an action's type to the capability it needs.
14
+ * `hostDeclaresCapability` checks the map against the ADAPTER UNDER TEST,
15
+ * never against the scenario data — the corpus itself never encodes
16
+ * per-host knowledge. `createMarkiiHost`'s own behaviors already return the
17
+ * shared `Unsupported` shape when a capability is missing; this module's
18
+ * job is to know that the fact should be a PASS (the correct thing for
19
+ * this host to say), not something to diff against `expected.json`.
20
+ *
21
+ * THE SCRIPTS-DISABLED SCENARIO (`10-scripts-disabled`) is not a
22
+ * `MarkiiHost` capability at all: the device-local execution switch is
23
+ * decided by each app's OWN call site (`preview-panel.ts`, `main.ts`)
24
+ * before it ever reaches `host.run`, using the shared, host-neutral
25
+ * wording in `./script-execution.js`. This module reproduces that same
26
+ * pre-check here, over the same shared functions and the SAME
27
+ * `adapter.labels` a real call site would use, so the scenario proves
28
+ * wording parity across hosts without inventing a capability the contract
29
+ * does not have. See `tmp/W11-phase3.md` for why this is a reported
30
+ * finding, not a silent workaround.
31
+ *
32
+ * SIMULATED EXECUTION. A `run` action's `simulate` field (plain data) tells
33
+ * this module what the isolate would have produced, instead of spawning a
34
+ * real Lua worker: whether the requested value needed a specific granted
35
+ * host, and whether it was a write blocked under the read-only tier. Real
36
+ * sandbox enforcement is `conformance/executor/`'s job; this corpus proves
37
+ * the HOST-LEVEL plumbing around it (grant prompts, wording, capability
38
+ * declarations, trigger forwarding) — see this file's `simulatedRunResult`.
39
+ * Every OTHER action runs through the real, unmocked shared behavior code.
40
+ */
41
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
42
+ import { dirname } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ import { createHtmlExportEngine } from '../html-engine.js';
45
+ import { createNodeArchiveExtractFs } from '../node-fs.js';
46
+ import { usesPackTrust } from './pack-trust-behavior.js';
47
+ import { join, relative, sep } from 'node:path';
48
+ import { zipSync } from 'fflate';
49
+ import { conformanceDir } from '@markii/core/corpus';
50
+ import { createMarkiiHost, } from './create-host.js';
51
+ import { scriptsDisabledDiagnosticLine, scriptsDisabledNotice, } from './script-execution.js';
52
+ // --- corpus location ----------------------------------------------------
53
+ /**
54
+ * `conformance/host/`, resolved relative to this module's own file, never
55
+ * `process.cwd()`. An installed package carries the corpus at
56
+ * `dist/conformance/host/`, a sibling of `dist/host/`; a repo checkout runs
57
+ * from `src/host/` and finds the repo-root corpus through
58
+ * `@markii/core/corpus`'s `conformanceDir`.
59
+ */
60
+ export function hostConformanceDir() {
61
+ const here = dirname(fileURLToPath(import.meta.url));
62
+ const packaged = join(here, '..', 'conformance', 'host');
63
+ if (existsSync(packaged))
64
+ return packaged;
65
+ return join(conformanceDir(), 'host');
66
+ }
67
+ /** Every scenario directory name under `dir`, sorted. */
68
+ export function listHostScenarioNames(dir = hostConformanceDir()) {
69
+ return readdirSync(dir, { withFileTypes: true })
70
+ .filter((entry) => entry.isDirectory())
71
+ .map((entry) => entry.name)
72
+ .sort();
73
+ }
74
+ /** Loads one scenario's three files. Never validates beyond `JSON.parse` — a malformed fixture is a corpus bug, and throwing loudly here is the right failure mode for it. */
75
+ export function loadHostScenario(name, baseDir = hostConformanceDir()) {
76
+ const dir = join(baseDir, name);
77
+ const noteText = readFileSync(join(dir, 'note.mk.md'), 'utf8');
78
+ const scenario = JSON.parse(readFileSync(join(dir, 'scenario.json'), 'utf8'));
79
+ const expected = JSON.parse(readFileSync(join(dir, 'expected.json'), 'utf8'));
80
+ return { dir, noteText, scenario, expected };
81
+ }
82
+ // --- .mkp archive zipping (from a plain files/ directory, never a committed binary) ---
83
+ function readDirRecursive(dir, base) {
84
+ const out = {};
85
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
86
+ const full = join(dir, entry.name);
87
+ if (entry.isDirectory()) {
88
+ Object.assign(out, readDirRecursive(full, base));
89
+ }
90
+ else {
91
+ const relativeName = relative(base, full).split(sep).join('/');
92
+ out[relativeName] = new Uint8Array(readFileSync(full));
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+ /** Zips a scenario's `files/<name>` directory into `.mkp` archive bytes on the fly — this is how the corpus ships pack fixtures as plain files instead of a committed binary. */
98
+ export function zipArchiveDirectory(archiveDir) {
99
+ return zipSync(readDirRecursive(archiveDir, archiveDir));
100
+ }
101
+ /** The one static map from an action's type to the capability it needs. `undefined` means every host supports it (there is nothing to decline). */
102
+ export const ACTION_CAPABILITY = {
103
+ open: undefined,
104
+ run: 'isolate',
105
+ exportHtml: 'export-format',
106
+ exportAnsi: 'export-format',
107
+ installPack: 'packs',
108
+ loadPacks: 'packs',
109
+ removePack: 'packs',
110
+ placePack: 'pack-trust',
111
+ listPacks: 'pack-trust',
112
+ enablePack: 'pack-trust',
113
+ disablePack: 'pack-trust',
114
+ completeAt: 'editor',
115
+ hoverAt: 'editor',
116
+ insertComponent: 'editor',
117
+ };
118
+ /** An action's capability: the static map, except that a pack action flagged `requiresTrust` needs the device trust list. */
119
+ function capabilityFor(action) {
120
+ if ((action.type === 'installPack' ||
121
+ action.type === 'loadPacks' ||
122
+ action.type === 'removePack') &&
123
+ action.requiresTrust === true) {
124
+ return 'pack-trust';
125
+ }
126
+ return ACTION_CAPABILITY[action.type];
127
+ }
128
+ function exportFormatFor(action) {
129
+ return action.type === 'exportAnsi' ? 'ansi' : 'html';
130
+ }
131
+ /** Whether `adapter` declares the capability `action` needs. Never consults the scenario or `expected.json` — only the adapter under test. */
132
+ export function hostDeclaresCapability(adapter, action) {
133
+ const capability = capabilityFor(action);
134
+ if (capability === undefined)
135
+ return true;
136
+ if (capability === 'pack-trust')
137
+ return usesPackTrust(adapter);
138
+ if (capability === 'isolate')
139
+ return adapter.isolate !== undefined;
140
+ if (capability === 'editor')
141
+ return adapter.editor !== undefined;
142
+ if (capability === 'packs') {
143
+ if (adapter.packs === undefined)
144
+ return false;
145
+ if (action.type === 'installPack') {
146
+ return adapter.packs.installRoot !== undefined;
147
+ }
148
+ if (action.type === 'removePack') {
149
+ return (adapter.packs.installRoot !== undefined &&
150
+ adapter.packs.removeFolder !== undefined);
151
+ }
152
+ return true;
153
+ }
154
+ // 'export-format'
155
+ return adapter.exports?.formats.has(exportFormatFor(action)) ?? false;
156
+ }
157
+ // --- the scripted prompt queue --------------------------------------------
158
+ /**
159
+ * Flattens every `run`/`installPack` action's `answers` into one queue, in
160
+ * document order — the same order the grant/consent prompts fire in, since
161
+ * `expected.json`'s `prompts` array is itself the ordered proof of that.
162
+ * `next()` THROWS once exhausted rather than returning a default: an
163
+ * unanswered prompt means the scenario and the implementation disagree
164
+ * about how many questions get asked, which must never be silently read
165
+ * as a deny (`tmp/W11-corpus-design.md`, "The prompt policy").
166
+ */
167
+ export function createAnswerQueue(scenario) {
168
+ const answers = [];
169
+ for (const action of scenario.actions) {
170
+ const source = action.type === 'run' ||
171
+ action.type === 'installPack' ||
172
+ action.type === 'enablePack'
173
+ ? action.answers
174
+ : undefined;
175
+ for (const answer of source ?? [])
176
+ answers.push(answer === 'allow');
177
+ }
178
+ let index = 0;
179
+ return {
180
+ next() {
181
+ if (index >= answers.length) {
182
+ throw new Error(`scripted prompt queue exhausted after ${String(index)} answer(s): ` +
183
+ 'the scenario and the implementation disagree about how many ' +
184
+ 'questions get asked.');
185
+ }
186
+ return answers[index++];
187
+ },
188
+ };
189
+ }
190
+ // --- simulated execution ---------------------------------------------------
191
+ function simulatedRunResult(sim, options) {
192
+ const name = sim?.name ?? 'value';
193
+ if (sim?.requiresManualTier === true && options.trigger !== 'manual') {
194
+ return {
195
+ values: {},
196
+ failures: [
197
+ {
198
+ name,
199
+ message: `${name}: a write was attempted under the read-only "${options.trigger ?? 'manual'}" tier.`,
200
+ kind: 'tier-blocked',
201
+ },
202
+ ],
203
+ cacheSnapshot: options.cacheSnapshot,
204
+ };
205
+ }
206
+ if (sim?.requiresHost !== undefined &&
207
+ !options.netAllowlist.includes(sim.requiresHost)) {
208
+ return {
209
+ values: {},
210
+ failures: [
211
+ {
212
+ name,
213
+ message: `${name}: host "${sim.requiresHost}" was not granted.`,
214
+ kind: 'capability-denied',
215
+ },
216
+ ],
217
+ cacheSnapshot: options.cacheSnapshot,
218
+ };
219
+ }
220
+ return {
221
+ values: { [name]: { status: 'fresh', value: sim?.value ?? 1 } },
222
+ failures: [],
223
+ cacheSnapshot: options.cacheSnapshot,
224
+ };
225
+ }
226
+ /** `unsupported()` in `./create-host.js` writes exactly this shape of line; scenarios describe the fully-capable host's behavior, so a decline line is not something `expected.json` should have to also state per host. */
227
+ const UNSUPPORTED_DIAGNOSTIC_LINE = /^declined \([a-z-]+ unsupported on /;
228
+ function decodeWritten(writtenFiles, path) {
229
+ if (path === undefined)
230
+ return undefined;
231
+ const bytes = writtenFiles.get(path);
232
+ return bytes === undefined ? undefined : new TextDecoder().decode(bytes);
233
+ }
234
+ /**
235
+ * Runs `scenario` against `adapter` end to end. `adapter` is the REAL,
236
+ * already-constructed host adapter under test (built by the caller from
237
+ * its own injected dependencies plus a scripted prompt source, e.g.
238
+ * `createAnswerQueue`); this function never builds one itself, so it works
239
+ * identically for any `HostAdapter`.
240
+ */
241
+ export async function runHostScenario(adapter, scenario, noteText, scenarioDir, options = {}) {
242
+ const diagnostics = [];
243
+ const prompts = [];
244
+ const writtenFiles = new Map();
245
+ let promptQueueError;
246
+ const recording = {
247
+ ...adapter,
248
+ now: () => scenario.clock,
249
+ diagnostics: (line) => {
250
+ diagnostics.push(line);
251
+ adapter.diagnostics(line);
252
+ },
253
+ prompt: async (request) => {
254
+ prompts.push(request.message);
255
+ return adapter.prompt(request);
256
+ },
257
+ writeFile: async (path, bytes) => {
258
+ writtenFiles.set(path, bytes);
259
+ await adapter.writeFile(path, bytes);
260
+ },
261
+ };
262
+ let currentSimulation;
263
+ const host = createMarkiiHost(recording, {
264
+ // The runner is Node-only test tooling, so it supplies the real Node
265
+ // implementations of the seams the host layer takes by injection.
266
+ htmlEngine: createHtmlExportEngine(),
267
+ archiveExtractFs: createNodeArchiveExtractFs(),
268
+ spawnRun: (spawnOptions) => Promise.resolve(simulatedRunResult(currentSimulation, spawnOptions)),
269
+ ...(options.renderAnsi ? { renderAnsi: options.renderAnsi } : {}),
270
+ });
271
+ const notePath = join(scenarioDir, 'note.mk.md');
272
+ const outcomes = [];
273
+ for (const action of scenario.actions) {
274
+ try {
275
+ outcomes.push(await runOneAction(host, recording, action, {
276
+ noteText,
277
+ notePath,
278
+ scenarioDir,
279
+ writtenFiles,
280
+ setSimulation: (sim) => {
281
+ currentSimulation = sim;
282
+ },
283
+ }));
284
+ }
285
+ catch (err) {
286
+ promptQueueError = err instanceof Error ? err.message : String(err);
287
+ break;
288
+ }
289
+ }
290
+ return {
291
+ outcomes,
292
+ diagnostics,
293
+ prompts,
294
+ ...(promptQueueError !== undefined ? { promptQueueError } : {}),
295
+ };
296
+ }
297
+ async function runOneAction(host, adapter, action, ctx) {
298
+ // A trust-scenario action on a host without the trust list never reaches
299
+ // the host: the declaration is what the corpus records.
300
+ if (capabilityFor(action) === 'pack-trust' && !usesPackTrust(adapter)) {
301
+ const detail = 'this host does not gate installed packs on a trust list.';
302
+ adapter.diagnostics(`declined (pack-trust unsupported on ${adapter.id}): ${detail}`);
303
+ return { kind: 'unsupported', capability: 'pack-trust', detail };
304
+ }
305
+ switch (action.type) {
306
+ case 'open':
307
+ return host.open({ path: ctx.notePath });
308
+ case 'run': {
309
+ if (action.scriptsDisabled === true) {
310
+ // The device-local scripts-off switch is not a `MarkiiHost`
311
+ // capability (see this file's top comment): it is decided at each
312
+ // app's own call site, before `host.run` is ever reached, using
313
+ // the shared wording in `./script-execution.js`. Reproduced here
314
+ // over the SAME functions and the adapter's own `labels` so the
315
+ // scenario proves wording parity without inventing a capability.
316
+ adapter.diagnostics(scriptsDisabledDiagnosticLine(action.trigger, adapter.labels));
317
+ return {
318
+ kind: 'declined',
319
+ reason: scriptsDisabledNotice(action.trigger, adapter.labels) ??
320
+ scriptsDisabledDiagnosticLine(action.trigger, adapter.labels),
321
+ };
322
+ }
323
+ ctx.setSimulation(action.simulate);
324
+ return host.run({
325
+ documentKey: ctx.notePath,
326
+ text: ctx.noteText,
327
+ trigger: action.trigger,
328
+ });
329
+ }
330
+ case 'exportHtml':
331
+ case 'exportAnsi': {
332
+ const outcome = await host.exportNote({
333
+ format: exportFormatFor(action),
334
+ notePath: ctx.notePath,
335
+ text: ctx.noteText,
336
+ });
337
+ return augmentExportOutcome(outcome, ctx.writtenFiles);
338
+ }
339
+ case 'installPack': {
340
+ const archiveBytes = zipArchiveDirectory(join(ctx.scenarioDir, action.archive));
341
+ return host.installPack({ archiveBytes, archivePath: action.archive });
342
+ }
343
+ case 'loadPacks':
344
+ return host.loadPacks();
345
+ case 'removePack':
346
+ return host.removePack(action.namespace);
347
+ case 'placePack': {
348
+ const installRoot = adapter.packs?.installRoot?.();
349
+ if (installRoot === undefined)
350
+ return { kind: 'failed' };
351
+ const files = readDirRecursive(join(ctx.scenarioDir, action.archive), join(ctx.scenarioDir, action.archive));
352
+ for (const [name, bytes] of Object.entries(files)) {
353
+ await adapter.writeFile(join(installRoot, action.namespace, ...name.split('/')), bytes);
354
+ }
355
+ return { kind: 'placed', namespace: action.namespace };
356
+ }
357
+ case 'listPacks':
358
+ return host.installedPacks();
359
+ case 'enablePack':
360
+ return host.enablePack(action.namespace);
361
+ case 'disablePack':
362
+ return host.disablePack(action.namespace);
363
+ case 'completeAt':
364
+ return host.completeAt({ line: action.line, column: action.column });
365
+ case 'hoverAt':
366
+ return host.hoverAt({ line: action.line, column: action.column });
367
+ case 'insertComponent':
368
+ return host.insertComponent({ name: action.name });
369
+ }
370
+ }
371
+ function augmentExportOutcome(outcome, writtenFiles) {
372
+ if (outcome.kind !== 'exported')
373
+ return outcome;
374
+ return { ...outcome, content: decodeWritten(writtenFiles, outcome.path) };
375
+ }
376
+ // --- diffing against expected.json -----------------------------------------
377
+ /** `expected[i]` compared against `actual[i]` for each index up to `expected`'s own length; a length mismatch is reported once rather than index-by-index noise. */
378
+ export function containsInOrder(actual, expected) {
379
+ if (actual.length !== expected.length) {
380
+ return [
381
+ `count: expected ${String(expected.length)}, got ${String(actual.length)} ` +
382
+ `(actual: ${JSON.stringify(actual)})`,
383
+ ];
384
+ }
385
+ const mismatches = [];
386
+ expected.forEach((exp, i) => {
387
+ if (!(actual[i] ?? '').includes(exp)) {
388
+ mismatches.push(`[${String(i)}]: expected to contain "${exp}", got "${actual[i]}"`);
389
+ }
390
+ });
391
+ return mismatches;
392
+ }
393
+ /** Extracts `{name: value}` for every non-error `StoredValue` in a `'ran'` outcome's `values`, for a plain-data comparison against `expected.json`. */
394
+ function landedValues(values) {
395
+ if (typeof values !== 'object' || values === null)
396
+ return {};
397
+ const out = {};
398
+ for (const [name, entry] of Object.entries(values)) {
399
+ if (typeof entry === 'object' &&
400
+ entry !== null &&
401
+ 'status' in entry &&
402
+ entry.status !== 'error' &&
403
+ 'value' in entry) {
404
+ out[name] = entry.value;
405
+ }
406
+ }
407
+ return out;
408
+ }
409
+ function failureKinds(failures) {
410
+ if (!Array.isArray(failures))
411
+ return [];
412
+ return failures.map((f) => typeof f === 'object' && f !== null && 'kind' in f
413
+ ? String(f.kind)
414
+ : 'unknown');
415
+ }
416
+ /**
417
+ * A coarse, per-`kind` comparison between one action's real outcome and its
418
+ * `expected.json` entry. Deliberately partial (matching the design's own
419
+ * "deliberately coarse" posture): a `'ran'` outcome compares landed values
420
+ * and failure KINDS, never timings or raw messages; an `'exported'` outcome
421
+ * compares `contains`/`excludes` substrings against the written file's
422
+ * content, never the whole document.
423
+ */
424
+ export function outcomeMismatches(actual, expected) {
425
+ if (expected === undefined)
426
+ return [];
427
+ const actualRecord = typeof actual === 'object' && actual !== null
428
+ ? actual
429
+ : {};
430
+ if (actualRecord['kind'] !== expected['kind']) {
431
+ return [
432
+ `kind: expected "${String(expected['kind'])}", got "${String(actualRecord['kind'])}"`,
433
+ ];
434
+ }
435
+ const mismatches = [];
436
+ switch (expected['kind']) {
437
+ case 'ran': {
438
+ const wantValues = expected['values'] ?? {};
439
+ const gotValues = landedValues(actualRecord['values']);
440
+ if (JSON.stringify(gotValues) !== JSON.stringify(wantValues)) {
441
+ mismatches.push(`values: expected ${JSON.stringify(wantValues)}, got ${JSON.stringify(gotValues)}`);
442
+ }
443
+ const wantKinds = expected['failureKinds'] ?? [];
444
+ const gotKinds = failureKinds(actualRecord['failures']);
445
+ if (JSON.stringify(gotKinds) !== JSON.stringify(wantKinds)) {
446
+ mismatches.push(`failure kinds: expected ${JSON.stringify(wantKinds)}, got ${JSON.stringify(gotKinds)}`);
447
+ }
448
+ if (expected['traceOk'] !== undefined) {
449
+ const trace = actualRecord['trace'];
450
+ if (trace?.ok !== expected['traceOk']) {
451
+ mismatches.push(`trace.ok: expected ${String(expected['traceOk'])}, got ${String(trace?.ok)}`);
452
+ }
453
+ }
454
+ break;
455
+ }
456
+ case 'exported': {
457
+ const content = String(actualRecord['content'] ?? '');
458
+ for (const needle of expected['contains'] ?? []) {
459
+ if (!content.includes(needle)) {
460
+ mismatches.push(`export should contain "${needle}"`);
461
+ }
462
+ }
463
+ for (const needle of expected['excludes'] ?? []) {
464
+ if (content.includes(needle)) {
465
+ mismatches.push(`export should not contain "${needle}"`);
466
+ }
467
+ }
468
+ break;
469
+ }
470
+ case 'installed': {
471
+ if (expected['namespace'] !== undefined &&
472
+ actualRecord['namespace'] !== expected['namespace']) {
473
+ mismatches.push(`namespace: expected "${String(expected['namespace'])}", got "${String(actualRecord['namespace'])}"`);
474
+ }
475
+ if (expected['replaced'] !== undefined &&
476
+ actualRecord['replaced'] !== expected['replaced']) {
477
+ mismatches.push(`replaced: expected ${String(expected['replaced'])}, got ${String(actualRecord['replaced'])}`);
478
+ }
479
+ break;
480
+ }
481
+ case 'declined': {
482
+ if (expected['step'] !== undefined &&
483
+ actualRecord['step'] !== expected['step']) {
484
+ mismatches.push(`step: expected "${String(expected['step'])}", got "${String(actualRecord['step'])}"`);
485
+ }
486
+ if (expected['reasonContains'] !== undefined &&
487
+ !String(actualRecord['reason'] ?? '').includes(String(expected['reasonContains']))) {
488
+ mismatches.push(`reason: expected to contain "${String(expected['reasonContains'])}", got "${String(actualRecord['reason'])}"`);
489
+ }
490
+ break;
491
+ }
492
+ case 'listed': {
493
+ const names = (list, pick) => (Array.isArray(list) ? list.map(pick) : []).map(String).sort();
494
+ const gotEnabled = names(actualRecord['enabled'], (entry) => entry.namespace);
495
+ const gotNotEnabled = names(actualRecord['notEnabled'], (entry) => entry);
496
+ for (const [key, got] of [
497
+ ['enabled', gotEnabled],
498
+ ['notEnabled', gotNotEnabled],
499
+ ]) {
500
+ const want = [...(expected[key] ?? [])].sort();
501
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
502
+ mismatches.push(`${key}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
503
+ }
504
+ }
505
+ break;
506
+ }
507
+ case 'enabled':
508
+ case 'disabled':
509
+ case 'not-enabled':
510
+ case 'placed':
511
+ case 'removed': {
512
+ if (expected['namespace'] !== undefined &&
513
+ actualRecord['namespace'] !== expected['namespace']) {
514
+ mismatches.push(`namespace: expected "${String(expected['namespace'])}", got "${String(actualRecord['namespace'])}"`);
515
+ }
516
+ break;
517
+ }
518
+ case 'loaded': {
519
+ const want = [...(expected['namespaces'] ?? [])].sort();
520
+ const got = [...(actualRecord['namespaces'] ?? [])].sort();
521
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
522
+ mismatches.push(`namespaces: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
523
+ }
524
+ // The component counts are engine-neutral on purpose: a pack with one
525
+ // view per engine has exactly one component unavailable on every host.
526
+ for (const [expectedKey, actualKey] of [
527
+ ['componentCount', 'components'],
528
+ ['unavailableCount', 'unavailable'],
529
+ ]) {
530
+ if (expected[expectedKey] === undefined)
531
+ continue;
532
+ const list = actualRecord[actualKey];
533
+ const count = Array.isArray(list) ? list.length : 0;
534
+ if (count !== expected[expectedKey]) {
535
+ mismatches.push(`${expectedKey}: expected ${String(expected[expectedKey])}, got ${String(count)}`);
536
+ }
537
+ }
538
+ break;
539
+ }
540
+ case 'completions': {
541
+ const got = actualRecord['labels'] ?? [];
542
+ for (const label of expected['labels'] ?? []) {
543
+ if (!got.includes(label)) {
544
+ mismatches.push(`completions should include "${label}"`);
545
+ }
546
+ }
547
+ for (const key of ['replaceStart', 'replaceEnd', 'tokenStart']) {
548
+ if (expected[key] !== undefined &&
549
+ actualRecord[key] !== expected[key]) {
550
+ mismatches.push(`${key}: expected ${String(expected[key])}, got ${String(actualRecord[key])}`);
551
+ }
552
+ }
553
+ if (expected['fenceEditCount'] !== undefined) {
554
+ const got = Array.isArray(actualRecord['fenceEdits'])
555
+ ? actualRecord['fenceEdits'].length
556
+ : 0;
557
+ if (got !== expected['fenceEditCount']) {
558
+ mismatches.push(`fenceEditCount: expected ${String(expected['fenceEditCount'])}, got ${String(got)}`);
559
+ }
560
+ }
561
+ break;
562
+ }
563
+ case 'hover': {
564
+ const contains = expected['contains'];
565
+ if (contains !== undefined &&
566
+ !String(actualRecord['text'] ?? '').includes(contains)) {
567
+ mismatches.push(`hover text should contain "${contains}"`);
568
+ }
569
+ for (const key of ['start', 'end']) {
570
+ if (expected[key] !== undefined &&
571
+ actualRecord[key] !== expected[key]) {
572
+ mismatches.push(`${key}: expected ${String(expected[key])}, got ${String(actualRecord[key])}`);
573
+ }
574
+ }
575
+ break;
576
+ }
577
+ default:
578
+ break; // kind equality is the whole check for every other outcome.
579
+ }
580
+ return mismatches;
581
+ }
582
+ /**
583
+ * The full assertion an app's conformance test makes for one scenario: for
584
+ * every action whose capability `adapter` declares, its outcome matches
585
+ * `expected`'s; for every other action, the outcome is exactly the shared
586
+ * `unsupported` record. Returns a list of human-readable problems — empty
587
+ * means the scenario passed.
588
+ */
589
+ export function assertHostScenario(adapter, scenario, expected, result) {
590
+ const problems = [];
591
+ if (result.promptQueueError !== undefined) {
592
+ problems.push(`prompt queue: ${result.promptQueueError}`);
593
+ }
594
+ scenario.actions.forEach((action, i) => {
595
+ const actual = result.outcomes[i];
596
+ const declared = hostDeclaresCapability(adapter, action);
597
+ if (!declared) {
598
+ const capability = capabilityFor(action);
599
+ const actualRecord = typeof actual === 'object' && actual !== null
600
+ ? actual
601
+ : {};
602
+ if (actualRecord['kind'] !== 'unsupported' ||
603
+ actualRecord['capability'] !== capability) {
604
+ problems.push(`action ${String(i)} (${action.type}): expected {kind:"unsupported", capability:"${String(capability)}"}, got ${JSON.stringify(actual)}`);
605
+ }
606
+ return;
607
+ }
608
+ for (const problem of outcomeMismatches(actual, expected.outcomes[i])) {
609
+ problems.push(`action ${String(i)} (${action.type}): ${problem}`);
610
+ }
611
+ });
612
+ // `expected.json`'s `diagnostics`/`prompts` describe what a FULLY CAPABLE
613
+ // host does. When every action in this scenario needs a capability the
614
+ // adapter lacks (a whole scenario built around one capability domain,
615
+ // e.g. every action in `06-install-pack-then-render` needs `packs`),
616
+ // nothing meaningful ran and there is nothing to compare — the
617
+ // per-action `unsupported` check above already covers this host
618
+ // honestly. Comparing an unrelated host's empty prompt/diagnostics list
619
+ // against a capable host's expectation here would be double-counting
620
+ // the same capability gap as a second, spurious failure.
621
+ const capabilityGatedActions = scenario.actions.filter((action) => capabilityFor(action) !== undefined);
622
+ const anyActionDeclared = capabilityGatedActions.length === 0 ||
623
+ capabilityGatedActions.some((action) => hostDeclaresCapability(adapter, action));
624
+ if (anyActionDeclared) {
625
+ const filteredDiagnostics = result.diagnostics.filter((line) => !UNSUPPORTED_DIAGNOSTIC_LINE.test(line));
626
+ for (const problem of containsInOrder(filteredDiagnostics, expected.diagnostics)) {
627
+ problems.push(`diagnostics ${problem}`);
628
+ }
629
+ for (const problem of containsInOrder(result.prompts, expected.prompts)) {
630
+ problems.push(`prompts ${problem}`);
631
+ }
632
+ }
633
+ return problems;
634
+ }