@open-agent-toolkit/cli 0.2.20 → 0.2.21
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/assets/docs/workflows/projects/artifacts.md +1 -1
- package/assets/docs/workflows/skills/explainer-kit.md +188 -23
- package/assets/docs/workflows/skills/index.md +1 -1
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +87 -25
- package/assets/skills/explainer-kit/briefs/deep-dive.md +35 -0
- package/assets/skills/explainer-kit/briefs/engineer-tour.md +45 -0
- package/assets/skills/explainer-kit/briefs/program-recap.md +41 -0
- package/assets/skills/explainer-kit/briefs/project-explainer.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-page.md +38 -0
- package/assets/skills/explainer-kit/briefs/project-recap.md +47 -0
- package/assets/skills/explainer-kit/briefs/supporting-diagram.md +28 -0
- package/assets/skills/explainer-kit/briefs/walkthrough-deck.md +35 -0
- package/assets/skills/explainer-kit/examples/project-recap/content.md +57 -14
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.json +104 -0
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +26 -4
- package/assets/skills/explainer-kit/recipes/engineer-tour.json +27 -10
- package/assets/skills/explainer-kit/recipes/program-recap.json +35 -11
- package/assets/skills/explainer-kit/recipes/project-explainer.json +27 -10
- package/assets/skills/explainer-kit/recipes/project-recap.json +43 -11
- package/assets/skills/explainer-kit/references/contracts.md +45 -19
- package/assets/skills/explainer-kit/schemas/author-request.v2.schema.json +41 -0
- package/assets/skills/explainer-kit/schemas/author-result.v2.schema.json +52 -0
- package/assets/skills/explainer-kit/scripts/lib/browser-runtime.mjs +442 -0
- package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +223 -10
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +28 -43
- package/assets/skills/explainer-kit/scripts/lib/diagram.mjs +237 -0
- package/assets/skills/explainer-kit/scripts/lib/html-safety.mjs +680 -0
- package/assets/skills/explainer-kit/scripts/lib/markdown.mjs +414 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +309 -10
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +313 -41
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +61 -0
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +166 -12
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +147 -2
- package/assets/skills/explainer-kit/scripts/run.mjs +796 -272
- package/assets/skills/explainer-kit/templates/deck-shell.html +25 -5
- package/assets/skills/explainer-kit/templates/diagram-shell.html +29 -7
- package/assets/skills/explainer-kit/templates/engineer-tour.html +133 -9
- package/assets/skills/explainer-kit/templates/house-style.html +82 -0
- package/assets/skills/oat-explainer-kit/SKILL.md +16 -9
- package/assets/skills/oat-explainer-kit/references/author-callback.md +51 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +10 -8
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +14 -0
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +7 -11
- package/assets/skills/oat-project-complete/SKILL.md +18 -2
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +7 -1
- package/assets/skills/oat-wave-execute/SKILL.md +12 -19
- package/assets/skills/oat-wave-program/SKILL.md +12 -13
- package/package.json +2 -2
- package/assets/skills/explainer-kit/schemas/author-request.schema.json +0 -85
- package/assets/skills/explainer-kit/schemas/author-result.schema.json +0 -65
|
@@ -30,13 +30,25 @@ claims, sources, overrides, and the freshness policy. It returns:
|
|
|
30
30
|
No provider name, command, credential, or dispatch protocol is part of this
|
|
31
31
|
contract.
|
|
32
32
|
|
|
33
|
-
Every unattended
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
Every run, interactive or unattended, also requires a provider-neutral author
|
|
34
|
+
callback; a run without one fails `E_AUTHOR_REQUIRED`. An in-process caller
|
|
35
|
+
supplies `options.author(request)`; a JSON-only CLI caller uses
|
|
36
|
+
`--author-module author.mjs`. The core invokes it once per resolved artifact
|
|
37
|
+
with an `explainer-kit.author-request/v2` payload containing the artifact
|
|
38
|
+
identity and type, the artifact's authoring path, the inlined brief, the
|
|
39
|
+
reconciled fact base, the resolved theme, the shell source for artistic
|
|
40
|
+
artifacts, the required narrative sections for narrative floor artifacts, and
|
|
41
|
+
bounded-discovery context. It must return an `explainer-kit.author-result/v2`
|
|
42
|
+
carrying exactly one of `content.markdown` or `content.html`, matching the
|
|
43
|
+
artifact's declared authoring path, plus non-secret provenance. A floor result
|
|
44
|
+
may also carry `proposedArtifacts` of `{id, profileId, rationale}`; the
|
|
45
|
+
referenced expansion profile supplies the type, authoring path, brief, and
|
|
46
|
+
shell. The executable callback is never persisted in `run-request.json`.
|
|
47
|
+
|
|
48
|
+
Unknown profile IDs and unsafe, duplicate, or floor-colliding artifact IDs are
|
|
49
|
+
hard errors. Proposals beyond a profile's `maxCount` or the recipe's
|
|
50
|
+
`expansion.limits.maxArtifacts` are rejected with a warning and the run
|
|
51
|
+
continues.
|
|
40
52
|
|
|
41
53
|
## Explicit source forms
|
|
42
54
|
|
|
@@ -57,7 +69,8 @@ provenance. The executable callback is never persisted in `run-request.json`.
|
|
|
57
69
|
An unattended request asserts that its explicit source artifacts are already
|
|
58
70
|
approved. It does not prompt, and it fails before narrative serialization when
|
|
59
71
|
the author is absent, returns an invalid result, or copies excessive verbatim
|
|
60
|
-
source text.
|
|
72
|
+
source text. It auto-approves with `auto-drafted` marking once the artifacts are
|
|
73
|
+
built and checked.
|
|
61
74
|
|
|
62
75
|
## Pipeline and retained package
|
|
63
76
|
|
|
@@ -65,19 +78,30 @@ The core executes:
|
|
|
65
78
|
|
|
66
79
|
1. validate request and recipe
|
|
67
80
|
2. reconcile or check the fact base
|
|
68
|
-
3. apply bounded discovery
|
|
81
|
+
3. apply bounded discovery
|
|
69
82
|
4. resolve one theme
|
|
70
|
-
5.
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
5. author each floor artifact against its brief, evaluate expansion proposals,
|
|
84
|
+
and author each accepted expansion artifact
|
|
85
|
+
6. render typed artifacts through the narrative renderer or validate
|
|
86
|
+
agent-composed HTML, per each artifact's declared authoring path
|
|
87
|
+
7. run structural, guideline, and optional browser QA
|
|
88
|
+
8. resolve content approval — the interactive gate pauses here, after render and
|
|
89
|
+
QA and before anything is published or persisted externally
|
|
90
|
+
9. write the manifest and build record
|
|
73
91
|
|
|
74
92
|
The run package retains the privacy-safe `run-request.json`,
|
|
75
93
|
`source/content-approval.json`, `source/fact-base.json`,
|
|
76
|
-
`source/fact-base.md`, `source/content/*.md
|
|
77
|
-
`source/
|
|
78
|
-
`
|
|
79
|
-
|
|
80
|
-
|
|
94
|
+
`source/fact-base.md`, authored content under `source/content/*.md` and
|
|
95
|
+
`source/content/*.html`, structured `source/author/*.json` results,
|
|
96
|
+
`theme.resolved.json`, rendered `site/` files, `manifest.json`, and
|
|
97
|
+
`build-record.json` as far as each stage succeeds. A stage failure records a
|
|
98
|
+
structured error and recovery action without deleting earlier outputs. Raw art
|
|
99
|
+
direction is omitted unless the request explicitly opts in.
|
|
100
|
+
|
|
101
|
+
Build-record stages are terminal once `passed` or `warned`. A rejected run that
|
|
102
|
+
is later approved reopens the render and QA stages through a narrowly guarded
|
|
103
|
+
record-level reset so the corrected sources are re-rendered and re-validated,
|
|
104
|
+
leaving an auditable trail rather than approving stale artifacts.
|
|
81
105
|
|
|
82
106
|
When a caller supplies `discover({ round, recipe, factBase })`, the callback
|
|
83
107
|
returns the findings added in that round. The core stops after two consecutive
|
|
@@ -93,7 +117,7 @@ Durability and publishing are never implicit.
|
|
|
93
117
|
`record-durability.mjs`; the core never creates commits. The first evidence
|
|
94
118
|
commit must contain every path and byte hash in `manifest.immutableHashes`:
|
|
95
119
|
the privacy-safe request, content approval, fact-base JSON and Markdown,
|
|
96
|
-
declared author results, all content
|
|
120
|
+
declared author results, all authored content, the resolved theme, and every
|
|
97
121
|
built artifact retained in the package. Mutable `manifest.json` and
|
|
98
122
|
`build-record.json` remain excluded for the separate evidence update.
|
|
99
123
|
Schema-v1 manifests created before complete-package coverage are rejected
|
|
@@ -110,7 +134,9 @@ verified. Publishing remains independently human-gated by the caller.
|
|
|
110
134
|
## Result
|
|
111
135
|
|
|
112
136
|
`runExplainer` returns the run root, manifest path, build-record path, outcome,
|
|
113
|
-
warnings, and bounded-discovery summary.
|
|
137
|
+
warnings, approval status and marking, and bounded-discovery summary. The
|
|
138
|
+
marking rides in the result and the approval record only; `manifest/v1` stays
|
|
139
|
+
frozen and carries no marking field. Input validation and unsupported
|
|
114
140
|
recipes reject before output mutation. Failures after initialization return a
|
|
115
141
|
`failed` result with paths to the retained record and intermediates.
|
|
116
142
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "explainer-kit.author-request/v2",
|
|
4
|
+
"title": "AuthorRequestV2",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"artifactId",
|
|
10
|
+
"artifactType",
|
|
11
|
+
"authoring",
|
|
12
|
+
"brief",
|
|
13
|
+
"factBase",
|
|
14
|
+
"theme"
|
|
15
|
+
],
|
|
16
|
+
"properties": {
|
|
17
|
+
"schemaVersion": { "const": "explainer-kit.author-request/v2" },
|
|
18
|
+
"artifactId": { "type": "string", "minLength": 1 },
|
|
19
|
+
"artifactType": {
|
|
20
|
+
"enum": ["hub", "diagram", "explainer", "deck", "catalog"]
|
|
21
|
+
},
|
|
22
|
+
"authoring": { "enum": ["markdown", "html"] },
|
|
23
|
+
"brief": { "type": "string", "minLength": 1 },
|
|
24
|
+
"factBase": { "$ref": "explainer-kit.fact-base/v1" },
|
|
25
|
+
"shell": { "type": "string", "minLength": 1 },
|
|
26
|
+
"theme": { "$ref": "explainer-kit.theme/v1" },
|
|
27
|
+
"floor": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"required": ["requiredNarrative"],
|
|
31
|
+
"properties": {
|
|
32
|
+
"requiredNarrative": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": { "type": "string", "minLength": 1 },
|
|
35
|
+
"minItems": 1,
|
|
36
|
+
"uniqueItems": true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "explainer-kit.author-result/v2",
|
|
4
|
+
"title": "AuthorResultV2",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["schemaVersion", "artifactId", "content", "provenance"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"schemaVersion": { "const": "explainer-kit.author-result/v2" },
|
|
10
|
+
"artifactId": { "type": "string", "minLength": 1 },
|
|
11
|
+
"content": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"oneOf": [{ "required": ["markdown"] }, { "required": ["html"] }],
|
|
15
|
+
"properties": {
|
|
16
|
+
"markdown": { "type": "string", "minLength": 1 },
|
|
17
|
+
"html": { "type": "string", "minLength": 1 }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"provenance": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"additionalProperties": false,
|
|
23
|
+
"required": ["authorId", "generatedAt"],
|
|
24
|
+
"properties": {
|
|
25
|
+
"authorId": { "type": "string", "minLength": 1 },
|
|
26
|
+
"generatedAt": { "type": "string", "format": "date-time" },
|
|
27
|
+
"method": { "type": "string", "minLength": 1 },
|
|
28
|
+
"trust": {
|
|
29
|
+
"description": "Stamped by the core on the retained record only. An author callback that asserts it is rejected.",
|
|
30
|
+
"enum": ["caller-bound", "self-asserted"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"proposedArtifacts": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"additionalProperties": false,
|
|
39
|
+
"required": ["id", "profileId", "rationale"],
|
|
40
|
+
"properties": {
|
|
41
|
+
"id": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
44
|
+
},
|
|
45
|
+
"profileId": { "type": "string", "minLength": 1 },
|
|
46
|
+
"rationale": { "type": "string", "minLength": 1 }
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"uniqueItems": true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
|
|
5
|
+
// Locations a headless Chromium may already exist at on a developer or CI
|
|
6
|
+
// machine. The core never installs a browser; it only uses one that is there.
|
|
7
|
+
const CHROMIUM_CANDIDATES = Object.freeze([
|
|
8
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
9
|
+
'/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
10
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
11
|
+
'/usr/bin/google-chrome',
|
|
12
|
+
'/usr/bin/google-chrome-stable',
|
|
13
|
+
'/usr/bin/chromium',
|
|
14
|
+
'/usr/bin/chromium-browser',
|
|
15
|
+
]);
|
|
16
|
+
const PROBE_REQUEST_FIELDS = new Set([
|
|
17
|
+
'artifact',
|
|
18
|
+
'disableAnimations',
|
|
19
|
+
'evaluate',
|
|
20
|
+
'injectedCss',
|
|
21
|
+
'javascriptEnabled',
|
|
22
|
+
'keyboard',
|
|
23
|
+
'media',
|
|
24
|
+
'reducedMotion',
|
|
25
|
+
'scenario',
|
|
26
|
+
'themeToggle',
|
|
27
|
+
'viewport',
|
|
28
|
+
'wideContent',
|
|
29
|
+
]);
|
|
30
|
+
const DISABLE_ANIMATIONS_CSS = `*, *::before, *::after {
|
|
31
|
+
animation: none !important;
|
|
32
|
+
animation-duration: 0s !important;
|
|
33
|
+
transition: none !important;
|
|
34
|
+
transition-duration: 0s !important;
|
|
35
|
+
scroll-behavior: auto !important;
|
|
36
|
+
}`;
|
|
37
|
+
|
|
38
|
+
export const RUNTIME_UNAVAILABLE_REASONS = Object.freeze({
|
|
39
|
+
driverMissing: 'browser-driver-not-installed',
|
|
40
|
+
executableMissing: 'no-installed-chromium-executable',
|
|
41
|
+
disabled: 'disabled-by-configuration',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const HEADLESS_PROBE_ENV = 'EXPLAINER_KIT_HEADLESS_PROBE';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Operators may switch probe resolution off for hermetic unit runs. The opt-out
|
|
48
|
+
* is reported separately from a capability failure so it never reads as
|
|
49
|
+
* "no runtime exists here".
|
|
50
|
+
*/
|
|
51
|
+
export function headlessProbeDisabled(env = process.env) {
|
|
52
|
+
return ['off', 'false', '0'].includes(
|
|
53
|
+
String(env[HEADLESS_PROBE_ENV] ?? '').toLowerCase(),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Detect whether this machine can actually drive a headless browser. The
|
|
59
|
+
* driver import is dynamic so the bundled core stays installable without it.
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveHeadlessRuntime({
|
|
62
|
+
loadDriver = () => import('@playwright/test'),
|
|
63
|
+
fileExists = existsSync,
|
|
64
|
+
env = process.env,
|
|
65
|
+
} = {}) {
|
|
66
|
+
if (headlessProbeDisabled(env)) {
|
|
67
|
+
return { available: false, reason: RUNTIME_UNAVAILABLE_REASONS.disabled };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let chromium;
|
|
71
|
+
try {
|
|
72
|
+
({ chromium } = await loadDriver());
|
|
73
|
+
} catch {
|
|
74
|
+
return {
|
|
75
|
+
available: false,
|
|
76
|
+
reason: RUNTIME_UNAVAILABLE_REASONS.driverMissing,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
typeof chromium?.launch !== 'function' ||
|
|
81
|
+
typeof chromium?.executablePath !== 'function'
|
|
82
|
+
) {
|
|
83
|
+
return {
|
|
84
|
+
available: false,
|
|
85
|
+
reason: RUNTIME_UNAVAILABLE_REASONS.driverMissing,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let bundled;
|
|
90
|
+
try {
|
|
91
|
+
bundled = chromium.executablePath();
|
|
92
|
+
} catch {
|
|
93
|
+
bundled = undefined;
|
|
94
|
+
}
|
|
95
|
+
const executablePath = [
|
|
96
|
+
env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
|
97
|
+
bundled,
|
|
98
|
+
...CHROMIUM_CANDIDATES,
|
|
99
|
+
].find((candidate) => candidate && fileExists(candidate));
|
|
100
|
+
if (!executablePath) {
|
|
101
|
+
return {
|
|
102
|
+
available: false,
|
|
103
|
+
reason: RUNTIME_UNAVAILABLE_REASONS.executableMissing,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
available: true,
|
|
109
|
+
name: 'chromium',
|
|
110
|
+
executablePath,
|
|
111
|
+
launch: () => chromium.launch({ headless: true, executablePath }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function launchInstalledChromium(options = {}) {
|
|
116
|
+
const runtime = await resolveHeadlessRuntime(options);
|
|
117
|
+
if (!runtime.available) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`No headless browser runtime is available (${runtime.reason}).`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return runtime.launch();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Materialize a browser probe callback backed by a real headless browser.
|
|
127
|
+
* Returns an unavailable descriptor instead of throwing when no runtime
|
|
128
|
+
* exists, so callers can record an accurate capability skip.
|
|
129
|
+
*/
|
|
130
|
+
export async function createBrowserProbeSession(options = {}) {
|
|
131
|
+
const runtime = await resolveHeadlessRuntime(options);
|
|
132
|
+
if (!runtime.available) {
|
|
133
|
+
return { available: false, reason: runtime.reason };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const browser = await runtime.launch();
|
|
137
|
+
const pages = new Map();
|
|
138
|
+
const server = createServer((request, response) => {
|
|
139
|
+
const html = pages.get(request.url);
|
|
140
|
+
if (html === undefined) {
|
|
141
|
+
response.writeHead(404).end('not found');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
response.writeHead(200, {
|
|
145
|
+
'content-type': 'text/html; charset=utf-8',
|
|
146
|
+
'cache-control': 'no-store',
|
|
147
|
+
});
|
|
148
|
+
response.end(html);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let address;
|
|
152
|
+
try {
|
|
153
|
+
address = await listen(server);
|
|
154
|
+
} catch (cause) {
|
|
155
|
+
await Promise.allSettled([browser.close(), closeServer(server)]);
|
|
156
|
+
throw cause;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
available: true,
|
|
161
|
+
runtime: { name: runtime.name, version: browser.version() },
|
|
162
|
+
probe: async (probeRequest) => {
|
|
163
|
+
const route = `/${randomBytes(12).toString('hex')}.html`;
|
|
164
|
+
pages.set(route, probeRequest.artifact.html);
|
|
165
|
+
try {
|
|
166
|
+
return await probeRenderedPage(
|
|
167
|
+
browser,
|
|
168
|
+
`http://127.0.0.1:${address.port}${route}`,
|
|
169
|
+
probeRequest,
|
|
170
|
+
);
|
|
171
|
+
} finally {
|
|
172
|
+
pages.delete(route);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
close: async () => {
|
|
176
|
+
await Promise.allSettled([browser.close(), closeServer(server)]);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function probeRenderedPage(browser, url, request) {
|
|
182
|
+
assertProbeRequestFields(request);
|
|
183
|
+
const page = await browser.newPage({
|
|
184
|
+
viewport: request.viewport,
|
|
185
|
+
reducedMotion: request.reducedMotion ?? 'reduce',
|
|
186
|
+
javaScriptEnabled: request.javascriptEnabled !== false,
|
|
187
|
+
});
|
|
188
|
+
try {
|
|
189
|
+
if (request.media === 'print') {
|
|
190
|
+
await page.emulateMedia({
|
|
191
|
+
media: 'print',
|
|
192
|
+
reducedMotion: request.reducedMotion ?? 'reduce',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
const stylesheet = probeStylesheet(request);
|
|
196
|
+
if (stylesheet) {
|
|
197
|
+
await page.route(url, async (route) => {
|
|
198
|
+
const response = await route.fetch();
|
|
199
|
+
const html = await response.text();
|
|
200
|
+
await route.fulfill({
|
|
201
|
+
response,
|
|
202
|
+
body: injectStylesheet(html, stylesheet),
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
await page.goto(url, { waitUntil: 'load' });
|
|
207
|
+
if (request.wideContent) {
|
|
208
|
+
await page.evaluate(({ containerSelector, width }) => {
|
|
209
|
+
const container = document.querySelector(containerSelector);
|
|
210
|
+
if (!container) return;
|
|
211
|
+
const probe = document.createElement('div');
|
|
212
|
+
probe.dataset.releaseWideContent = 'true';
|
|
213
|
+
probe.style.width = `${width}px`;
|
|
214
|
+
if (matchMedia('print').matches) probe.style.maxWidth = '100%';
|
|
215
|
+
probe.style.height = '1px';
|
|
216
|
+
container.append(probe);
|
|
217
|
+
}, request.wideContent);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const layout = await page.evaluate(request.evaluate);
|
|
221
|
+
const keyboard = await probeKeyboard(page, request);
|
|
222
|
+
const themeToggle = request.themeToggle
|
|
223
|
+
? await probeThemeToggle(page, request.themeToggle)
|
|
224
|
+
: layout.themeToggle;
|
|
225
|
+
return {
|
|
226
|
+
...layout,
|
|
227
|
+
keyboard,
|
|
228
|
+
...(themeToggle && { themeToggle }),
|
|
229
|
+
};
|
|
230
|
+
} finally {
|
|
231
|
+
await page.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertProbeRequestFields(request) {
|
|
236
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
237
|
+
throw new TypeError('Browser probe request must be an object.');
|
|
238
|
+
}
|
|
239
|
+
const unsupported = Object.keys(request).filter(
|
|
240
|
+
(field) => !PROBE_REQUEST_FIELDS.has(field),
|
|
241
|
+
);
|
|
242
|
+
if (unsupported.length > 0) {
|
|
243
|
+
throw new TypeError(
|
|
244
|
+
`Browser probe request has unsupported fields: ${unsupported.join(', ')}.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (
|
|
248
|
+
request.disableAnimations !== undefined &&
|
|
249
|
+
typeof request.disableAnimations !== 'boolean'
|
|
250
|
+
) {
|
|
251
|
+
throw new TypeError('Browser probe disableAnimations must be a boolean.');
|
|
252
|
+
}
|
|
253
|
+
if (
|
|
254
|
+
request.injectedCss !== undefined &&
|
|
255
|
+
typeof request.injectedCss !== 'string'
|
|
256
|
+
) {
|
|
257
|
+
throw new TypeError('Browser probe injectedCss must be a string.');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function probeStylesheet(request) {
|
|
262
|
+
return [
|
|
263
|
+
request.injectedCss?.trim(),
|
|
264
|
+
request.disableAnimations ? DISABLE_ANIMATIONS_CSS : undefined,
|
|
265
|
+
]
|
|
266
|
+
.filter(Boolean)
|
|
267
|
+
.join('\n');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function injectStylesheet(html, stylesheet) {
|
|
271
|
+
const style = `<style data-explainer-probe-controls>${stylesheet}</style>`;
|
|
272
|
+
return /<\/head\s*>/i.test(html)
|
|
273
|
+
? html.replace(/<\/head\s*>/i, `${style}</head>`)
|
|
274
|
+
: `${style}${html}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function primeKeyboardFocus(page) {
|
|
278
|
+
await page.bringToFront();
|
|
279
|
+
await page.mouse.click(1, 1);
|
|
280
|
+
await page.evaluate(() => {
|
|
281
|
+
window.focus();
|
|
282
|
+
const body = document.body;
|
|
283
|
+
const originalTabIndex = body.getAttribute('tabindex');
|
|
284
|
+
try {
|
|
285
|
+
body.setAttribute('tabindex', '-1');
|
|
286
|
+
body.focus({ preventScroll: true });
|
|
287
|
+
} finally {
|
|
288
|
+
if (originalTabIndex === null) {
|
|
289
|
+
body.removeAttribute('tabindex');
|
|
290
|
+
} else {
|
|
291
|
+
body.setAttribute('tabindex', originalTabIndex);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function pressTabFromDocument(
|
|
298
|
+
page,
|
|
299
|
+
{ pressTab = () => page.keyboard.press('Tab') } = {},
|
|
300
|
+
) {
|
|
301
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
302
|
+
await primeKeyboardFocus(page);
|
|
303
|
+
await pressTab();
|
|
304
|
+
const advanced = await page.evaluate(() => {
|
|
305
|
+
const focused =
|
|
306
|
+
document.activeElement !== document.body &&
|
|
307
|
+
document.activeElement !== document.documentElement;
|
|
308
|
+
const visibleFocusable = [
|
|
309
|
+
...document.querySelectorAll(
|
|
310
|
+
'a[href], button, input, select, textarea, [tabindex]',
|
|
311
|
+
),
|
|
312
|
+
].some((element) => {
|
|
313
|
+
const style = getComputedStyle(element);
|
|
314
|
+
return (
|
|
315
|
+
style.display !== 'none' &&
|
|
316
|
+
style.visibility !== 'hidden' &&
|
|
317
|
+
element.getClientRects().length > 0
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
return focused || !visibleFocusable;
|
|
321
|
+
});
|
|
322
|
+
if (advanced) return true;
|
|
323
|
+
await page.waitForTimeout(25);
|
|
324
|
+
}
|
|
325
|
+
return page.evaluate(() => {
|
|
326
|
+
const visibleControls = [
|
|
327
|
+
...document.querySelectorAll(
|
|
328
|
+
'a[href], button, input, select, textarea, [tabindex]',
|
|
329
|
+
),
|
|
330
|
+
].filter((element) => {
|
|
331
|
+
if (!(element instanceof HTMLElement) || element.matches(':disabled')) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const style = getComputedStyle(element);
|
|
335
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
336
|
+
});
|
|
337
|
+
return (
|
|
338
|
+
visibleControls.length === 0 ||
|
|
339
|
+
visibleControls.some((element) => element.tabIndex >= 0)
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function probeArrowKey(page, key) {
|
|
345
|
+
const positive = key === 'ArrowRight' || key === 'ArrowDown';
|
|
346
|
+
const makeRoomKey = positive ? 'ArrowLeft' : 'ArrowRight';
|
|
347
|
+
await page.bringToFront();
|
|
348
|
+
let before = await deckPosition(page);
|
|
349
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
350
|
+
const hasRoom = positive
|
|
351
|
+
? before.current < before.total
|
|
352
|
+
: before.current > 1;
|
|
353
|
+
if (hasRoom) break;
|
|
354
|
+
await page.keyboard.press(makeRoomKey);
|
|
355
|
+
before = await deckPosition(page);
|
|
356
|
+
if (positive ? before.current < before.total : before.current > 1) {
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
await page.waitForTimeout(25);
|
|
360
|
+
before = await deckPosition(page);
|
|
361
|
+
}
|
|
362
|
+
if (positive ? before.current >= before.total : before.current <= 1) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
366
|
+
await page.keyboard.press(key);
|
|
367
|
+
let after = await deckPosition(page);
|
|
368
|
+
if (
|
|
369
|
+
positive ? after.current > before.current : after.current < before.current
|
|
370
|
+
) {
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
await page.waitForTimeout(25);
|
|
374
|
+
after = await deckPosition(page);
|
|
375
|
+
if (
|
|
376
|
+
positive ? after.current > before.current : after.current < before.current
|
|
377
|
+
) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function probeKeyboard(page, request) {
|
|
385
|
+
const tab = await pressTabFromDocument(page);
|
|
386
|
+
if (!request.keyboard?.arrows) return { tab };
|
|
387
|
+
|
|
388
|
+
const arrows = {};
|
|
389
|
+
for (const key of request.keyboard.arrows) {
|
|
390
|
+
arrows[key] = await probeArrowKey(page, key);
|
|
391
|
+
}
|
|
392
|
+
return { tab, arrows };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function probeThemeToggle(page, themeToggle) {
|
|
396
|
+
const locator = page.locator(themeToggle.selector);
|
|
397
|
+
const present = (await locator.count()) === 1;
|
|
398
|
+
if (!present) return { present: false };
|
|
399
|
+
const initialMode = await page.evaluate(
|
|
400
|
+
() => document.documentElement.dataset.themeMode,
|
|
401
|
+
);
|
|
402
|
+
await locator.focus();
|
|
403
|
+
await page.keyboard.press('Enter');
|
|
404
|
+
const toggledMode = await page.evaluate(
|
|
405
|
+
() => document.documentElement.dataset.themeMode,
|
|
406
|
+
);
|
|
407
|
+
await page.reload({ waitUntil: 'load' });
|
|
408
|
+
const persistedMode = await page.evaluate(
|
|
409
|
+
() => document.documentElement.dataset.themeMode,
|
|
410
|
+
);
|
|
411
|
+
return {
|
|
412
|
+
present: true,
|
|
413
|
+
initialMode,
|
|
414
|
+
toggledMode,
|
|
415
|
+
keyboardOperable: toggledMode !== initialMode,
|
|
416
|
+
persisted: persistedMode === toggledMode,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function deckPosition(page) {
|
|
421
|
+
return page.evaluate(() => {
|
|
422
|
+
const text = document.querySelector('#deck-counter')?.textContent ?? '';
|
|
423
|
+
const [current, total] = text
|
|
424
|
+
.split('/')
|
|
425
|
+
.map((value) => Number.parseInt(value, 10));
|
|
426
|
+
return { current, total };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function listen(server) {
|
|
431
|
+
return new Promise((resolveListen, reject) => {
|
|
432
|
+
server.once('error', reject);
|
|
433
|
+
server.listen(0, '127.0.0.1', () => {
|
|
434
|
+
server.off('error', reject);
|
|
435
|
+
resolveListen(server.address());
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function closeServer(server) {
|
|
441
|
+
return new Promise((resolveClose) => server.close(resolveClose));
|
|
442
|
+
}
|