debutly 0.0.0-stage → 0.2.0
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/LICENSE +662 -0
- package/README.md +192 -2
- package/bin/debutly.mjs +8 -0
- package/drizzle/0000_init.sql +107 -0
- package/drizzle/0001_predictions.sql +14 -0
- package/drizzle/0002_dashboard.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +722 -0
- package/drizzle/meta/0001_snapshot.json +821 -0
- package/drizzle/meta/0002_snapshot.json +942 -0
- package/drizzle/meta/_journal.json +27 -0
- package/package.json +83 -4
- package/src/agent/describe.ts +29 -0
- package/src/agent/effect.ts +94 -0
- package/src/agent/give-up.ts +42 -0
- package/src/agent/label.ts +19 -0
- package/src/agent/loop.ts +302 -0
- package/src/agent/parse-reply.ts +127 -0
- package/src/agent/prompt.ts +85 -0
- package/src/agent/run-agent.ts +68 -0
- package/src/artifacts/db.ts +55 -0
- package/src/artifacts/s3.ts +36 -0
- package/src/artifacts/store.ts +26 -0
- package/src/bin.ts +140 -0
- package/src/browser/ax.ts +77 -0
- package/src/browser/execute.ts +401 -0
- package/src/browser/observe.ts +62 -0
- package/src/browser/reflow.ts +73 -0
- package/src/browser/samples.ts +119 -0
- package/src/browser/scan-script.ts +108 -0
- package/src/browser/settle.ts +32 -0
- package/src/browser/signals.ts +73 -0
- package/src/cancel.ts +20 -0
- package/src/cli.ts +281 -0
- package/src/config.ts +62 -0
- package/src/cost/estimate.ts +32 -0
- package/src/cost/prices.ts +32 -0
- package/src/cost/tiers.ts +27 -0
- package/src/costs.ts +96 -0
- package/src/crew/run-unit.ts +62 -0
- package/src/detect/findings.ts +649 -0
- package/src/detect/wcag.ts +38 -0
- package/src/doctor.ts +145 -0
- package/src/hear-cli.ts +137 -0
- package/src/hear.ts +309 -0
- package/src/import.ts +108 -0
- package/src/inbox/mailpit.ts +46 -0
- package/src/inbox/types.ts +10 -0
- package/src/init.ts +184 -0
- package/src/llm/anthropic.ts +41 -0
- package/src/llm/credits.ts +35 -0
- package/src/llm/factory.ts +65 -0
- package/src/llm/meter.ts +39 -0
- package/src/llm/openai-compatible.ts +90 -0
- package/src/llm/openrouter.ts +9 -0
- package/src/llm/scripted.ts +18 -0
- package/src/llm/types.ts +36 -0
- package/src/perceive.ts +150 -0
- package/src/perception/aggregate.ts +111 -0
- package/src/perception/compare.ts +77 -0
- package/src/perception/evaluate.ts +83 -0
- package/src/perception/exhibits.ts +146 -0
- package/src/perception/market-context.ts +50 -0
- package/src/perception/run-perception.ts +178 -0
- package/src/persona/generate.ts +92 -0
- package/src/persona/inclusion.ts +53 -0
- package/src/persona/language.ts +59 -0
- package/src/persona/people.ts +32 -0
- package/src/predictions.ts +33 -0
- package/src/queue/dispatch.ts +18 -0
- package/src/queue/jobs.ts +28 -0
- package/src/queue/processor.ts +42 -0
- package/src/report/fix-first.ts +121 -0
- package/src/report/launch-readiness.ts +106 -0
- package/src/report/markdown.ts +202 -0
- package/src/run.ts +141 -0
- package/src/sandbox/outbox.ts +65 -0
- package/src/store/db.ts +40 -0
- package/src/store/import-run.ts +116 -0
- package/src/store/pg-run-store.ts +163 -0
- package/src/store/predictions.ts +70 -0
- package/src/store/run-store.ts +78 -0
- package/src/store/schema.ts +193 -0
- package/src/store/url.ts +15 -0
- package/src/types.ts +201 -0
- package/src/util/chromium.ts +20 -0
- package/src/util/json.ts +7 -0
- package/src/util/key-defaults.ts +34 -0
- package/src/util/legacy-env.ts +14 -0
- package/src/util/load-env.ts +28 -0
- package/src/util/pool.ts +12 -0
- package/src/util/run-origin.ts +27 -0
- package/src/worker.ts +53 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { Locator, Page } from "playwright";
|
|
2
|
+
import type { Action, ActionResult, InteractiveElement, Observation } from "../types";
|
|
3
|
+
import { sampleFiles, sampleFor, type SampleKind } from "./samples";
|
|
4
|
+
|
|
5
|
+
const DESTRUCTIVE = /\b(delete|remove|destroy|deactivate|close account|cancel (my )?(subscription|account))\b/i;
|
|
6
|
+
|
|
7
|
+
// Agents never click anything named like this, whichever action would click it: a plain click,
|
|
8
|
+
// the button behind an upload, or a custom dropdown and the option chosen in it.
|
|
9
|
+
class Refused extends Error {}
|
|
10
|
+
function refuseDestructive(name: string): void {
|
|
11
|
+
if (DESTRUCTIVE.test(name)) throw new Refused(`destructive action refused: "${name}"`);
|
|
12
|
+
}
|
|
13
|
+
// Every name the control goes by: Debutly's reading, and the one the browser gives assistive
|
|
14
|
+
// technology, which is what the screen-reader agent sees (a button holding only an image whose alt
|
|
15
|
+
// says "Delete workspace" has no name in the first and that one in the second).
|
|
16
|
+
function refuseDestructiveControl(el: InteractiveElement): void {
|
|
17
|
+
refuseDestructive(el.name);
|
|
18
|
+
if (el.ax) refuseDestructive(el.ax.name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const COVERED = "another element covers it";
|
|
22
|
+
export const NOT_BY_KEYBOARD = "can't be reached with the keyboard";
|
|
23
|
+
const WAIT_MS = 3_000;
|
|
24
|
+
const OPTION = '[role="option"]';
|
|
25
|
+
|
|
26
|
+
export interface ExecuteOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Keyboard only, as a screen-reader user works: a control is used by moving focus to it (as
|
|
29
|
+
* Tab would) and pressing Enter or Space, so one that isn't in the tab order can't be used.
|
|
30
|
+
* Items inside lists, menus, tab lists and radio groups are reached with arrow keys, not Tab;
|
|
31
|
+
* those are still chosen as a mouse would.
|
|
32
|
+
*/
|
|
33
|
+
keyboard?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Roles reached with arrow keys inside their list, menu, tab list or group, not with Tab.
|
|
37
|
+
const ARROW_KEY_ITEMS = new Set(["option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "treeitem", "radio", "input:radio"]);
|
|
38
|
+
// What Space presses; Enter presses the rest. Text fields and selects are only focused.
|
|
39
|
+
const SPACE_KEY = new Set(["checkbox", "input:checkbox", "switch"]);
|
|
40
|
+
// Buttons take Enter and Space alike, so a keyboard user who gets nothing from Enter tries Space.
|
|
41
|
+
const BUTTON_ROLE = /^(button|input:(submit|button|reset|image))$/;
|
|
42
|
+
/** How long to watch for Enter's effect before trying Space. */
|
|
43
|
+
const ENTER_SETTLE_MS = 1_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Presses Enter on the focused button; if nothing happened that a person would notice (the page
|
|
47
|
+
* sent nothing, kept its address and looks exactly the same), presses Space, as a keyboard user
|
|
48
|
+
* would. True when Space then did what Enter didn't: Twenty's set-up "Continue" ignores Enter and
|
|
49
|
+
* goes on with Space, and a keyboard-only agent that knew only Enter gave up there twice
|
|
50
|
+
* (2026-10-24). It goes by how the page looks, not its text, so a toggle whose change is only
|
|
51
|
+
* visual is never pressed twice.
|
|
52
|
+
*/
|
|
53
|
+
async function pressEnterThenSpace(page: Page, id: number): Promise<boolean> {
|
|
54
|
+
const look = () => page.screenshot({ animations: "disabled", timeout: 5_000 }).catch(() => undefined);
|
|
55
|
+
const url = page.url();
|
|
56
|
+
// The first key press on a page draws the focus ring; Shift, which presses nothing, draws it
|
|
57
|
+
// first, or Enter would always seem to have changed the page.
|
|
58
|
+
await page.keyboard.press("Shift");
|
|
59
|
+
const before = await look();
|
|
60
|
+
let sent = 0;
|
|
61
|
+
const count = () => sent++;
|
|
62
|
+
page.on("request", count);
|
|
63
|
+
try {
|
|
64
|
+
await page.keyboard.press("Enter");
|
|
65
|
+
await page.waitForTimeout(ENTER_SETTLE_MS);
|
|
66
|
+
} finally {
|
|
67
|
+
page.off("request", count);
|
|
68
|
+
}
|
|
69
|
+
const afterEnter = sent || page.url() !== url || !before ? undefined : await look();
|
|
70
|
+
if (!before || !afterEnter || !afterEnter.equals(before)) return false;
|
|
71
|
+
// Space goes to whatever has focus: only to the button that was checked before Enter, never to
|
|
72
|
+
// something Enter moved focus to unseen.
|
|
73
|
+
const stillFocused = await page.evaluate((target) => document.activeElement?.getAttribute("data-debutly-id") === target, String(id));
|
|
74
|
+
if (!stillFocused) return false;
|
|
75
|
+
await page.keyboard.press("Space");
|
|
76
|
+
await page.waitForTimeout(ENTER_SETTLE_MS);
|
|
77
|
+
if (page.url() !== url) return true;
|
|
78
|
+
const afterSpace = await look();
|
|
79
|
+
return !!afterSpace && !afterSpace.equals(before);
|
|
80
|
+
}
|
|
81
|
+
const FOCUS_ONLY = /^(input:(text|email|password|number|search|tel|url|date|time|datetime-local|month|week)|textarea|select|combobox)$/;
|
|
82
|
+
|
|
83
|
+
// Is the focused element entirely hidden behind something else? Checks its middle and four points
|
|
84
|
+
// just inside its corners: WCAG 2.4.11 asks that focus is not entirely hidden. Returns what covers it.
|
|
85
|
+
const focusCoveredScript = (id: number) => `(() => {
|
|
86
|
+
const el = document.querySelector('[data-debutly-id="${id}"]');
|
|
87
|
+
if (!el) return null;
|
|
88
|
+
const r = el.getBoundingClientRect();
|
|
89
|
+
const points = [[0.5, 0.5], [0.1, 0.1], [0.9, 0.1], [0.1, 0.9], [0.9, 0.9]].map(([fx, fy]) => [r.left + r.width * fx, r.top + r.height * fy]);
|
|
90
|
+
let cover = null;
|
|
91
|
+
for (const [x, y] of points) {
|
|
92
|
+
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
|
|
93
|
+
const hit = document.elementFromPoint(x, y);
|
|
94
|
+
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) return null;
|
|
95
|
+
cover = cover || hit;
|
|
96
|
+
}
|
|
97
|
+
if (!cover) return 'the edge of the window';
|
|
98
|
+
const classes = typeof cover.className === 'string' ? cover.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
|
|
99
|
+
const text = (cover.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
|
|
100
|
+
return cover.tagName.toLowerCase() + (cover.id ? '#' + cover.id : '') + classes.map((c) => '.' + c).join('') + (text ? ' ("' + text + '")' : '');
|
|
101
|
+
})()`;
|
|
102
|
+
|
|
103
|
+
class NotByKeyboard extends Error {}
|
|
104
|
+
/** A control a person can't use at all right now, whatever they use: said as it is. */
|
|
105
|
+
class Unusable extends Error {}
|
|
106
|
+
|
|
107
|
+
/** Moves focus to the control as Tab would; refuses one that isn't in the tab order. Returns what hides it, if anything. */
|
|
108
|
+
async function focusByKeyboard(page: Page, locator: Locator, id: number): Promise<string | undefined> {
|
|
109
|
+
const reach = await locator.evaluate(
|
|
110
|
+
(node) => {
|
|
111
|
+
const el = node as HTMLElement & { disabled?: boolean };
|
|
112
|
+
if (el.disabled === true || el.getAttribute("aria-disabled") === "true") return "disabled";
|
|
113
|
+
return el.tabIndex >= 0 && !el.closest("[inert]") ? "tabbable" : "untabbable";
|
|
114
|
+
},
|
|
115
|
+
undefined,
|
|
116
|
+
{ timeout: 5_000 },
|
|
117
|
+
);
|
|
118
|
+
// A disabled button can't take focus, so Enter went to whatever had it, and the agent was told
|
|
119
|
+
// only that nothing changed; it pressed "Create account" again and gave up (2026-10-24). A mouse
|
|
120
|
+
// click on it is told it is disabled; so is a key press.
|
|
121
|
+
if (reach === "disabled") throw new Unusable(UNUSABLE.disabled);
|
|
122
|
+
if (reach === "untabbable") throw new NotByKeyboard(`${NOT_BY_KEYBOARD}: it isn't in the tab order`);
|
|
123
|
+
await locator.focus({ timeout: 5_000 });
|
|
124
|
+
return ((await page.evaluate(focusCoveredScript(id))) as string | null) ?? undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Playwright's timeout message hides the cause in its call log; say it the way a person would notice it.
|
|
128
|
+
export function describeFailure(err: unknown): string {
|
|
129
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
130
|
+
const covered = message.match(/(<[^\n]*?>)[^\n]*?(?: from [^\n]*? subtree)? intercepts pointer events/);
|
|
131
|
+
if (covered) return `${COVERED}: ${covered[1].slice(0, 160)}`;
|
|
132
|
+
if (/element is not enabled|element is disabled/.test(message)) return "the element is disabled";
|
|
133
|
+
if (/element is not visible/.test(message)) return "the element is not visible";
|
|
134
|
+
return message.split("\n")[0];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// When Playwright gives up waiting on an element it says only "Timeout exceeded", and an agent
|
|
138
|
+
// told that tries the same thing again. A person can see why: the field only opens for typing
|
|
139
|
+
// after its edit button, or the link is in a side menu that is shut. A string, like SCAN_SCRIPT.
|
|
140
|
+
const WHY_UNUSABLE_SCRIPT = (id: number) => `(() => {
|
|
141
|
+
const el = document.querySelector('[data-debutly-id="${id}"]');
|
|
142
|
+
if (!el) return 'gone';
|
|
143
|
+
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return 'disabled';
|
|
144
|
+
if ((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.readOnly) return 'readonly';
|
|
145
|
+
const style = getComputedStyle(el);
|
|
146
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
147
|
+
const r = el.getBoundingClientRect();
|
|
148
|
+
if (r.width === 0 || r.height === 0 || style.visibility === 'hidden') return 'hidden';
|
|
149
|
+
if (r.right <= 0 || r.bottom <= 0 || r.left >= innerWidth || r.top >= innerHeight) return 'offscreen';
|
|
150
|
+
return '';
|
|
151
|
+
})()`;
|
|
152
|
+
|
|
153
|
+
const UNUSABLE: Record<string, string> = {
|
|
154
|
+
gone: "the element is no longer on the page",
|
|
155
|
+
disabled: "the element is disabled",
|
|
156
|
+
readonly: "the field is read-only, so it can't be typed in yet; there may be a button to edit it",
|
|
157
|
+
hidden: "the element is not visible",
|
|
158
|
+
offscreen: "it is off the screen, perhaps in a menu or panel that is closed; open that first",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
async function explainTimeout(page: Page, id: number, err: unknown): Promise<string | undefined> {
|
|
162
|
+
if (!/Timeout \d+ms exceeded/.test(err instanceof Error ? err.message : String(err))) return undefined;
|
|
163
|
+
const why = (await page.evaluate(WHY_UNUSABLE_SCRIPT(id)).catch(() => "")) as string;
|
|
164
|
+
return UNUSABLE[why];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Playwright clicks an element's centre; a person clicks whatever part of it they can see.
|
|
168
|
+
async function clickVisiblePart(page: Page, id: number): Promise<boolean> {
|
|
169
|
+
const locator = page.locator(`[data-debutly-id="${id}"]`);
|
|
170
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 2_000 }).catch(() => {});
|
|
171
|
+
const box = await locator.boundingBox();
|
|
172
|
+
if (!box) return false;
|
|
173
|
+
for (const fy of [0.5, 0.25, 0.75]) {
|
|
174
|
+
for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
|
175
|
+
const x = box.x + box.width * fx;
|
|
176
|
+
const y = box.y + box.height * fy;
|
|
177
|
+
const reachable = await page.evaluate(
|
|
178
|
+
([px, py, target]) => {
|
|
179
|
+
const hit = document.elementFromPoint(px, py);
|
|
180
|
+
const el = document.querySelector(`[data-debutly-id="${target}"]`);
|
|
181
|
+
return !!(hit && el && (hit === el || el.contains(hit)));
|
|
182
|
+
},
|
|
183
|
+
[x, y, id] as const,
|
|
184
|
+
);
|
|
185
|
+
if (reachable) {
|
|
186
|
+
await page.mouse.click(x, y);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// A native select's list is drawn outside the page, where arrow keys move a highlight the agent
|
|
195
|
+
// can't see: choose the option by its label, as a person picks what they read.
|
|
196
|
+
async function choose(select: Locator, text: string): Promise<void> {
|
|
197
|
+
const labels = await select.evaluate((el) => Array.from((el as HTMLSelectElement).options, (o) => o.label));
|
|
198
|
+
const label = labels[matchOption(labels, text)];
|
|
199
|
+
refuseDestructive(label);
|
|
200
|
+
await select.selectOption({ label }, { timeout: 5_000 });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The option a person would pick for `text`: the one it names, else the first containing it. */
|
|
204
|
+
function matchOption(labels: string[], text: string): number {
|
|
205
|
+
const want = text.trim().toLowerCase();
|
|
206
|
+
let i = labels.findIndex((l) => l.trim().toLowerCase() === want);
|
|
207
|
+
if (i === -1) i = labels.findIndex((l) => l.toLowerCase().includes(want));
|
|
208
|
+
if (i === -1) throw new Error(`no option "${text}"; the options are ${labels.map((l) => `"${l}"`).join(", ")}`);
|
|
209
|
+
return i;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The options of the dropdown that is open now: each visible role="option", with its position. */
|
|
213
|
+
async function openOptions(page: Page): Promise<{ label: string; nth: number }[]> {
|
|
214
|
+
const all = await page.locator(OPTION).evaluateAll((els) =>
|
|
215
|
+
els.map((el) => ({
|
|
216
|
+
label: (el.textContent ?? "").replace(/\s+/g, " ").trim(),
|
|
217
|
+
visible: typeof el.checkVisibility === "function" ? el.checkVisibility() : (el as HTMLElement).offsetParent !== null,
|
|
218
|
+
})),
|
|
219
|
+
);
|
|
220
|
+
return all.flatMap((o, nth) => (o.visible ? [{ label: o.label, nth }] : []));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A custom dropdown (a button or div with role="combobox", as Radix and shadcn build them) takes
|
|
224
|
+
// no typing: a person opens it and clicks the option they read. Agents typed into them and got
|
|
225
|
+
// "Element is not an <input>" again and again, until the report called the page stuck.
|
|
226
|
+
async function pick(page: Page, trigger: Locator, text: string): Promise<void> {
|
|
227
|
+
const appear = () => page.locator(OPTION).first().waitFor({ state: "visible", timeout: 2_000 }).catch(() => {});
|
|
228
|
+
if (!(await openOptions(page)).length) {
|
|
229
|
+
await trigger.click({ timeout: 5_000 });
|
|
230
|
+
await appear();
|
|
231
|
+
}
|
|
232
|
+
// Some open on a key rather than a click.
|
|
233
|
+
if (!(await openOptions(page)).length) {
|
|
234
|
+
await trigger.focus();
|
|
235
|
+
await page.keyboard.press("ArrowDown");
|
|
236
|
+
await appear();
|
|
237
|
+
}
|
|
238
|
+
const options = await openOptions(page);
|
|
239
|
+
if (!options.length) throw new Error("the dropdown shows no options");
|
|
240
|
+
let chosen: { label: string; nth: number };
|
|
241
|
+
try {
|
|
242
|
+
chosen = options[matchOption(options.map((o) => o.label), text)];
|
|
243
|
+
refuseDestructive(chosen.label);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
await page.keyboard.press("Escape"); // leave it closed, as a person who found nothing would
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
await page.locator(OPTION).nth(chosen.nth).click({ timeout: 5_000 });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// A key can click too:
|
|
252
|
+
// - Enter activates the focused button or link, or the option a combobox highlights
|
|
253
|
+
// (aria-activedescendant);
|
|
254
|
+
// - Enter in a form field submits its form through the form's default button, which may sit
|
|
255
|
+
// outside the form (form="…"), or with no button through the form itself;
|
|
256
|
+
// - Space presses the focused button, or ticks the focused box;
|
|
257
|
+
// - an arrow key moves a focused native select to the next option.
|
|
258
|
+
// Each thing the key would set off is listed with every name it goes by (aria-label, the elements
|
|
259
|
+
// aria-labelledby points at, labels, text, value, title) and its data-debutly-id, so the check also
|
|
260
|
+
// sees the name agents see. With nothing interactive focused there is nothing: the page's own text
|
|
261
|
+
// is not a button. A string, like SCAN_SCRIPT: tsx injects helpers into functions the page lacks.
|
|
262
|
+
const keyTargetsScript = (key: string) => `(() => {
|
|
263
|
+
const key = ${JSON.stringify(key)};
|
|
264
|
+
const el = document.activeElement;
|
|
265
|
+
if (!el || el === document.body || el === document.documentElement) return [];
|
|
266
|
+
const targets = [];
|
|
267
|
+
const add = (e) => {
|
|
268
|
+
if (!e) return;
|
|
269
|
+
const names = [e.getAttribute('aria-label'), e.getAttribute('title')];
|
|
270
|
+
for (const ref of (e.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean)) {
|
|
271
|
+
const r = document.getElementById(ref);
|
|
272
|
+
if (r) names.push(r.textContent);
|
|
273
|
+
}
|
|
274
|
+
if (e.labels) for (const l of e.labels) names.push(l.textContent);
|
|
275
|
+
if (e.tagName === 'INPUT' || e.tagName === 'BUTTON') names.push(e.value);
|
|
276
|
+
if (e.tagName === 'INPUT' && e.type === 'image') names.push(e.getAttribute('alt'));
|
|
277
|
+
if (e.tagName !== 'INPUT') names.push(e.innerText);
|
|
278
|
+
targets.push({ id: e.getAttribute('data-debutly-id'), names: names.filter((n) => n && n.trim()) });
|
|
279
|
+
};
|
|
280
|
+
if (key === 'Enter') {
|
|
281
|
+
const active = el.getAttribute('aria-activedescendant');
|
|
282
|
+
if (active) add(document.getElementById(active));
|
|
283
|
+
const field = el.tagName === 'INPUT' && !['submit', 'button', 'reset', 'image'].includes(el.type);
|
|
284
|
+
if (field && el.form) {
|
|
285
|
+
const button = Array.from(el.form.elements).find((e) =>
|
|
286
|
+
(e.tagName === 'BUTTON' && e.type === 'submit') || (e.tagName === 'INPUT' && (e.type === 'submit' || e.type === 'image')));
|
|
287
|
+
if (button) add(button);
|
|
288
|
+
else targets.push({ id: null, names: [el.form.getAttribute('aria-label'), el.form.getAttribute('name'), el.form.getAttribute('action')].filter((n) => n && n.trim()) });
|
|
289
|
+
} else if (!field && el.matches('a, button, input, summary, [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="tab"]')) {
|
|
290
|
+
add(el);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (key === 'Space' && el.matches('button, summary, input[type="checkbox"], input[type="radio"], input[type="submit"], input[type="button"], input[type="reset"], input[type="image"], [role="button"], [role="checkbox"], [role="switch"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="tab"]')) {
|
|
294
|
+
add(el);
|
|
295
|
+
}
|
|
296
|
+
if ((key === 'ArrowDown' || key === 'ArrowUp') && el.tagName === 'SELECT') {
|
|
297
|
+
const next = el.options[el.selectedIndex + (key === 'ArrowDown' ? 1 : -1)];
|
|
298
|
+
if (next) targets.push({ id: null, names: [next.label] });
|
|
299
|
+
}
|
|
300
|
+
return targets;
|
|
301
|
+
})()`;
|
|
302
|
+
|
|
303
|
+
async function namesAKeyWouldActivate(page: Page, key: string, obs: Observation): Promise<string[]> {
|
|
304
|
+
const targets = (await page.evaluate(keyTargetsScript(key))) as { id: string | null; names: string[] }[];
|
|
305
|
+
return targets.flatMap((t) => [...t.names, ...obs.elements.filter((e) => t.id !== null && String(e.id) === t.id).flatMap((e) => [e.name, e.ax?.name ?? ""])]);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Attach a sample file, as a person picks one from their computer. A visible file field takes it
|
|
309
|
+
// directly; an upload button, label or drop zone opens the file picker, which is answered.
|
|
310
|
+
// Without this an agent asked for a photo (an Instagram post needs one) could only give up.
|
|
311
|
+
async function upload(page: Page, target: Locator, isFileField: boolean, wanted?: SampleKind): Promise<void> {
|
|
312
|
+
const files = await sampleFiles();
|
|
313
|
+
if (isFileField) {
|
|
314
|
+
await target.setInputFiles(files[wanted ?? sampleFor(await target.getAttribute("accept"))], { timeout: 5_000 });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const chooser = page.waitForEvent("filechooser", { timeout: 5_000 }).catch(() => null);
|
|
318
|
+
await target.click({ timeout: 5_000 });
|
|
319
|
+
const picker = await chooser;
|
|
320
|
+
if (!picker) throw new Error("clicking it opened no file picker");
|
|
321
|
+
await picker.setFiles(files[wanted ?? sampleFor(await picker.element().getAttribute("accept"))]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function execute(
|
|
325
|
+
page: Page,
|
|
326
|
+
action: Action,
|
|
327
|
+
obs: Observation,
|
|
328
|
+
origin: string,
|
|
329
|
+
// Links the agent received by email (e.g. a magic link to the auth server) may leave the app's origin.
|
|
330
|
+
allowUrl: (href: string) => boolean = () => false,
|
|
331
|
+
opts: ExecuteOptions = {},
|
|
332
|
+
): Promise<ActionResult> {
|
|
333
|
+
const result: ActionResult = {};
|
|
334
|
+
try {
|
|
335
|
+
switch (action.type) {
|
|
336
|
+
case "click":
|
|
337
|
+
case "fill": {
|
|
338
|
+
const el = obs.elements.find((e) => e.id === action.id);
|
|
339
|
+
if (!el) return { error: `no element with id ${action.id}` };
|
|
340
|
+
const customDropdown = el.tag !== "select" && el.role === "combobox" && el.tag !== "input" && el.tag !== "textarea";
|
|
341
|
+
if (action.type === "click" || customDropdown) refuseDestructiveControl(el);
|
|
342
|
+
const locator = page.locator(`[data-debutly-id="${action.id}"]`);
|
|
343
|
+
const byKeyboard = opts.keyboard && !ARROW_KEY_ITEMS.has(el.role);
|
|
344
|
+
if (byKeyboard) result.focusObscured = await focusByKeyboard(page, locator, action.id);
|
|
345
|
+
if (action.type === "click" && byKeyboard) {
|
|
346
|
+
if (SPACE_KEY.has(el.role)) await page.keyboard.press("Space");
|
|
347
|
+
else if (BUTTON_ROLE.test(el.role)) {
|
|
348
|
+
if (await pressEnterThenSpace(page, action.id)) result.enterIgnored = true;
|
|
349
|
+
} else if (!FOCUS_ONLY.test(el.role)) await page.keyboard.press("Enter");
|
|
350
|
+
} else if (action.type === "click") {
|
|
351
|
+
try {
|
|
352
|
+
await locator.click({ timeout: 5_000 });
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (!describeFailure(err).startsWith(COVERED) || !(await clickVisiblePart(page, action.id))) throw err;
|
|
355
|
+
}
|
|
356
|
+
} else if (el.tag === "select") await choose(locator, action.text);
|
|
357
|
+
else if (customDropdown) await pick(page, locator, action.text);
|
|
358
|
+
else await locator.fill(action.text, { timeout: 5_000 });
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "upload": {
|
|
362
|
+
const el = obs.elements.find((e) => e.id === action.id);
|
|
363
|
+
if (!el) return { error: `no element with id ${action.id}` };
|
|
364
|
+
if (el.role !== "input:file") refuseDestructiveControl(el);
|
|
365
|
+
const target = page.locator(`[data-debutly-id="${action.id}"]`);
|
|
366
|
+
// A drop zone or label a keyboard can't reach can't open the file picker for a keyboard user.
|
|
367
|
+
if (opts.keyboard && el.role !== "input:file") result.focusObscured = await focusByKeyboard(page, target, action.id);
|
|
368
|
+
await upload(page, target, el.role === "input:file", action.file);
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case "goto": {
|
|
372
|
+
const target = new URL(action.path, origin);
|
|
373
|
+
if (target.origin !== new URL(origin).origin && !allowUrl(target.href)) {
|
|
374
|
+
return { blocked: `off-origin navigation refused: ${target.href}` };
|
|
375
|
+
}
|
|
376
|
+
await page.goto(target.href);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case "press":
|
|
380
|
+
for (const name of await namesAKeyWouldActivate(page, action.key, obs)) refuseDestructive(name);
|
|
381
|
+
await page.keyboard.press(action.key);
|
|
382
|
+
break;
|
|
383
|
+
case "wait":
|
|
384
|
+
// A few seconds more than the settle before every look (runSession) already gives.
|
|
385
|
+
await page.waitForTimeout(WAIT_MS);
|
|
386
|
+
break;
|
|
387
|
+
case "done":
|
|
388
|
+
case "check_email":
|
|
389
|
+
return {};
|
|
390
|
+
}
|
|
391
|
+
await page.waitForLoadState("domcontentloaded");
|
|
392
|
+
// Let async handlers (fetch after submit, deferred errors) surface their signals.
|
|
393
|
+
await page.waitForTimeout(300);
|
|
394
|
+
return result.focusObscured || result.enterIgnored ? result : {};
|
|
395
|
+
} catch (err) {
|
|
396
|
+
if (err instanceof Refused) return { blocked: err.message };
|
|
397
|
+
if (err instanceof NotByKeyboard || err instanceof Unusable) return { error: err.message };
|
|
398
|
+
const why = "id" in action ? await explainTimeout(page, action.id, err) : undefined;
|
|
399
|
+
return { error: why ?? describeFailure(err), ...(result.focusObscured ? { focusObscured: result.focusObscured } : {}) };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { InteractiveElement, Observation } from "../types";
|
|
3
|
+
import { accessibleNames } from "./ax";
|
|
4
|
+
import { SCAN_SCRIPT } from "./scan-script";
|
|
5
|
+
|
|
6
|
+
// The page's language, how much of its visible text is Arabic and the direction that Arabic is
|
|
7
|
+
// laid out in, and whether it lets people zoom. The direction is measured where the Arabic is, as
|
|
8
|
+
// an app may turn its own container right to left rather than <body>: one that did was reported
|
|
9
|
+
// as showing Arabic left to right. A string, like SCAN_SCRIPT: tsx injects helpers into functions
|
|
10
|
+
// the page lacks.
|
|
11
|
+
const LANGUAGE_SCRIPT = `(() => {
|
|
12
|
+
const body = document.body;
|
|
13
|
+
const text = (body && body.innerText) || '';
|
|
14
|
+
const letters = (text.match(/\\p{L}/gu) || []).length;
|
|
15
|
+
const arabic = (text.match(/\\p{Script=Arabic}/gu) || []).length;
|
|
16
|
+
let dir = body ? getComputedStyle(body).direction : 'ltr';
|
|
17
|
+
if (arabic && body) {
|
|
18
|
+
let rtl = 0, ltr = 0;
|
|
19
|
+
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
20
|
+
for (let node = walker.nextNode(), seen = 0; node && seen < 5000; node = walker.nextNode(), seen++) {
|
|
21
|
+
const count = (node.nodeValue.match(/\\p{Script=Arabic}/gu) || []).length;
|
|
22
|
+
if (!count || !node.parentElement) continue;
|
|
23
|
+
if (getComputedStyle(node.parentElement).direction === 'rtl') rtl += count;
|
|
24
|
+
else ltr += count;
|
|
25
|
+
}
|
|
26
|
+
if (rtl + ltr) dir = rtl >= ltr ? 'rtl' : 'ltr';
|
|
27
|
+
}
|
|
28
|
+
// Pinch-zoom, which people with low vision rely on on phones (WCAG 1.4.4 asks for 200%).
|
|
29
|
+
const meta = document.querySelector('meta[name="viewport"]');
|
|
30
|
+
const viewport = meta ? meta.getAttribute('content') || '' : '';
|
|
31
|
+
const setting = {};
|
|
32
|
+
for (const part of viewport.split(/[,;]/)) {
|
|
33
|
+
const [key, value] = part.split('=').map((s) => s.trim().toLowerCase());
|
|
34
|
+
if (key) setting[key] = value || '';
|
|
35
|
+
}
|
|
36
|
+
const noScaling = setting['user-scalable'] === 'no' || setting['user-scalable'] === '0';
|
|
37
|
+
const maxScale = parseFloat(setting['maximum-scale']);
|
|
38
|
+
return {
|
|
39
|
+
lang: document.documentElement.getAttribute('lang') || '',
|
|
40
|
+
dir,
|
|
41
|
+
arabicShare: letters ? Math.round((arabic / letters) * 100) / 100 : 0,
|
|
42
|
+
zoomBlocked: noScaling || maxScale < 2 ? viewport : '',
|
|
43
|
+
};
|
|
44
|
+
})()`;
|
|
45
|
+
|
|
46
|
+
export async function observe(page: Page): Promise<Observation> {
|
|
47
|
+
const elements = (await page.evaluate(SCAN_SCRIPT)) as InteractiveElement[];
|
|
48
|
+
const names = await accessibleNames(page);
|
|
49
|
+
if (names) for (const el of elements) {
|
|
50
|
+
const ax = names.get(el.id);
|
|
51
|
+
if (ax) el.ax = ax;
|
|
52
|
+
}
|
|
53
|
+
// Line by line, as it reads on screen, so what changed after an action can be told line by line.
|
|
54
|
+
const text = (await page.locator("body").innerText())
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join("\n")
|
|
59
|
+
.slice(0, 2000);
|
|
60
|
+
const language = (await page.evaluate(LANGUAGE_SCRIPT)) as Pick<Observation, "lang" | "dir" | "arabicShare" | "zoomBlocked">;
|
|
61
|
+
return { url: page.url(), title: await page.title(), elements, text, ...language };
|
|
62
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { ReflowCheck } from "../types";
|
|
3
|
+
|
|
4
|
+
// WCAG 1.4.10 Reflow: at 320 CSS pixels wide (a 1280-pixel screen zoomed to 400%) a page should
|
|
5
|
+
// read without scrolling sideways. Each page the low-vision agent arrives on is measured where it
|
|
6
|
+
// is: its window is narrowed to that width, measured and widened again before the agent looks.
|
|
7
|
+
// Nothing is requested twice, so no link or form is ever replayed to measure it.
|
|
8
|
+
|
|
9
|
+
export const REFLOW_WIDTH = 320;
|
|
10
|
+
const MAX_PAGES = 12;
|
|
11
|
+
/** Time for the page to lay itself out after each change of width. */
|
|
12
|
+
const LAYOUT_MS = 300;
|
|
13
|
+
|
|
14
|
+
// The page's sideways overflow, and the element reaching furthest right outside any scrolling
|
|
15
|
+
// box. A string, like SCAN_SCRIPT: tsx injects helpers into functions the page lacks.
|
|
16
|
+
const MEASURE_SCRIPT = `(() => {
|
|
17
|
+
const doc = document.documentElement;
|
|
18
|
+
const view = doc.clientWidth;
|
|
19
|
+
const overflowPx = Math.max(doc.scrollWidth, document.body ? document.body.scrollWidth : 0) - view;
|
|
20
|
+
if (overflowPx <= 2) return { overflowPx: 0, widest: '', exception: '' };
|
|
21
|
+
const inScroller = (el) => {
|
|
22
|
+
for (let p = el.parentElement; p && p !== document.body && p !== doc; p = p.parentElement) {
|
|
23
|
+
const x = getComputedStyle(p).overflowX;
|
|
24
|
+
if (x !== 'visible') return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
28
|
+
// Past the right edge, or on a right-to-left page past the left one. (A left-to-right page
|
|
29
|
+
// can't scroll left, so menus parked off its left edge don't count.)
|
|
30
|
+
const rtl = getComputedStyle(document.body).direction === 'rtl';
|
|
31
|
+
let widest = null, furthest = 2;
|
|
32
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
33
|
+
const r = el.getBoundingClientRect();
|
|
34
|
+
const out = rtl ? -r.left : r.right - view;
|
|
35
|
+
if (r.width > 0 && out > furthest && !inScroller(el)) { widest = el; furthest = out; }
|
|
36
|
+
}
|
|
37
|
+
// WCAG lets content that needs two dimensions scroll: data tables, maps, diagrams, video, code.
|
|
38
|
+
const twoD = widest && widest.closest('table, [role="grid"], [role="table"], pre, canvas, video, iframe, svg, map');
|
|
39
|
+
const name = (el) => {
|
|
40
|
+
const classes = typeof el.className === 'string' ? el.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
|
|
41
|
+
const text = (el.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
|
|
42
|
+
return el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + classes.map((c) => '.' + c).join('') + (text ? ' ("' + text + '")' : '');
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
overflowPx: Math.round(overflowPx),
|
|
46
|
+
widest: widest ? name(widest) + ', ' + Math.round(widest.getBoundingClientRect().width) + ' px wide' : '',
|
|
47
|
+
exception: twoD ? 'it is inside ' + (twoD.tagName === 'TABLE' || /grid|table/.test(twoD.getAttribute('role') || '') ? 'a data table' : 'a ' + twoD.tagName.toLowerCase()) + ', which may scroll' : '',
|
|
48
|
+
};
|
|
49
|
+
})()`;
|
|
50
|
+
|
|
51
|
+
export class ReflowMeter {
|
|
52
|
+
private readonly seen = new Set<string>();
|
|
53
|
+
readonly checks: ReflowCheck[] = [];
|
|
54
|
+
|
|
55
|
+
/** Measures the page the agent is on, the first time it arrives at each address. */
|
|
56
|
+
async measure(page: Page): Promise<void> {
|
|
57
|
+
const url = page.url().split("#")[0];
|
|
58
|
+
const size = page.viewportSize();
|
|
59
|
+
if (!size || !/^https?:/.test(url) || this.seen.has(url) || this.seen.size >= MAX_PAGES) return;
|
|
60
|
+
this.seen.add(url);
|
|
61
|
+
try {
|
|
62
|
+
await page.setViewportSize({ width: REFLOW_WIDTH, height: size.height });
|
|
63
|
+
await page.waitForTimeout(LAYOUT_MS);
|
|
64
|
+
const measured = (await page.evaluate(MEASURE_SCRIPT)) as Omit<ReflowCheck, "url" | "widthPx">;
|
|
65
|
+
this.checks.push({ url, widthPx: REFLOW_WIDTH, ...measured });
|
|
66
|
+
} catch {
|
|
67
|
+
// The page changed under the measurement: this one goes without it.
|
|
68
|
+
} finally {
|
|
69
|
+
await page.setViewportSize(size).catch(() => {});
|
|
70
|
+
await page.waitForTimeout(LAYOUT_MS);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Files an agent can attach where an app asks for one: a photo (for a post, an avatar, a logo)
|
|
2
|
+
// and a document (a CV, a brief). Made here rather than shipped, and made up: nothing real or
|
|
3
|
+
// personal is ever uploaded. Written once per process to the system's temp folder.
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { deflateSync } from "node:zlib";
|
|
8
|
+
|
|
9
|
+
export type SampleKind = "photo" | "document";
|
|
10
|
+
|
|
11
|
+
/** A square photo, large enough for social posts that refuse small images (1080 px). */
|
|
12
|
+
const PHOTO_PX = 1080;
|
|
13
|
+
|
|
14
|
+
let written: Promise<Record<SampleKind, string>> | undefined;
|
|
15
|
+
|
|
16
|
+
/** Paths to the sample files, written on first use. */
|
|
17
|
+
export function sampleFiles(): Promise<Record<SampleKind, string>> {
|
|
18
|
+
written ??= (async () => {
|
|
19
|
+
const dir = join(tmpdir(), "debutly-samples");
|
|
20
|
+
await mkdir(dir, { recursive: true });
|
|
21
|
+
const photo = join(dir, "sample-photo.png");
|
|
22
|
+
const document = join(dir, "sample-cv.pdf");
|
|
23
|
+
await writeFile(photo, png(PHOTO_PX, PHOTO_PX));
|
|
24
|
+
await writeFile(document, pdf(SAMPLE_CV));
|
|
25
|
+
return { photo, document };
|
|
26
|
+
})();
|
|
27
|
+
return written;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Which sample an upload field wants, from its `accept` attribute; a photo when it doesn't say. */
|
|
31
|
+
export function sampleFor(accept: string | null | undefined): SampleKind {
|
|
32
|
+
const a = (accept ?? "").toLowerCase();
|
|
33
|
+
if (!a || /image|\.png|\.jpe?g|\.gif|\.webp/.test(a)) return "photo";
|
|
34
|
+
if (/pdf|word|document|text|\.docx?|\.txt|\.rtf/.test(a)) return "document";
|
|
35
|
+
return "photo";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SAMPLE_CV = [
|
|
39
|
+
"Alex Sample - Curriculum Vitae",
|
|
40
|
+
"alex.sample@example.com - London, UK",
|
|
41
|
+
"",
|
|
42
|
+
"Experience",
|
|
43
|
+
"Marketing Coordinator, Example Ltd (2022 - present)",
|
|
44
|
+
"- Ran weekly social campaigns and reported on their results",
|
|
45
|
+
"Marketing Assistant, Sample & Co (2020 - 2022)",
|
|
46
|
+
"- Wrote newsletters and kept the company blog up to date",
|
|
47
|
+
"",
|
|
48
|
+
"Education",
|
|
49
|
+
"BA Business Studies, Example University (2017 - 2020)",
|
|
50
|
+
"",
|
|
51
|
+
"Skills",
|
|
52
|
+
"Copywriting, Social media, Google Analytics, Canva, Excel",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// A gradient, so image checks that refuse blank pictures accept it.
|
|
56
|
+
function png(width: number, height: number): Buffer {
|
|
57
|
+
const rows: Buffer[] = [];
|
|
58
|
+
for (let y = 0; y < height; y++) {
|
|
59
|
+
const row = Buffer.alloc(1 + width * 3);
|
|
60
|
+
for (let x = 0; x < width; x++) {
|
|
61
|
+
row[1 + x * 3] = Math.round((x / width) * 200) + 30;
|
|
62
|
+
row[2 + x * 3] = Math.round((y / height) * 160) + 60;
|
|
63
|
+
row[3 + x * 3] = 170;
|
|
64
|
+
}
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
const header = Buffer.alloc(13);
|
|
68
|
+
header.writeUInt32BE(width, 0);
|
|
69
|
+
header.writeUInt32BE(height, 4);
|
|
70
|
+
header.set([8, 2, 0, 0, 0], 8); // 8-bit RGB
|
|
71
|
+
return Buffer.concat([
|
|
72
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
73
|
+
chunk("IHDR", header),
|
|
74
|
+
chunk("IDAT", deflateSync(Buffer.concat(rows))),
|
|
75
|
+
chunk("IEND", Buffer.alloc(0)),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function chunk(type: string, data: Buffer): Buffer {
|
|
80
|
+
const length = Buffer.alloc(4);
|
|
81
|
+
length.writeUInt32BE(data.length);
|
|
82
|
+
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
83
|
+
const crc = Buffer.alloc(4);
|
|
84
|
+
crc.writeUInt32BE(crc32(body));
|
|
85
|
+
return Buffer.concat([length, body, crc]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function crc32(bytes: Buffer): number {
|
|
89
|
+
let c = ~0;
|
|
90
|
+
for (const b of bytes) {
|
|
91
|
+
c ^= b;
|
|
92
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
|
|
93
|
+
}
|
|
94
|
+
return ~c >>> 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A one-page PDF with real text, so apps that read a CV's text find some.
|
|
98
|
+
function pdf(lines: string[]): Buffer {
|
|
99
|
+
const escape = (s: string) => s.replace(/[\\()]/g, (m) => `\\${m}`);
|
|
100
|
+
const text = ["BT", "/F1 12 Tf", "16 TL", "56 780 Td", ...lines.map((l) => `(${escape(l)}) '`), "ET"].join("\n");
|
|
101
|
+
const objects = [
|
|
102
|
+
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
103
|
+
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
104
|
+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
|
105
|
+
`<< /Length ${Buffer.byteLength(text)} >>\nstream\n${text}\nendstream`,
|
|
106
|
+
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
107
|
+
];
|
|
108
|
+
let out = "%PDF-1.4\n";
|
|
109
|
+
const offsets: number[] = [];
|
|
110
|
+
objects.forEach((body, i) => {
|
|
111
|
+
offsets.push(Buffer.byteLength(out));
|
|
112
|
+
out += `${i + 1} 0 obj\n${body}\nendobj\n`;
|
|
113
|
+
});
|
|
114
|
+
const xref = Buffer.byteLength(out);
|
|
115
|
+
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
116
|
+
out += offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
|
|
117
|
+
out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
118
|
+
return Buffer.from(out, "latin1");
|
|
119
|
+
}
|