@markii/host 0.17.0-next.3 → 0.17.0-next.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 +34 -1
- package/dist/browser-worker.classic.js +640 -0
- package/dist/browser-worker.js +19 -19
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +10 -3
- package/dist/conformance/host/05-export-html-pack-directive/expected.json +2 -0
- package/dist/conformance/host/06-install-pack-then-render/expected.json +6 -1
- package/dist/conformance/host/17-export-linked-set/expected.json +17 -0
- package/dist/conformance/host/17-export-linked-set/files/second.mk.md +3 -0
- package/dist/conformance/host/17-export-linked-set/note.mk.md +4 -0
- package/dist/conformance/host/17-export-linked-set/scenario.json +6 -0
- package/dist/conformance/host/README.md +17 -12
- package/dist/diagnostics/render-diagnostics.d.ts +1 -1
- package/dist/export/note-export.d.ts +1 -1
- package/dist/host/adapter.d.ts +13 -3
- package/dist/host/cascade-export-behavior.d.ts +24 -8
- package/dist/host/cascade-export-behavior.js +61 -1
- package/dist/host/conformance-runner.d.ts +3 -4
- package/dist/host/conformance-runner.js +58 -8
- package/dist/host/create-host.d.ts +65 -8
- package/dist/host/create-host.js +54 -23
- package/dist/host/export-behavior.d.ts +2 -1
- package/dist/host/export-behavior.js +1 -1
- package/dist/host/labels.d.ts +5 -14
- package/dist/host/labels.js +8 -22
- package/dist/host/pack-archive.d.ts +4 -10
- package/dist/host/pack-archive.js +11 -14
- package/dist/host/pack-install.d.ts +10 -21
- package/dist/host/pack-install.js +22 -16
- package/dist/host/pack-trust-behavior.js +1 -1
- package/dist/host/run-behavior.d.ts +4 -0
- package/dist/host/run-behavior.js +9 -1
- package/dist/host/terminal-packs.d.ts +1 -1
- package/dist/html-engine.js +4 -4
- package/dist/index.d.ts +19 -81
- package/dist/index.js +19 -62
- package/dist/internal.d.ts +28 -0
- package/dist/internal.js +31 -0
- package/dist/node-fs.d.ts +0 -3
- package/dist/node-fs.js +1 -21
- package/dist/node-worker.d.ts +8 -0
- package/dist/node-worker.js +36 -15
- package/dist/node.d.ts +1 -2
- package/dist/node.js +4 -3
- package/dist/packs/discover.d.ts +2 -0
- package/dist/packs/discover.js +1 -0
- package/dist/run/bundle-run.d.ts +1 -1
- package/dist/run/bundle-run.js +1 -1
- package/dist/run/net-bridge.js +7 -5
- package/dist/run/net-pinning.d.ts +2 -0
- package/dist/run/net-pinning.js +17 -3
- package/dist/run/net-provider.js +23 -12
- package/dist/run/net-refusal.d.ts +4 -0
- package/dist/run/net-refusal.js +24 -0
- package/package.json +28 -12
package/dist/browser.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface FetchNetProviderOptions {
|
|
|
11
11
|
* every request. Entries that do not parse as an http(s) URL are ignored.
|
|
12
12
|
*/
|
|
13
13
|
allowedOrigins?: readonly string[];
|
|
14
|
-
/** How a policy refusal is built. Defaults to a plain `Error
|
|
14
|
+
/** How a policy refusal is built. Defaults to a plain `Error`. The provider marks every refusal it throws, so the isolate bridge sends it as a denial. A transport failure is never marked. */
|
|
15
15
|
denial?: (message: string) => Error;
|
|
16
16
|
/** The `fetch` to use. Defaults to the global one; injectable for tests. */
|
|
17
17
|
fetch?: typeof fetch;
|
package/dist/browser.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// `new URL(..., import.meta.url)` references below resolve in a consumer's
|
|
15
15
|
// bundler without configuration where that bundler treats them as assets.
|
|
16
16
|
import { createBrowserIsolate } from './run/browser-isolate.js';
|
|
17
|
+
import { netRefusal } from './run/net-refusal.js';
|
|
17
18
|
function originOf(entry) {
|
|
18
19
|
try {
|
|
19
20
|
const url = new URL(entry);
|
|
@@ -82,7 +83,10 @@ export function createFetchNetProvider(options = {}) {
|
|
|
82
83
|
const allowed = new Set((options.allowedOrigins ?? [])
|
|
83
84
|
.map(originOf)
|
|
84
85
|
.filter((origin) => origin !== undefined));
|
|
85
|
-
const
|
|
86
|
+
const build = options.denial ?? ((message) => new Error(message));
|
|
87
|
+
// Every policy refusal is marked so the net bridge can tell it from a
|
|
88
|
+
// transport failure (a plain `Error`).
|
|
89
|
+
const denial = (message) => netRefusal(build(message));
|
|
86
90
|
return (netAllowlist, maxFetchBytes, _policy, signal) => {
|
|
87
91
|
// The run's granted hosts, re-checked here on the page side: the worker
|
|
88
92
|
// checks them too, but the worker is the isolate a hostile script runs
|
|
@@ -110,7 +114,7 @@ export function createFetchNetProvider(options = {}) {
|
|
|
110
114
|
throw denial(`origin "${url.origin}" is not on the host's allowlist`);
|
|
111
115
|
}
|
|
112
116
|
if (signal?.aborted)
|
|
113
|
-
throw
|
|
117
|
+
throw new Error('the run has ended');
|
|
114
118
|
const doFetch = options.fetch ?? fetch;
|
|
115
119
|
let response;
|
|
116
120
|
try {
|
|
@@ -126,7 +130,10 @@ export function createFetchNetProvider(options = {}) {
|
|
|
126
130
|
});
|
|
127
131
|
}
|
|
128
132
|
catch (error) {
|
|
129
|
-
|
|
133
|
+
// A transport failure (network down, connection refused, CORS,
|
|
134
|
+
// abort): an ordinary runtime failure with the reason, not a policy
|
|
135
|
+
// refusal.
|
|
136
|
+
throw new Error(`request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
130
137
|
}
|
|
131
138
|
if (response.type === 'opaqueredirect') {
|
|
132
139
|
throw denial('redirects are not followed in a browser');
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"outcomes": [
|
|
3
3
|
{ "kind": "opened" },
|
|
4
|
-
{
|
|
4
|
+
{
|
|
5
|
+
"kind": "installed",
|
|
6
|
+
"namespace": "ana",
|
|
7
|
+
"replaced": false,
|
|
8
|
+
"installedDirName": "ana"
|
|
9
|
+
},
|
|
5
10
|
{ "kind": "loaded", "namespaces": ["ana"] },
|
|
6
11
|
{ "kind": "installed", "namespace": "ana", "replaced": true }
|
|
7
12
|
],
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"outcomes": [
|
|
3
|
+
{
|
|
4
|
+
"kind": "exported",
|
|
5
|
+
"noteCount": 2,
|
|
6
|
+
"entries": ["index.html", "note.html", "second.html"]
|
|
7
|
+
}
|
|
8
|
+
],
|
|
9
|
+
"diagnostics": [
|
|
10
|
+
"Cascade export wrote",
|
|
11
|
+
"Exported",
|
|
12
|
+
"Rendered with the static engine because no pack components are loaded",
|
|
13
|
+
"Exported",
|
|
14
|
+
"Rendered with the static engine because no pack components are loaded"
|
|
15
|
+
],
|
|
16
|
+
"prompts": []
|
|
17
|
+
}
|
|
@@ -49,6 +49,7 @@ needs, is the runner's own `ACTION_CAPABILITY` map:
|
|
|
49
49
|
| `run` | `isolate` |
|
|
50
50
|
| `exportHtml` | `export-format` (`html`) |
|
|
51
51
|
| `exportAnsi` | `export-format` (`ansi`) |
|
|
52
|
+
| `exportLinkedSet` | `linked-set-export` (a `resolveLinkedSetTarget`) |
|
|
52
53
|
| `installPack` | `packs` (and an `installRoot`) |
|
|
53
54
|
| `loadPacks` | `packs` |
|
|
54
55
|
| `removePack` | `packs` (and an `installRoot` plus `removeFolder`) |
|
|
@@ -97,12 +98,12 @@ would have produced:
|
|
|
97
98
|
and persists exactly what it always does. Only the isolate that would
|
|
98
99
|
execute the script is faked.
|
|
99
100
|
- `requiresManualTier`: the run reports `tier-blocked` under any trigger
|
|
100
|
-
but `'manual'`, regardless of grants
|
|
101
|
+
but `'manual'`, regardless of grants, the same enforcement the real
|
|
101
102
|
sandbox applies to an effectful call under a read-only trigger.
|
|
102
103
|
|
|
103
104
|
Faking the isolate keeps this corpus fast and deterministic. It proves the
|
|
104
|
-
HOST-LEVEL plumbing
|
|
105
|
-
trigger forwarding
|
|
105
|
+
HOST-LEVEL plumbing, grant prompts, wording, capability declarations,
|
|
106
|
+
trigger forwarding, is identical across hosts. Real sandbox enforcement
|
|
106
107
|
(the tier gate, the net allowlist, the marshal boundary) is
|
|
107
108
|
`conformance/executor/`'s job and is not duplicated here.
|
|
108
109
|
|
|
@@ -137,9 +138,13 @@ coarse, matching each `kind`:
|
|
|
137
138
|
taxonomy only, never a raw message or a timing; an optional `traceOk`
|
|
138
139
|
compares the recorded run trace's `ok` flag.
|
|
139
140
|
- `exported`: `contains`/`excludes` are substrings checked against the
|
|
140
|
-
written file's content, never the whole document
|
|
141
|
-
already pin bytes.
|
|
142
|
-
|
|
141
|
+
written file's content, never the whole document, the render corpora
|
|
142
|
+
already pin bytes. `valueCount` is how many last-run values were baked
|
|
143
|
+
in, and `renderEngine` (`static` or `react`) is which engine drew the body.
|
|
144
|
+
A linked-set export is an `exported` outcome too: `noteCount` is how many
|
|
145
|
+
notes the archive holds, and `entries` is its sorted file names.
|
|
146
|
+
- `installed`: `namespace` and `replaced`, and `installedDirName`, the name
|
|
147
|
+
of the folder the pack was unzipped into.
|
|
143
148
|
- `declined`: `step` (`'consent'`/`'replace'`, or absent for the
|
|
144
149
|
scripts-disabled synthetic outcome) and `reasonContains`, a substring of
|
|
145
150
|
the user-facing reason.
|
|
@@ -159,7 +164,7 @@ coarse, matching each `kind`:
|
|
|
159
164
|
|
|
160
165
|
An action whose capability the adapter under test does not declare is
|
|
161
166
|
never diffed against `expected.json` at all (see "Capability handling"
|
|
162
|
-
below)
|
|
167
|
+
below), `expected.json` always describes the FULLY CAPABLE host's
|
|
163
168
|
correct behavior, even for a scenario where one real host today has
|
|
164
169
|
nothing to compare it to.
|
|
165
170
|
|
|
@@ -171,11 +176,11 @@ difference) without the corpus having to spell out three wordings for
|
|
|
171
176
|
one behavior. Diagnostics lines written by a capability decline itself
|
|
172
177
|
(`declined (<capability> unsupported on <host>): …`) are filtered out of
|
|
173
178
|
the actual list before this comparison, since that line exists only
|
|
174
|
-
because the capability was ABSENT
|
|
179
|
+
because the capability was ABSENT, it is not part of what
|
|
175
180
|
`expected.json` describes.
|
|
176
181
|
|
|
177
182
|
`prompts` is load-bearing: it proves all three hosts ask the same
|
|
178
|
-
questions, worded the same way, in the same order
|
|
183
|
+
questions, worded the same way, in the same order, the single most
|
|
179
184
|
security-relevant parity property in this batch.
|
|
180
185
|
|
|
181
186
|
## Capability handling (the crux)
|
|
@@ -215,7 +220,7 @@ rather than a skipped test or a faked empty answer.
|
|
|
215
220
|
| `14-remove-pack` | removing an installed pack deletes it; removing it again reports it is not installed |
|
|
216
221
|
| `15-load-packs-engine-views` | loading packs reports every installed component and, as `unavailable`, the ones this host's engine has no view for |
|
|
217
222
|
| `16-pack-trust` | a folder that arrives without consent is listed as present and never loaded; enable, disable, re-enable and remove move the device trust list; a host without the list reports the whole scenario `unsupported` |
|
|
223
|
+
| `17-export-linked-set` | a note and the notes it links to export as one archive with an index page; a host with no linked-set target reports `unsupported` |
|
|
218
224
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
`unsupported` on every host today (a reported gap, not a corpus defect).
|
|
225
|
+
The capabilities an adapter declares decide which scenarios report
|
|
226
|
+
`unsupported` on that host. That is a reported gap and not a corpus defect.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place a render-time diagnostic event becomes a line on a host's
|
|
3
|
-
* diagnostics surface (docs/
|
|
3
|
+
* diagnostics surface (docs/hosts.md's host checklist, item 9).
|
|
4
4
|
*
|
|
5
5
|
* Both renderers report a quiet in-page marker through their `onDiagnostic`
|
|
6
6
|
* render option (`@markii/stdlib`'s `DiagnosticEvent`). The marker alone
|
|
@@ -71,7 +71,7 @@ export declare const EXPORT_HIDE_SCRIPT_BLOCKS_CLASS = "mk-export--hide-scripts"
|
|
|
71
71
|
export declare const EXPORT_PAGE_CSS = "\nbody {\n margin: 0;\n background: #fff;\n}\n.doc {\n box-sizing: border-box;\n max-width: 46rem;\n margin: 0 auto;\n padding: 2.5rem 1.5rem 4rem;\n}\n.doc.mk-export--hide-scripts .mk-script {\n display: none;\n}\n@page {\n margin: 2cm;\n}\n@media print {\n .doc {\n max-width: none;\n margin: 0;\n padding: 0;\n }\n .doc .mk-card,\n .doc .mk-callout,\n .doc .mk-stat,\n .doc .mk-figure,\n .doc .mk-unknown,\n .doc table {\n break-inside: avoid;\n }\n /* A collapsed `details` prints as its summary alone, which loses the\n content the author wrote. On paper there is nothing to expand, so\n every disclosure prints open. */\n .doc details > *:not(summary) {\n display: block;\n }\n .doc .mk-script,\n .doc .mk-preview__run-marker {\n display: none;\n }\n}\n";
|
|
72
72
|
/**
|
|
73
73
|
* The static HTML engine, injected so this module imports no renderer.
|
|
74
|
-
* A host passes `createHtmlExportEngine()` from `@markii/host/
|
|
74
|
+
* A host passes `createHtmlExportEngine()` from `@markii/host/html-engine`, which
|
|
75
75
|
* wraps `@markii/html`; a test may pass a stub.
|
|
76
76
|
*/
|
|
77
77
|
export interface HtmlExportEngine {
|
package/dist/host/adapter.d.ts
CHANGED
|
@@ -150,7 +150,7 @@ export interface HostPackSource {
|
|
|
150
150
|
* `authorizedFolders` names.
|
|
151
151
|
*/
|
|
152
152
|
bundledPacks?(): readonly DiscoveredPack[];
|
|
153
|
-
/** Removes an installed pack folder. Required when `installRoot` is present. */
|
|
153
|
+
/** Removes an installed pack folder and everything under it. Required when `installRoot` is present: a replacing install clears the old folder through it, and removing a pack uses it. */
|
|
154
154
|
removeFolder?(path: string): Promise<void>;
|
|
155
155
|
}
|
|
156
156
|
/** Every export format any host offers today. */
|
|
@@ -161,6 +161,14 @@ export interface HostExportCapabilities {
|
|
|
161
161
|
resolveTarget(suggestedFileName: string, format: HostExportFormat): Promise<string | undefined>;
|
|
162
162
|
/** Only present on a host declaring `'pdf'`. The one Electron-touching seam. */
|
|
163
163
|
htmlToPdf?(html: string): Promise<Uint8Array>;
|
|
164
|
+
/**
|
|
165
|
+
* Names the zip archive of a linked-set export, or resolves `undefined`
|
|
166
|
+
* when the user cancels. Present only on a host that offers the
|
|
167
|
+
* linked-set export; a host can offer single-note export without it
|
|
168
|
+
* (the command line tool does), so it is a member of its own rather than
|
|
169
|
+
* a `HostExportFormat`.
|
|
170
|
+
*/
|
|
171
|
+
resolveLinkedSetTarget?(suggestedFileName: string): Promise<string | undefined>;
|
|
164
172
|
}
|
|
165
173
|
/**
|
|
166
174
|
* The wording nouns every shared behavior's message templates interpolate
|
|
@@ -194,10 +202,12 @@ export interface HostLabels {
|
|
|
194
202
|
*.
|
|
195
203
|
*/
|
|
196
204
|
export interface HostAdapter {
|
|
197
|
-
|
|
205
|
+
/** A short name for the host, used in diagnostics wording. */
|
|
206
|
+
readonly id: string;
|
|
198
207
|
readonly labels: HostLabels;
|
|
199
208
|
readFile(path: string): Promise<Uint8Array>;
|
|
200
209
|
exists(path: string): Promise<boolean>;
|
|
210
|
+
/** Writes `bytes` to `path` and creates any missing parent folder first. Exports and pack installs both write through it. */
|
|
201
211
|
writeFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
202
212
|
/** A missing folder resolves to an empty list — never throws, matching the existing `PackDirectoryLister` contract. */
|
|
203
213
|
listFolder(path: string): Promise<readonly HostDirEntry[]>;
|
|
@@ -208,7 +218,7 @@ export interface HostAdapter {
|
|
|
208
218
|
* through with no adapter wrapper at all.
|
|
209
219
|
*/
|
|
210
220
|
readonly memento: GrantMemento;
|
|
211
|
-
/** One diagnostics line on this host's designated surface (docs/
|
|
221
|
+
/** One diagnostics line on this host's designated surface (docs/hosts.md). */
|
|
212
222
|
diagnostics(line: string): void;
|
|
213
223
|
now(): number;
|
|
214
224
|
readonly isolate?: HostIsolate;
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* produces. Written once so the hosts cannot drift on what a cascade is
|
|
6
6
|
* or what it says.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* `createMarkiiHost`'s `exportLinkedSet` drives it: the host's picker
|
|
9
|
+
* names the archive's path (`adapter.exports.resolveLinkedSetTarget`), the
|
|
10
|
+
* notes are read through `adapter.readFile`, the archive is written through
|
|
11
|
+
* `adapter.writeFile`, and the diagnostics lines go to `adapter.diagnostics`.
|
|
12
|
+
* The per-host nouns (which surface holds the details) come from
|
|
13
|
+
* `HostLabels`, never from a literal here.
|
|
13
14
|
*
|
|
14
15
|
* BOUNDS ARE FIXED. The walk always uses `DEFAULT_CASCADE_MAX_DEPTH` and
|
|
15
16
|
* `DEFAULT_CASCADE_MAX_NOTES`; nothing here lets a caller override them.
|
|
@@ -19,7 +20,8 @@ import type { OnDiagnostic, UnavailableComponent } from '@markii/stdlib';
|
|
|
19
20
|
import type { CascadeLinkResolver, CascadeNoteReader, CascadeTruncation } from '../export/cascade.js';
|
|
20
21
|
import type { HtmlExportEngine, ExportBodyRenderer, ExportPackStylesheet, ExportRenderInfo, StaticExportReason } from '../export/note-export.js';
|
|
21
22
|
import type { EmbeddedImageReport, ExportImageReader } from '../export/image-embed.js';
|
|
22
|
-
import type { HostLabels } from './adapter.js';
|
|
23
|
+
import type { HostAdapter, HostLabels } from './adapter.js';
|
|
24
|
+
import type { ExportLinkedSetOutcome } from './create-host.js';
|
|
23
25
|
/** One note the cascade exported, for the diagnostics surface. */
|
|
24
26
|
export interface CascadeExportedNote {
|
|
25
27
|
/** The note's own path, in the same form the reader and resolver use. */
|
|
@@ -37,7 +39,7 @@ export interface UnreadableCascadeNote {
|
|
|
37
39
|
}
|
|
38
40
|
/** What one cascade export attempt did, for both the message and the diagnostics lines. */
|
|
39
41
|
export type CascadeExportOutcome = {
|
|
40
|
-
readonly kind: '
|
|
42
|
+
readonly kind: 'exported';
|
|
41
43
|
/** The written archive's display path. */
|
|
42
44
|
readonly path: string;
|
|
43
45
|
readonly bytes: number;
|
|
@@ -109,7 +111,7 @@ export declare function buildCascadeArchive(request: CascadeArchiveRequest): Pro
|
|
|
109
111
|
* failure says what failed and points at the same surface, never at a
|
|
110
112
|
* stack trace.
|
|
111
113
|
*/
|
|
112
|
-
export declare function cascadeResultMessage(outcome:
|
|
114
|
+
export declare function cascadeResultMessage(outcome: ExportLinkedSetOutcome, labels: HostLabels, where?: string): string;
|
|
113
115
|
/**
|
|
114
116
|
* The lines for the host's diagnostics surface for one cascade export:
|
|
115
117
|
* every note reached named with the file it became, every note skipped
|
|
@@ -118,3 +120,17 @@ export declare function cascadeResultMessage(outcome: CascadeExportOutcome, labe
|
|
|
118
120
|
* deliberately omits.
|
|
119
121
|
*/
|
|
120
122
|
export declare function cascadeDiagnosticLines(outcome: CascadeExportOutcome): string[];
|
|
123
|
+
/** What `exportLinkedSetViaAdapter` needs beyond the adapter: the static engine and the request's own seams. */
|
|
124
|
+
export interface LinkedSetExportInputs extends Omit<CascadeArchiveRequest, 'readNote'> {
|
|
125
|
+
/** The suggested archive file name handed to the host's picker. */
|
|
126
|
+
readonly suggestedFileName: string;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Exports a note and the notes it links to as one archive: resolves the
|
|
130
|
+
* target through the adapter's picker, builds the archive reading every
|
|
131
|
+
* note through `adapter.readFile`, writes it through `adapter.writeFile`,
|
|
132
|
+
* and sends the diagnostics lines to `adapter.diagnostics`. Resolves
|
|
133
|
+
* `undefined` when the host has no linked-set target or its picker was
|
|
134
|
+
* cancelled. Never throws.
|
|
135
|
+
*/
|
|
136
|
+
export declare function exportLinkedSetViaAdapter(adapter: HostAdapter, inputs: LinkedSetExportInputs): Promise<CascadeExportOutcome | undefined>;
|
|
@@ -109,7 +109,7 @@ export async function buildCascadeArchive(request) {
|
|
|
109
109
|
* stack trace.
|
|
110
110
|
*/
|
|
111
111
|
export function cascadeResultMessage(outcome, labels, where) {
|
|
112
|
-
if (outcome.kind
|
|
112
|
+
if (outcome.kind !== 'exported') {
|
|
113
113
|
return `Markii: could not export this cascade. Open ${labels.diagnosticsSurface} for details.`;
|
|
114
114
|
}
|
|
115
115
|
const name = fileNameOf(outcome.path);
|
|
@@ -149,3 +149,63 @@ export function cascadeDiagnosticLines(outcome) {
|
|
|
149
149
|
}
|
|
150
150
|
return lines;
|
|
151
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Exports a note and the notes it links to as one archive: resolves the
|
|
154
|
+
* target through the adapter's picker, builds the archive reading every
|
|
155
|
+
* note through `adapter.readFile`, writes it through `adapter.writeFile`,
|
|
156
|
+
* and sends the diagnostics lines to `adapter.diagnostics`. Resolves
|
|
157
|
+
* `undefined` when the host has no linked-set target or its picker was
|
|
158
|
+
* cancelled. Never throws.
|
|
159
|
+
*/
|
|
160
|
+
export async function exportLinkedSetViaAdapter(adapter, inputs) {
|
|
161
|
+
const resolveTarget = adapter.exports?.resolveLinkedSetTarget;
|
|
162
|
+
if (resolveTarget === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
const { suggestedFileName, ...archiveRequest } = inputs;
|
|
165
|
+
let path;
|
|
166
|
+
let outcome;
|
|
167
|
+
try {
|
|
168
|
+
path = await resolveTarget.call(adapter.exports, suggestedFileName);
|
|
169
|
+
if (path === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
const target = path;
|
|
172
|
+
const archive = await buildCascadeArchive({
|
|
173
|
+
...archiveRequest,
|
|
174
|
+
readNote: async (notePath) => {
|
|
175
|
+
try {
|
|
176
|
+
return new TextDecoder().decode(await adapter.readFile(notePath));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
if (archive.kind === 'failed') {
|
|
184
|
+
outcome = { kind: 'failed', path: target, reason: archive.reason };
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
await adapter.writeFile(target, archive.bytes);
|
|
188
|
+
outcome = {
|
|
189
|
+
kind: 'exported',
|
|
190
|
+
path: target,
|
|
191
|
+
bytes: archive.bytes.byteLength,
|
|
192
|
+
notes: archive.notes,
|
|
193
|
+
unreadable: archive.unreadable,
|
|
194
|
+
...(archive.truncated !== undefined
|
|
195
|
+
? { truncated: archive.truncated }
|
|
196
|
+
: {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
outcome = {
|
|
202
|
+
kind: 'failed',
|
|
203
|
+
...(path !== undefined ? { path } : {}),
|
|
204
|
+
reason: cascadeReasonOf(error),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
for (const line of cascadeDiagnosticLines(outcome)) {
|
|
208
|
+
adapter.diagnostics(line);
|
|
209
|
+
}
|
|
210
|
+
return outcome;
|
|
211
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { ArchiveExtractFs } from './pack-archive.js';
|
|
2
1
|
import type { RunTrigger } from '@markii/runtime';
|
|
3
2
|
import type { HostAdapter } from './adapter.js';
|
|
4
3
|
/**
|
|
@@ -36,6 +35,8 @@ export type HostScenarioAction = {
|
|
|
36
35
|
readonly type: 'exportHtml';
|
|
37
36
|
} | {
|
|
38
37
|
readonly type: 'exportAnsi';
|
|
38
|
+
} | {
|
|
39
|
+
readonly type: 'exportLinkedSet';
|
|
39
40
|
} | {
|
|
40
41
|
readonly type: 'installPack';
|
|
41
42
|
/** A directory under the scenario's own folder (e.g. `"files/ana-1.0.0"`), zipped into `.mkp` bytes by this module — never a committed binary. */
|
|
@@ -99,7 +100,7 @@ export interface LoadedHostScenario {
|
|
|
99
100
|
export declare function loadHostScenario(name: string, baseDir?: string): LoadedHostScenario;
|
|
100
101
|
/** 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. */
|
|
101
102
|
export declare function zipArchiveDirectory(archiveDir: string): Uint8Array;
|
|
102
|
-
export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format';
|
|
103
|
+
export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format' | 'linked-set-export';
|
|
103
104
|
/** 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). */
|
|
104
105
|
export declare const ACTION_CAPABILITY: Readonly<Record<HostScenarioAction['type'], HostScenarioCapability | undefined>>;
|
|
105
106
|
/** Whether `adapter` declares the capability `action` needs. Never consults the scenario or `expected.json` — only the adapter under test. */
|
|
@@ -135,8 +136,6 @@ export interface HostScenarioRunResult {
|
|
|
135
136
|
*/
|
|
136
137
|
export declare function runHostScenario(adapter: HostAdapter, scenario: HostScenario, noteText: string, scenarioDir: string, options?: {
|
|
137
138
|
readonly renderAnsi?: (text: string) => Promise<string>;
|
|
138
|
-
/** Where an installed pack archive is extracted. Defaults to the Node file system, which is right for an adapter that reads and writes real files. An adapter over another store (memory, a virtual file system) passes the matching writer. */
|
|
139
|
-
readonly archiveExtractFs?: ArchiveExtractFs;
|
|
140
139
|
}): Promise<HostScenarioRunResult>;
|
|
141
140
|
/** `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. */
|
|
142
141
|
export declare function containsInOrder(actual: readonly string[], expected: readonly string[]): string[];
|
|
@@ -37,13 +37,12 @@
|
|
|
37
37
|
* Every OTHER action runs through the real, unmocked shared behavior code.
|
|
38
38
|
*/
|
|
39
39
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
40
|
-
import { dirname } from 'node:path';
|
|
40
|
+
import { dirname, resolve } from 'node:path';
|
|
41
41
|
import { fileURLToPath } from 'node:url';
|
|
42
42
|
import { createHtmlExportEngine } from '../html-engine.js';
|
|
43
|
-
import { createNodeArchiveExtractFs } from '../node-fs.js';
|
|
44
43
|
import { usesPackTrust } from './pack-trust-behavior.js';
|
|
45
44
|
import { join, relative, sep } from 'node:path';
|
|
46
|
-
import { zipSync } from 'fflate';
|
|
45
|
+
import { unzipSync, zipSync } from 'fflate';
|
|
47
46
|
import { conformanceDir } from '@markii/core/corpus';
|
|
48
47
|
import { createMarkiiHost, } from './create-host.js';
|
|
49
48
|
import { scriptsDisabledDiagnosticLine, scriptsDisabledNotice, } from './script-execution.js';
|
|
@@ -102,6 +101,7 @@ export const ACTION_CAPABILITY = {
|
|
|
102
101
|
run: 'isolate',
|
|
103
102
|
exportHtml: 'export-format',
|
|
104
103
|
exportAnsi: 'export-format',
|
|
104
|
+
exportLinkedSet: 'linked-set-export',
|
|
105
105
|
installPack: 'packs',
|
|
106
106
|
loadPacks: 'packs',
|
|
107
107
|
removePack: 'packs',
|
|
@@ -137,13 +137,13 @@ export function hostDeclaresCapability(adapter, action) {
|
|
|
137
137
|
return adapter.isolate !== undefined;
|
|
138
138
|
if (capability === 'editor')
|
|
139
139
|
return adapter.editor !== undefined;
|
|
140
|
+
if (capability === 'linked-set-export') {
|
|
141
|
+
return adapter.exports?.resolveLinkedSetTarget !== undefined;
|
|
142
|
+
}
|
|
140
143
|
if (capability === 'packs') {
|
|
141
144
|
if (adapter.packs === undefined)
|
|
142
145
|
return false;
|
|
143
|
-
if (action.type === 'installPack') {
|
|
144
|
-
return adapter.packs.installRoot !== undefined;
|
|
145
|
-
}
|
|
146
|
-
if (action.type === 'removePack') {
|
|
146
|
+
if (action.type === 'installPack' || action.type === 'removePack') {
|
|
147
147
|
return (adapter.packs.installRoot !== undefined &&
|
|
148
148
|
adapter.packs.removeFolder !== undefined);
|
|
149
149
|
}
|
|
@@ -262,7 +262,6 @@ export async function runHostScenario(adapter, scenario, noteText, scenarioDir,
|
|
|
262
262
|
// The runner is Node-only test tooling, so it supplies the real Node
|
|
263
263
|
// implementations of the seams the host layer takes by injection.
|
|
264
264
|
htmlEngine: createHtmlExportEngine(),
|
|
265
|
-
archiveExtractFs: options.archiveExtractFs ?? createNodeArchiveExtractFs(),
|
|
266
265
|
spawnRun: (spawnOptions) => Promise.resolve(simulatedRunResult(currentSimulation, spawnOptions)),
|
|
267
266
|
...(options.renderAnsi ? { renderAnsi: options.renderAnsi } : {}),
|
|
268
267
|
});
|
|
@@ -334,6 +333,24 @@ async function runOneAction(host, adapter, action, ctx) {
|
|
|
334
333
|
});
|
|
335
334
|
return augmentExportOutcome(outcome, ctx.writtenFiles);
|
|
336
335
|
}
|
|
336
|
+
case 'exportLinkedSet': {
|
|
337
|
+
const outcome = await host.exportLinkedSet({
|
|
338
|
+
rootPath: ctx.notePath,
|
|
339
|
+
// A markdown link to a note file that exists beside the linking
|
|
340
|
+
// note: the same rule for every host, so the archive's contents
|
|
341
|
+
// do not depend on which host runs the scenario.
|
|
342
|
+
resolveLink: (link, fromNotePath) => {
|
|
343
|
+
if (link.kind !== 'markdown')
|
|
344
|
+
return undefined;
|
|
345
|
+
const target = resolve(dirname(fromNotePath), link.path);
|
|
346
|
+
return target.endsWith('.md') && existsSync(target)
|
|
347
|
+
? target
|
|
348
|
+
: undefined;
|
|
349
|
+
},
|
|
350
|
+
readValues: () => ({}),
|
|
351
|
+
});
|
|
352
|
+
return augmentLinkedSetOutcome(outcome, ctx.writtenFiles);
|
|
353
|
+
}
|
|
337
354
|
case 'installPack': {
|
|
338
355
|
const archiveBytes = zipArchiveDirectory(join(ctx.scenarioDir, action.archive));
|
|
339
356
|
return host.installPack({ archiveBytes, archivePath: action.archive });
|
|
@@ -366,6 +383,13 @@ async function runOneAction(host, adapter, action, ctx) {
|
|
|
366
383
|
return host.insertComponent({ name: action.name });
|
|
367
384
|
}
|
|
368
385
|
}
|
|
386
|
+
function augmentLinkedSetOutcome(outcome, writtenFiles) {
|
|
387
|
+
if (outcome.kind !== 'exported')
|
|
388
|
+
return outcome;
|
|
389
|
+
const bytes = writtenFiles.get(outcome.path);
|
|
390
|
+
const entries = bytes === undefined ? undefined : Object.keys(unzipSync(bytes)).sort();
|
|
391
|
+
return { ...outcome, noteCount: outcome.notes.length, entries };
|
|
392
|
+
}
|
|
369
393
|
function augmentExportOutcome(outcome, writtenFiles) {
|
|
370
394
|
if (outcome.kind !== 'exported')
|
|
371
395
|
return outcome;
|
|
@@ -452,6 +476,25 @@ export function outcomeMismatches(actual, expected) {
|
|
|
452
476
|
break;
|
|
453
477
|
}
|
|
454
478
|
case 'exported': {
|
|
479
|
+
if (expected['valueCount'] !== undefined &&
|
|
480
|
+
actualRecord['valueCount'] !== expected['valueCount']) {
|
|
481
|
+
mismatches.push(`valueCount: expected ${String(expected['valueCount'])}, got ${String(actualRecord['valueCount'])}`);
|
|
482
|
+
}
|
|
483
|
+
if (expected['renderEngine'] !== undefined) {
|
|
484
|
+
const render = actualRecord['render'];
|
|
485
|
+
if (render?.engine !== expected['renderEngine']) {
|
|
486
|
+
mismatches.push(`render engine: expected "${String(expected['renderEngine'])}", got "${String(render?.engine)}"`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (expected['noteCount'] !== undefined &&
|
|
490
|
+
actualRecord['noteCount'] !== expected['noteCount']) {
|
|
491
|
+
mismatches.push(`noteCount: expected ${String(expected['noteCount'])}, got ${String(actualRecord['noteCount'])}`);
|
|
492
|
+
}
|
|
493
|
+
if (expected['entries'] !== undefined &&
|
|
494
|
+
JSON.stringify(actualRecord['entries']) !==
|
|
495
|
+
JSON.stringify(expected['entries'])) {
|
|
496
|
+
mismatches.push(`archive entries: expected ${JSON.stringify(expected['entries'])}, got ${JSON.stringify(actualRecord['entries'])}`);
|
|
497
|
+
}
|
|
455
498
|
const content = String(actualRecord['content'] ?? '');
|
|
456
499
|
for (const needle of expected['contains'] ?? []) {
|
|
457
500
|
if (!content.includes(needle)) {
|
|
@@ -466,6 +509,13 @@ export function outcomeMismatches(actual, expected) {
|
|
|
466
509
|
break;
|
|
467
510
|
}
|
|
468
511
|
case 'installed': {
|
|
512
|
+
if (expected['installedDirName'] !== undefined) {
|
|
513
|
+
const dir = String(actualRecord['installedDir'] ?? '');
|
|
514
|
+
const name = dir.split(/[/\\]/).pop();
|
|
515
|
+
if (name !== expected['installedDirName']) {
|
|
516
|
+
mismatches.push(`installedDir: expected a folder named "${String(expected['installedDirName'])}", got "${dir}"`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
469
519
|
if (expected['namespace'] !== undefined &&
|
|
470
520
|
actualRecord['namespace'] !== expected['namespace']) {
|
|
471
521
|
mismatches.push(`namespace: expected "${String(expected['namespace'])}", got "${String(actualRecord['namespace'])}"`);
|