@a11y-pulse/tab-orchestrator 0.1.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.md +166 -0
- package/README.md +93 -0
- package/dist/adaptor.d.ts +47 -0
- package/dist/adaptors/puppeteer.d.ts +14 -0
- package/dist/adaptors/puppeteer.js +52 -0
- package/dist/adaptors/puppeteer.js.map +1 -0
- package/dist/audit-scaffold.d.ts +50 -0
- package/dist/browser-scripts.d.ts +148 -0
- package/dist/clip.d.ts +7 -0
- package/dist/context-signals.d.ts +7 -0
- package/dist/focus-style.d.ts +5 -0
- package/dist/get-selector.d.ts +11 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1312 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestrator.d.ts +19 -0
- package/dist/truncate-html.d.ts +9 -0
- package/dist/types.d.ts +65 -0
- package/dist/unfocused-pair.d.ts +14 -0
- package/package.json +63 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Copyright (c) 2026-present, A11y Pulse Limited (https://www.a11ypulse.com/). All rights reserved.
|
|
2
|
+
|
|
3
|
+
# PolyForm Shield License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/shield/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree
|
|
10
|
+
to them as both strict obligations and conditions to all
|
|
11
|
+
your licenses.
|
|
12
|
+
|
|
13
|
+
## Copyright License
|
|
14
|
+
|
|
15
|
+
The licensor grants you a copyright license for the
|
|
16
|
+
software to do everything you might do with the software
|
|
17
|
+
that would otherwise infringe the licensor's copyright
|
|
18
|
+
in it for any permitted purpose. However, you may
|
|
19
|
+
only distribute the software according to [Distribution
|
|
20
|
+
License](#distribution-license) and make changes or new works
|
|
21
|
+
based on the software according to [Changes and New Works
|
|
22
|
+
License](#changes-and-new-works-license).
|
|
23
|
+
|
|
24
|
+
## Distribution License
|
|
25
|
+
|
|
26
|
+
The licensor grants you an additional copyright license
|
|
27
|
+
to distribute copies of the software. Your license
|
|
28
|
+
to distribute covers distributing the software with
|
|
29
|
+
changes and new works permitted by [Changes and New Works
|
|
30
|
+
License](#changes-and-new-works-license).
|
|
31
|
+
|
|
32
|
+
## Notices
|
|
33
|
+
|
|
34
|
+
You must ensure that anyone who gets a copy of any part of
|
|
35
|
+
the software from you also gets a copy of these terms or the
|
|
36
|
+
URL for them above, as well as copies of any plain-text lines
|
|
37
|
+
beginning with `Required Notice:` that the licensor provided
|
|
38
|
+
with the software. For example:
|
|
39
|
+
|
|
40
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
41
|
+
|
|
42
|
+
## Changes and New Works License
|
|
43
|
+
|
|
44
|
+
The licensor grants you an additional copyright license to
|
|
45
|
+
make changes and new works based on the software for any
|
|
46
|
+
permitted purpose.
|
|
47
|
+
|
|
48
|
+
## Patent License
|
|
49
|
+
|
|
50
|
+
The licensor grants you a patent license for the software that
|
|
51
|
+
covers patent claims the licensor can license, or becomes able
|
|
52
|
+
to license, that you would infringe by using the software.
|
|
53
|
+
|
|
54
|
+
## Noncompete
|
|
55
|
+
|
|
56
|
+
Any purpose is a permitted purpose, except for providing any
|
|
57
|
+
product that competes with the software or any product the
|
|
58
|
+
licensor or any of its affiliates provides using the software.
|
|
59
|
+
|
|
60
|
+
## Competition
|
|
61
|
+
|
|
62
|
+
Goods and services compete even when they provide functionality
|
|
63
|
+
through different kinds of interfaces or for different technical
|
|
64
|
+
platforms. Applications can compete with services, libraries
|
|
65
|
+
with plugins, frameworks with development tools, and so on,
|
|
66
|
+
even if they're written in different programming languages
|
|
67
|
+
or for different computer architectures. Goods and services
|
|
68
|
+
compete even when provided free of charge. If you market a
|
|
69
|
+
product as a practical substitute for the software or another
|
|
70
|
+
product, it definitely competes.
|
|
71
|
+
|
|
72
|
+
## New Products
|
|
73
|
+
|
|
74
|
+
If you are using the software to provide a product that does
|
|
75
|
+
not compete, but the licensor or any of its affiliates brings
|
|
76
|
+
your product into competition by providing a new version of
|
|
77
|
+
the software or another product using the software, you may
|
|
78
|
+
continue using versions of the software available under these
|
|
79
|
+
terms beforehand to provide your competing product, but not
|
|
80
|
+
any later versions.
|
|
81
|
+
|
|
82
|
+
## Discontinued Products
|
|
83
|
+
|
|
84
|
+
You may begin using the software to compete with a product
|
|
85
|
+
or service that the licensor or any of its affiliates has
|
|
86
|
+
stopped providing, unless the licensor includes a plain-text
|
|
87
|
+
line beginning with `Licensor Line of Business:` with the
|
|
88
|
+
software that mentions that line of business. For example:
|
|
89
|
+
|
|
90
|
+
> Licensor Line of Business: YoyodyneCMS Content Management
|
|
91
|
+
System (http://example.com/cms)
|
|
92
|
+
|
|
93
|
+
## Sales of Business
|
|
94
|
+
|
|
95
|
+
If the licensor or any of its affiliates sells a line of
|
|
96
|
+
business developing the software or using the software
|
|
97
|
+
to provide a product, the buyer can also enforce
|
|
98
|
+
[Noncompete](#noncompete) for that product.
|
|
99
|
+
|
|
100
|
+
## Fair Use
|
|
101
|
+
|
|
102
|
+
You may have "fair use" rights for the software under the
|
|
103
|
+
law. These terms do not limit them.
|
|
104
|
+
|
|
105
|
+
## No Other Rights
|
|
106
|
+
|
|
107
|
+
These terms do not allow you to sublicense or transfer any of
|
|
108
|
+
your licenses to anyone else, or prevent the licensor from
|
|
109
|
+
granting licenses to anyone else. These terms do not imply
|
|
110
|
+
any other licenses.
|
|
111
|
+
|
|
112
|
+
## Patent Defense
|
|
113
|
+
|
|
114
|
+
If you make any written claim that the software infringes or
|
|
115
|
+
contributes to infringement of any patent, your patent license
|
|
116
|
+
for the software granted under these terms ends immediately. If
|
|
117
|
+
your company makes such a claim, your patent license ends
|
|
118
|
+
immediately for work on behalf of your company.
|
|
119
|
+
|
|
120
|
+
## Violations
|
|
121
|
+
|
|
122
|
+
The first time you are notified in writing that you have
|
|
123
|
+
violated any of these terms, or done anything with the software
|
|
124
|
+
not covered by your licenses, your licenses can nonetheless
|
|
125
|
+
continue if you come into full compliance with these terms,
|
|
126
|
+
and take practical steps to correct past violations, within
|
|
127
|
+
32 days of receiving notice. Otherwise, all your licenses
|
|
128
|
+
end immediately.
|
|
129
|
+
|
|
130
|
+
## No Liability
|
|
131
|
+
|
|
132
|
+
***As far as the law allows, the software comes as is, without
|
|
133
|
+
any warranty or condition, and the licensor will not be liable
|
|
134
|
+
to you for any damages arising out of these terms or the use
|
|
135
|
+
or nature of the software, under any kind of legal claim.***
|
|
136
|
+
|
|
137
|
+
## Definitions
|
|
138
|
+
|
|
139
|
+
The **licensor** is the individual or entity offering these
|
|
140
|
+
terms, and the **software** is the software the licensor makes
|
|
141
|
+
available under these terms.
|
|
142
|
+
|
|
143
|
+
A **product** can be a good or service, or a combination
|
|
144
|
+
of them.
|
|
145
|
+
|
|
146
|
+
**You** refers to the individual or entity agreeing to these
|
|
147
|
+
terms.
|
|
148
|
+
|
|
149
|
+
**Your company** is any legal entity, sole proprietorship,
|
|
150
|
+
or other kind of organization that you work for, plus all
|
|
151
|
+
its affiliates.
|
|
152
|
+
|
|
153
|
+
**Affiliates** means the other organizations than an
|
|
154
|
+
organization has control over, is under the control of, or is
|
|
155
|
+
under common control with.
|
|
156
|
+
|
|
157
|
+
**Control** means ownership of substantially all the assets of
|
|
158
|
+
an entity, or the power to direct its management and policies
|
|
159
|
+
by vote, contract, or otherwise. Control can be direct or
|
|
160
|
+
indirect.
|
|
161
|
+
|
|
162
|
+
**Your licenses** are all the licenses granted to you for the
|
|
163
|
+
software under these terms.
|
|
164
|
+
|
|
165
|
+
**Use** means anything you do with the software requiring one
|
|
166
|
+
of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @a11y-pulse/tab-orchestrator
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@a11y-pulse/tab-orchestrator)
|
|
4
|
+
[](https://github.com/A11y-Pulse/audits/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE.md)
|
|
6
|
+
|
|
7
|
+
A shared Tab-session runner for A11y Pulse's keyboard-driven accessibility audits. It drives a page through its focusable elements once — pressing Tab, tracking visited elements, capturing screenshots and styles on demand — while one or more **consumers** each score the tab stops for their own WCAG success criterion. This lets audits like [`@a11y-pulse/focus-appearance-audit`](../focus-appearance-audit) (WCAG 2.4.7) share a single tab loop with sibling audits instead of each re-tabbing the page from scratch.
|
|
8
|
+
|
|
9
|
+
It is not an audit itself: it has no opinion on what "passing" means. It only owns the mechanics of moving focus, tracking cycle completion, and capturing the primitives (computed styles, clipped screenshots, unfocused/focused pairs) that consumers need.
|
|
10
|
+
|
|
11
|
+
This package was developed by [A11y Pulse](https://www.a11ypulse.com/) for its accessibility monitoring service. It is released as source-available under the [PolyForm Shield License 1.0.0](#license).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @a11y-pulse/tab-orchestrator
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Puppeteer is an optional peer dependency, needed only if you use the bundled [`PuppeteerAdaptor`](#puppeteeradaptor).
|
|
20
|
+
|
|
21
|
+
## `createTabOrchestrator`
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createTabOrchestrator } from "@a11y-pulse/tab-orchestrator";
|
|
25
|
+
|
|
26
|
+
const orchestrator = createTabOrchestrator(adaptor, sessionOptions);
|
|
27
|
+
|
|
28
|
+
orchestrator.attach(consumerA);
|
|
29
|
+
orchestrator.attach(consumerB);
|
|
30
|
+
|
|
31
|
+
await orchestrator.run();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `createTabOrchestrator(adaptor: BrowserAdaptor, options?: TabSessionOptions)` builds a session against a single page, driven through the given [`BrowserAdaptor`](#browseradaptor). `TabSessionOptions` covers shared cost controls (`screenshotSettleDelay`, `screenshotClipBuffer`, `markerLimit`, `baselineElementLimit`), not any particular audit's policy.
|
|
35
|
+
- `.attach(consumer)` registers a `TabConsumer`. Attaching after `run()` has started throws.
|
|
36
|
+
- `.run()` presses Tab repeatedly, dispatching each `TabStopSnapshot` to every still-attached consumer, until either a full tab cycle completes, focus is lost, navigation happens, or every consumer has disconnected. Calling it twice throws; calling it with zero attached consumers returns immediately without tabbing.
|
|
37
|
+
|
|
38
|
+
Each consumer declares the `capabilities` it needs (e.g. `baselineStyles`, `unfocusedPair`) and can disconnect independently once it has what it needs (say, after hitting its own element limit) — the orchestrator keeps driving the remaining consumers. Per-stop work like unfocused-pair screenshots is only captured if a still-attached consumer actually asks for it that stop.
|
|
39
|
+
|
|
40
|
+
Most callers won't build a `TabConsumer` by hand — audit packages export a `createXAudit(options)` helper that returns one, plus a `runXAudit(adaptor, options)` convenience wrapper that builds a private single-consumer orchestrator for you. See [`@a11y-pulse/focus-appearance-audit`'s "Shared tab session"](../focus-appearance-audit#shared-tab-session) section for a worked example.
|
|
41
|
+
|
|
42
|
+
## `BrowserAdaptor`
|
|
43
|
+
|
|
44
|
+
The orchestrator drives a page through an **adaptor**: a small interface of primitives (evaluate JS in the page, press Tab, take a clipped screenshot, etc.) so it never needs to know which browser automation library is behind it.
|
|
45
|
+
|
|
46
|
+
| Method | Description |
|
|
47
|
+
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| `evaluate(fn, ...args)` | Runs `fn` in the page context, passing in any serialisable `args`, and returns its result. |
|
|
49
|
+
| `evaluateHandle(fn)` | Runs `fn` in the page context and returns an opaque `ElementRef` handle to the `Element` it returns, without serialising it. |
|
|
50
|
+
| `disposeRef(ref)` | Releases a handle previously returned by `evaluateHandle`. |
|
|
51
|
+
| `pressTab()` | Presses the Tab key, advancing focus to the next focusable element. |
|
|
52
|
+
| `screenshotClip(clip)` | Screenshots a clipped region of the page (`{ x, y, width, height }`) and returns PNG bytes. |
|
|
53
|
+
| `ensureFocusReporting()` | Ensures the page reports focus for its lifetime — in particular that `document.hasFocus()` works and `:focus` styles apply, even when the page is not the foreground tab/window. Must not throw. |
|
|
54
|
+
|
|
55
|
+
The orchestrator is the only thing that mutates the page through this adaptor during a session; consumers score snapshots, they don't call `pressTab`, take screenshots, or install observers themselves.
|
|
56
|
+
|
|
57
|
+
### `PuppeteerAdaptor`
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import { createTabOrchestrator } from "@a11y-pulse/tab-orchestrator";
|
|
61
|
+
import { PuppeteerAdaptor } from "@a11y-pulse/tab-orchestrator/puppeteer";
|
|
62
|
+
|
|
63
|
+
const orchestrator = createTabOrchestrator(new PuppeteerAdaptor(page));
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The package ships one implementation, `PuppeteerAdaptor`, backed by a Puppeteer `Page`, exported from the `./puppeteer` subpath so consumers that only need the type-level `BrowserAdaptor` interface aren't forced to import Puppeteer. Other environments (Playwright, Selenium, WebDriver) can be supported by implementing `BrowserAdaptor` against your automation library's page/session object; use [`src/adaptors/puppeteer.ts`](./src/adaptors/puppeteer.ts) as a reference implementation.
|
|
67
|
+
|
|
68
|
+
## Releasing
|
|
69
|
+
|
|
70
|
+
Releases are managed in the [A11y-Pulse/audits](https://github.com/A11y-Pulse/audits) monorepo with [Changesets](https://github.com/changesets/changesets). Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) (OIDC). There is no long-lived `NPM_TOKEN`.
|
|
71
|
+
|
|
72
|
+
### Ship a change
|
|
73
|
+
|
|
74
|
+
1. Open a PR against `main` that includes a changeset (`npx changeset`) naming `@a11y-pulse/tab-orchestrator`.
|
|
75
|
+
2. After merge, the Release workflow opens a Version PR. Merging that PR publishes this package to npm and tags `@a11y-pulse/tab-orchestrator@<version>`.
|
|
76
|
+
|
|
77
|
+
Trusted Publisher on npm must stay configured for:
|
|
78
|
+
|
|
79
|
+
| Field | Value |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| Organization or user | `A11y-Pulse` |
|
|
82
|
+
| Repository | `audits` |
|
|
83
|
+
| Workflow filename | `release.yml` |
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
Released under the [PolyForm Shield License 1.0.0](./LICENSE.md), in plain language:
|
|
88
|
+
|
|
89
|
+
- **Source-available.** The source is public and you can read, fork, and modify it.
|
|
90
|
+
- **Permitted for non-competing use.** You can use this package freely in your own products and services, as long as they don't compete with A11y Pulse.
|
|
91
|
+
- **Competing products are forbidden.** You may not use this software (or a modified version of it) to build a product or service that competes with A11y Pulse's accessibility monitoring offering.
|
|
92
|
+
|
|
93
|
+
See [LICENSE.md](./LICENSE.md) for the full, binding terms.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type Rect = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* An element handle in the page. This type is adaptor-specific and is opaque to the audit.
|
|
9
|
+
*/
|
|
10
|
+
export type ElementRef = unknown;
|
|
11
|
+
/**
|
|
12
|
+
* A framework-specific adaptor that provides the primitives needed by the audit. An adaptor wraps
|
|
13
|
+
* a single page in a web browser.
|
|
14
|
+
*/
|
|
15
|
+
export interface BrowserAdaptor {
|
|
16
|
+
/**
|
|
17
|
+
* Run `fn` in the page context, passing in any serialisable `args`
|
|
18
|
+
*/
|
|
19
|
+
evaluate<T>(fn: (...args: any[]) => T | Promise<T>, ...args: unknown[]): Promise<T>;
|
|
20
|
+
/**
|
|
21
|
+
* Return an opaque handle to an element in the page
|
|
22
|
+
*/
|
|
23
|
+
evaluateHandle(fn: () => Element | null): Promise<ElementRef>;
|
|
24
|
+
/**
|
|
25
|
+
* Release a handle previously returned by evaluateHandle
|
|
26
|
+
*/
|
|
27
|
+
disposeRef(ref: ElementRef): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Press the tab key
|
|
30
|
+
*/
|
|
31
|
+
pressTab(): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Screenshot a clipped region of the page. `scale` is the device scale
|
|
34
|
+
* factor (bitmap pixels per CSS pixel).
|
|
35
|
+
*/
|
|
36
|
+
screenshotClip(clip: Rect, scale?: number): Promise<Uint8Array>;
|
|
37
|
+
/** Device scale factor for evidence screenshots stored on failure. */
|
|
38
|
+
readonly screenshotClipScale?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Ensure the page reports focus for its lifetime. In particular, `document.hasFocus()` must
|
|
41
|
+
* function correctly and :focus styles must be applied, even when the page is not the
|
|
42
|
+
* foreground.
|
|
43
|
+
*
|
|
44
|
+
* This function must not throw.
|
|
45
|
+
*/
|
|
46
|
+
ensureFocusReporting(): Promise<void>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Page } from "puppeteer";
|
|
2
|
+
import type { BrowserAdaptor, ElementRef, Rect } from "../adaptor";
|
|
3
|
+
/** A BrowserAdaptor backed by a Puppeteer Page. */
|
|
4
|
+
export declare class PuppeteerAdaptor implements BrowserAdaptor {
|
|
5
|
+
private readonly page;
|
|
6
|
+
readonly screenshotClipScale = 2;
|
|
7
|
+
constructor(page: Page);
|
|
8
|
+
evaluate<T>(fn: (...args: any[]) => T | Promise<T>, ...args: unknown[]): Promise<T>;
|
|
9
|
+
evaluateHandle(fn: () => Element | null): Promise<ElementRef>;
|
|
10
|
+
disposeRef(ref: ElementRef): Promise<void>;
|
|
11
|
+
pressTab(): Promise<void>;
|
|
12
|
+
screenshotClip(clip: Rect, scale?: number): Promise<Uint8Array>;
|
|
13
|
+
ensureFocusReporting(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/adaptors/puppeteer.ts
|
|
2
|
+
var focusEmulationEnabled = /* @__PURE__ */ new WeakSet();
|
|
3
|
+
var PuppeteerAdaptor = class {
|
|
4
|
+
constructor(page) {
|
|
5
|
+
this.page = page;
|
|
6
|
+
}
|
|
7
|
+
page;
|
|
8
|
+
screenshotClipScale = 2;
|
|
9
|
+
evaluate(fn, ...args) {
|
|
10
|
+
return this.page.evaluate(fn, ...args);
|
|
11
|
+
}
|
|
12
|
+
evaluateHandle(fn) {
|
|
13
|
+
return this.page.evaluateHandle(fn);
|
|
14
|
+
}
|
|
15
|
+
async disposeRef(ref) {
|
|
16
|
+
await ref.dispose();
|
|
17
|
+
}
|
|
18
|
+
async pressTab() {
|
|
19
|
+
await this.page.keyboard.press("Tab");
|
|
20
|
+
}
|
|
21
|
+
async screenshotClip(clip, scale = 1) {
|
|
22
|
+
return await this.page.screenshot({
|
|
23
|
+
type: "png",
|
|
24
|
+
optimizeForSpeed: true,
|
|
25
|
+
clip: {
|
|
26
|
+
...clip,
|
|
27
|
+
scale
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async ensureFocusReporting() {
|
|
32
|
+
if (focusEmulationEnabled.has(this.page)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const client = await this.page.createCDPSession();
|
|
37
|
+
await client.send("Emulation.setFocusEmulationEnabled", {
|
|
38
|
+
enabled: true
|
|
39
|
+
});
|
|
40
|
+
focusEmulationEnabled.add(this.page);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.warn(
|
|
43
|
+
"Could not enable focus emulation for the tab orchestrator",
|
|
44
|
+
{ error }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
export {
|
|
50
|
+
PuppeteerAdaptor
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=puppeteer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adaptors/puppeteer.ts"],"sourcesContent":["import type { JSHandle, Page } from \"puppeteer\";\nimport type { BrowserAdaptor, ElementRef, Rect } from \"../adaptor\";\n\n// Pages that already have focus emulation enabled. A fresh adaptor is created per\n// audit run, but a page is often reused across runs (e.g. one page per snapshot,\n// many checks), so without this each run would open another CDP session that is\n// never detached. The emulation persists for the page's lifetime, so enabling it\n// once is enough. (Detaching the session instead is not an option — Chrome clears\n// the emulation when the CDP client disconnects.)\nconst focusEmulationEnabled = new WeakSet<Page>();\n\n/** A BrowserAdaptor backed by a Puppeteer Page. */\nexport class PuppeteerAdaptor implements BrowserAdaptor {\n\treadonly screenshotClipScale = 2;\n\n\tconstructor(private readonly page: Page) {}\n\n\tevaluate<T>(\n\t\t// biome-ignore lint/suspicious/noExplicitAny: the page-evaluated fn accepts arbitrary serialised args and element handles.\n\t\tfn: (...args: any[]) => T | Promise<T>,\n\t\t...args: unknown[]\n\t): Promise<T> {\n\t\t// Puppeteer resolves any JSHandle passed as an arg to the live element.\n\t\treturn this.page.evaluate(fn as never, ...(args as never[])) as Promise<T>;\n\t}\n\n\tevaluateHandle(fn: () => Element | null): Promise<ElementRef> {\n\t\treturn this.page.evaluateHandle(fn);\n\t}\n\n\tasync disposeRef(ref: ElementRef): Promise<void> {\n\t\tawait (ref as JSHandle).dispose();\n\t}\n\n\tasync pressTab(): Promise<void> {\n\t\tawait this.page.keyboard.press(\"Tab\");\n\t}\n\n\tasync screenshotClip(clip: Rect, scale = 1): Promise<Uint8Array> {\n\t\treturn (await this.page.screenshot({\n\t\t\ttype: \"png\",\n\t\t\toptimizeForSpeed: true,\n\t\t\tclip: {\n\t\t\t\t...clip,\n\t\t\t\tscale,\n\t\t\t},\n\t\t})) as Uint8Array;\n\t}\n\n\tasync ensureFocusReporting(): Promise<void> {\n\t\t// Pages only report focus while they are the foreground tab. Since this browser instance\n\t\t// is likely running in the background, we need to enable focus emulation to get accurate\n\t\t// results.\n\t\t//\n\t\t// Note this function does not throw if enabling emulation fails; it is a best-effort.\n\t\tif (focusEmulationEnabled.has(this.page)) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tconst client = await this.page.createCDPSession();\n\t\t\tawait client.send(\"Emulation.setFocusEmulationEnabled\", {\n\t\t\t\tenabled: true,\n\t\t\t});\n\t\t\tfocusEmulationEnabled.add(this.page);\n\t\t} catch (error) {\n\t\t\tconsole.warn(\n\t\t\t\t\"Could not enable focus emulation for the tab orchestrator\",\n\t\t\t\t{ error },\n\t\t\t);\n\t\t}\n\t}\n}\n"],"mappings":";AASA,IAAM,wBAAwB,oBAAI,QAAc;AAGzC,IAAM,mBAAN,MAAiD;AAAA,EAGvD,YAA6B,MAAY;AAAZ;AAAA,EAAa;AAAA,EAAb;AAAA,EAFpB,sBAAsB;AAAA,EAI/B,SAEC,OACG,MACU;AAEb,WAAO,KAAK,KAAK,SAAS,IAAa,GAAI,IAAgB;AAAA,EAC5D;AAAA,EAEA,eAAe,IAA+C;AAC7D,WAAO,KAAK,KAAK,eAAe,EAAE;AAAA,EACnC;AAAA,EAEA,MAAM,WAAW,KAAgC;AAChD,UAAO,IAAiB,QAAQ;AAAA,EACjC;AAAA,EAEA,MAAM,WAA0B;AAC/B,UAAM,KAAK,KAAK,SAAS,MAAM,KAAK;AAAA,EACrC;AAAA,EAEA,MAAM,eAAe,MAAY,QAAQ,GAAwB;AAChE,WAAQ,MAAM,KAAK,KAAK,WAAW;AAAA,MAClC,MAAM;AAAA,MACN,kBAAkB;AAAA,MAClB,MAAM;AAAA,QACL,GAAG;AAAA,QACH;AAAA,MACD;AAAA,IACD,CAAC;AAAA,EACF;AAAA,EAEA,MAAM,uBAAsC;AAM3C,QAAI,sBAAsB,IAAI,KAAK,IAAI,GAAG;AACzC;AAAA,IACD;AAEA,QAAI;AACH,YAAM,SAAS,MAAM,KAAK,KAAK,iBAAiB;AAChD,YAAM,OAAO,KAAK,sCAAsC;AAAA,QACvD,SAAS;AAAA,MACV,CAAC;AACD,4BAAsB,IAAI,KAAK,IAAI;AAAA,IACpC,SAAS,OAAO;AACf,cAAQ;AAAA,QACP;AAAA,QACA,EAAE,MAAM;AAAA,MACT;AAAA,IACD;AAAA,EACD;AACD;","names":[]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { SessionEndReason, TabSessionHandle } from "./types";
|
|
2
|
+
export declare const DEFAULT_ELEMENT_LIMIT = 1024;
|
|
3
|
+
export declare const DEFAULT_SCREENSHOT_SETTLE_DELAY = 33;
|
|
4
|
+
export declare const DEFAULT_FAILED_ELEMENT_LIMIT = 0;
|
|
5
|
+
export declare const DEFAULT_TIMEOUT = 0;
|
|
6
|
+
export type BaseAuditOptions = {
|
|
7
|
+
/** Max focusable elements to tab through. */
|
|
8
|
+
elementLimit?: number;
|
|
9
|
+
/** How long to wait (ms) after each Tab for the page to settle. */
|
|
10
|
+
screenshotSettleDelay?: number;
|
|
11
|
+
/** Disconnect early after this many elements fail (0 = never). */
|
|
12
|
+
failedElementLimit?: number;
|
|
13
|
+
/** Return whatever's gathered so far after this many ms (0 = no timeout). */
|
|
14
|
+
timeout?: number;
|
|
15
|
+
};
|
|
16
|
+
export type BaseAuditSummary = {
|
|
17
|
+
checked: number;
|
|
18
|
+
passed: number;
|
|
19
|
+
failed: number;
|
|
20
|
+
/** True if the element limit was hit before tabbing finished. */
|
|
21
|
+
reachedLimit: boolean;
|
|
22
|
+
/** True if the audit stopped early after hitting `failedElementLimit`. */
|
|
23
|
+
reachedFailedElementLimit: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* True if the audit returned early because `timeout` elapsed. The results
|
|
26
|
+
* are whatever had been gathered when the deadline hit.
|
|
27
|
+
*/
|
|
28
|
+
timedOut: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Why the tab session ended, or `null` when this consumer disconnected
|
|
31
|
+
* itself (element limit, failed-element limit, or timeout).
|
|
32
|
+
*/
|
|
33
|
+
sessionEnd: SessionEndReason | null;
|
|
34
|
+
};
|
|
35
|
+
export type AuditSelfDisconnect = {
|
|
36
|
+
/** True once this consumer has disconnected itself from the session. */
|
|
37
|
+
readonly disconnectedSelf: boolean;
|
|
38
|
+
/** Arm a timeout that calls `onTimeout` then disconnects, if `timeout` is greater than 0. */
|
|
39
|
+
armTimeout(timeout: number, session: TabSessionHandle, onTimeout: () => void): void;
|
|
40
|
+
/** Disconnect the session and record that this consumer initiated it. */
|
|
41
|
+
disconnect(session: TabSessionHandle): void;
|
|
42
|
+
/** Clear any pending timeout without disconnecting. */
|
|
43
|
+
clear(): void;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Arms an optional self-disconnecting timeout and tracks whether this
|
|
47
|
+
* consumer initiated its own disconnect, so `onSessionEnd` can tell that
|
|
48
|
+
* apart from an external end.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createAuditSelfDisconnect(): AuditSelfDisconnect;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IMPORTANT: Functions in this file are serialized and injected into the audited page, so they
|
|
3
|
+
* must not reference any symbols outside their own scope. This also means they cannot share code,
|
|
4
|
+
* so any common logic (like descending into shadow roots) must be duplicated.
|
|
5
|
+
*/
|
|
6
|
+
import type { Rect } from "./adaptor";
|
|
7
|
+
import type { StyleSnapshot } from "./types";
|
|
8
|
+
type BaselineElementEntry = {
|
|
9
|
+
index: number;
|
|
10
|
+
/** Position of this element's snapshot in the payload's `styles` table. */
|
|
11
|
+
styleIndex: number;
|
|
12
|
+
/** The element's page-relative rect in its unfocused (baseline) state. */
|
|
13
|
+
rect: Rect;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The baseline as a table of unique style snapshots plus per-element entries referencing them.
|
|
17
|
+
* Elements overwhelmingly share identical snapshots (same classes), so interning keeps the
|
|
18
|
+
* serialised payload O(unique styles) instead of O(elements × style size).
|
|
19
|
+
*/
|
|
20
|
+
export type BaselinePayload = {
|
|
21
|
+
styles: StyleSnapshot[];
|
|
22
|
+
entries: BaselineElementEntry[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Find up to `limit` focusable elements and mark them with `markerAttr` and a unique index. Then
|
|
26
|
+
* return a snapshot of their styles to use as a baseline.
|
|
27
|
+
*/
|
|
28
|
+
export declare function baselineScript(props: string[], markerAttr: string, limit: number): BaselinePayload;
|
|
29
|
+
export type ActiveElementBase = {
|
|
30
|
+
index: number | null;
|
|
31
|
+
isBody: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The focused element is an <iframe>. Tabbing into a frame passes focus into
|
|
34
|
+
* its embedded document, but the parent's document.activeElement is the frame
|
|
35
|
+
* element itself, so the loop uses this to skip the frame as a tab stop.
|
|
36
|
+
*/
|
|
37
|
+
isIframe: boolean;
|
|
38
|
+
html: string;
|
|
39
|
+
styles: StyleSnapshot;
|
|
40
|
+
rect: Rect;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Read the currently focused element (descending open shadow roots), returning
|
|
44
|
+
* its marker index, opening-tag HTML (marker attribute stripped), focus-relevant
|
|
45
|
+
* styles, and page-relative rect.
|
|
46
|
+
*/
|
|
47
|
+
export declare function probeActiveElementScript(props: string[], markerAttr: string): ActiveElementBase;
|
|
48
|
+
export declare function activeElementHandleScript(): Element | null;
|
|
49
|
+
export declare function pageDimensionsScript(): {
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
};
|
|
53
|
+
export declare function elementRectScript(el: Element | null): Rect;
|
|
54
|
+
/**
|
|
55
|
+
* Whether something unrelated (e.g. a fixed cookie banner) covers the element's centre point, so
|
|
56
|
+
* a screenshot of the element's box would show the covering element instead.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isCenterObscuredScript(el: Element | null): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Scroll the element to the centre of the viewport, instantly so a screenshot taken immediately
|
|
61
|
+
* afterwards isn't captured mid-animation.
|
|
62
|
+
*/
|
|
63
|
+
export declare function scrollToCenterScript(el: Element | null): void;
|
|
64
|
+
/**
|
|
65
|
+
* Blur (unfocus) an element
|
|
66
|
+
*/
|
|
67
|
+
export declare function blurScript(el: Element | null): void;
|
|
68
|
+
/**
|
|
69
|
+
* Focus an element
|
|
70
|
+
*/
|
|
71
|
+
export declare function focusScript(el: Element | null): void;
|
|
72
|
+
/**
|
|
73
|
+
* Read focus-relevant computed styles from a given element. Unlike
|
|
74
|
+
* `probeActiveElementScript`, this does not use `document.activeElement`, so it
|
|
75
|
+
* still works after the node has been blurred.
|
|
76
|
+
*/
|
|
77
|
+
export declare function elementStylesScript(el: Element | null, props: string[]): StyleSnapshot;
|
|
78
|
+
/**
|
|
79
|
+
* Remove the marker attribute from all elements and blur the active element. This is a best-effort
|
|
80
|
+
* attempt to reset state between audits.
|
|
81
|
+
*/
|
|
82
|
+
export declare function clearMarkersScript(markerAttr: string): void;
|
|
83
|
+
export type MeasureObscuringResult = {
|
|
84
|
+
coveredFraction: number;
|
|
85
|
+
fullyObscured: boolean;
|
|
86
|
+
offscreen: boolean;
|
|
87
|
+
opacity: "opaque" | "semi-transparent" | "unknown";
|
|
88
|
+
obscuredByHtml: string | null;
|
|
89
|
+
/** True when a covering element was marked for selector resolution. */
|
|
90
|
+
hasObscurer: boolean;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Hit-test whether author-created content entirely hides the focused element.
|
|
94
|
+
* Marks a single covering element with `obscurerAttr` when containment confirms
|
|
95
|
+
* a full cover, so the host can resolve its selector via getSelector.
|
|
96
|
+
*/
|
|
97
|
+
export declare function measureObscuringScript(el: Element | null, obscurerAttr: string): MeasureObscuringResult;
|
|
98
|
+
/** Return the element marked as the obscurer, if any. */
|
|
99
|
+
export declare function obscurerHandleScript(): Element | null;
|
|
100
|
+
export declare function clearObscurerScript(obscurerAttr: string): void;
|
|
101
|
+
type ContextObserverState = {
|
|
102
|
+
baselineHref: string;
|
|
103
|
+
openedWindow: boolean;
|
|
104
|
+
submittedForm: boolean;
|
|
105
|
+
/** Elements that received focusin since the last drain. */
|
|
106
|
+
focusIns: Element[];
|
|
107
|
+
softUrlChange: boolean;
|
|
108
|
+
installed: boolean;
|
|
109
|
+
};
|
|
110
|
+
declare global {
|
|
111
|
+
interface Window {
|
|
112
|
+
__a11yContextObserver?: ContextObserverState;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Install once: wrap window.open (record + inert stub), capture-phase submit
|
|
117
|
+
* (record + preventDefault), track focusin and soft URL changes.
|
|
118
|
+
*/
|
|
119
|
+
export declare function installContextObserverScript(): void;
|
|
120
|
+
export type DrainContextObserverResult = {
|
|
121
|
+
openedWindow: boolean;
|
|
122
|
+
submittedForm: boolean;
|
|
123
|
+
focusRemoved: boolean;
|
|
124
|
+
redirect: "outside" | "same-subtree" | null;
|
|
125
|
+
softUrlChange: boolean;
|
|
126
|
+
navigation: boolean;
|
|
127
|
+
attributedHtml: string | null;
|
|
128
|
+
/** True when an attributed element was marked for selector resolution. */
|
|
129
|
+
hasAttributed: boolean;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Drain observer signals for the current tab stop and reset per-stop flags.
|
|
133
|
+
* Classification of focus removal / redirection uses the post-Tab settle
|
|
134
|
+
* activeElement and the focusin history from this stop only.
|
|
135
|
+
*/
|
|
136
|
+
export declare function drainContextObserverScript(markerAttr: string): DrainContextObserverResult;
|
|
137
|
+
/** Return the element marked as attributed by drainContextObserverScript (the
|
|
138
|
+
* element that received a focus event this stop and then lost it), if any. */
|
|
139
|
+
export declare function attributedHandleScript(): Element | null;
|
|
140
|
+
export declare function clearAttributedScript(): void;
|
|
141
|
+
/**
|
|
142
|
+
* Drop focusin history accumulated after drain (e.g. pixel-diff blur/refocus)
|
|
143
|
+
* so the next tab stop does not mis-attribute F55 or redirects.
|
|
144
|
+
*/
|
|
145
|
+
export declare function clearContextFocusInsScript(): void;
|
|
146
|
+
/** Current location.href for navigation comparison in the host. */
|
|
147
|
+
export declare function locationHrefScript(): string;
|
|
148
|
+
export {};
|
package/dist/clip.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Rect } from "./adaptor";
|
|
2
|
+
/**
|
|
3
|
+
* A screenshot clip framing `rect` with up to `buffer` pixels of padding on each side, shrunk
|
|
4
|
+
* where the page edges leave less room. The padding allows for indicators drawn just outside
|
|
5
|
+
* the element box (outlines, shadows) and for anti-aliasing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function bufferedClip(rect: Rect, pageWidth: number, pageHeight: number, buffer: number): Rect;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether focus destination Y is inside the subtree of intended focus X
|
|
3
|
+
* (descendant or ancestor), which is legitimate delegation rather than theft.
|
|
4
|
+
* Walks across open shadow roots so focusing into a host's shadow content is
|
|
5
|
+
* same-subtree, not theft.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isSameFocusSubtree(intended: Element, settled: Element): boolean;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computed-style properties that are likely to draw a visible focus indicator. Layout properties
|
|
3
|
+
* like margin and padding are deliberately excluded.
|
|
4
|
+
*/
|
|
5
|
+
export declare const FOCUS_STYLE_PROPERTIES: readonly ["background-color", "background-image", "border-bottom-color", "border-bottom-style", "border-bottom-width", "border-left-color", "border-left-style", "border-left-width", "border-right-color", "border-right-style", "border-right-width", "border-top-color", "border-top-style", "border-top-width", "box-shadow", "color", "font-weight", "outline-color", "outline-style", "outline-width", "text-decoration"];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a compact, hopefully-unique selector for any given element on the page. This function is
|
|
3
|
+
* serialized and run in the page context, so it must not reference any symbols outside its own
|
|
4
|
+
* scope.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors axe-core's approach: each level contributes the fewest features that discriminate the
|
|
7
|
+
* element (a unique id, the rarest single class, or an :nth-of-type position), and the walk stops
|
|
8
|
+
* as soon as the joined selector matches exactly one element. Selectors stay short because of
|
|
9
|
+
* that early stop, not because of truncation; the length budget is only a backstop.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getSelector(root: Node | null): string;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type { BrowserAdaptor, ElementRef, Rect } from "./adaptor";
|
|
2
|
+
export type { AuditSelfDisconnect, BaseAuditOptions, BaseAuditSummary, } from "./audit-scaffold";
|
|
3
|
+
export { createAuditSelfDisconnect, DEFAULT_ELEMENT_LIMIT, DEFAULT_FAILED_ELEMENT_LIMIT, DEFAULT_SCREENSHOT_SETTLE_DELAY, DEFAULT_TIMEOUT, } from "./audit-scaffold";
|
|
4
|
+
export type { ActiveElementBase, BaselinePayload } from "./browser-scripts";
|
|
5
|
+
export { activeElementHandleScript, baselineScript, blurScript, clearMarkersScript, elementRectScript, elementStylesScript, focusScript, isCenterObscuredScript, pageDimensionsScript, probeActiveElementScript, scrollToCenterScript, } from "./browser-scripts";
|
|
6
|
+
export { bufferedClip } from "./clip";
|
|
7
|
+
export { FOCUS_STYLE_PROPERTIES } from "./focus-style";
|
|
8
|
+
export { getSelector } from "./get-selector";
|
|
9
|
+
export type { TabSessionOptions } from "./orchestrator";
|
|
10
|
+
export { createTabOrchestrator } from "./orchestrator";
|
|
11
|
+
export type { TruncateHtmlOptions } from "./truncate-html";
|
|
12
|
+
export { truncateHtml } from "./truncate-html";
|
|
13
|
+
export type { ActiveElementInfo, Capability, ContextChangeDrain, ContextChangeSignals, ObscuredMeasurement, SessionEndReason, StyleSnapshot, TabConsumer, TabSessionHandle, TabStopSnapshot, UnfocusedPair, } from "./types";
|