@open-agent-toolkit/cli 0.2.19 → 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/config/dispatch-matrix-recommendation.json +15 -5
- package/assets/docs/cli-utilities/configuration.md +10 -5
- package/assets/docs/contributing/index.md +1 -0
- package/assets/docs/contributing/verifying-cursor-pins.md +282 -0
- package/assets/docs/workflows/projects/artifacts.md +1 -1
- package/assets/docs/workflows/projects/dispatch-ceiling.md +50 -4
- 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/assets/skills/subagent-orchestration/SKILL.md +1 -1
- package/assets/skills/subagent-orchestration/references/evidence-and-refresh.md +153 -15
- package/assets/skills/subagent-orchestration/references/model-selection-principles.md +75 -3
- package/assets/skills/subagent-orchestration/references/provider-claude.md +95 -35
- package/assets/skills/subagent-orchestration/references/provider-codex.md +36 -10
- package/assets/skills/subagent-orchestration/references/provider-cursor.md +91 -15
- package/dist/providers/cursor/codec/catalog.d.ts +19 -1
- package/dist/providers/cursor/codec/catalog.d.ts.map +1 -1
- package/dist/providers/cursor/codec/catalog.js +54 -0
- 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
|
@@ -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
|
+
}
|