@ultimat3/scraping 17.0.0 → 18.0.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/README.md +26 -0
- package/package.json +6 -6
- package/src/cdp-fake.ts +25 -0
- package/src/cdp-port.ts +16 -0
- package/src/cdp-target.ts +18 -0
- package/src/color-scheme.ts +49 -0
- package/src/html-target.ts +51 -9
- package/src/index.ts +2 -0
- package/src/page-over-target.ts +6 -0
- package/src/page.ts +16 -0
- package/src/target.ts +23 -0
package/README.md
CHANGED
|
@@ -93,6 +93,7 @@ gate, offline included.
|
|
|
93
93
|
| `page.query(selector)` | `ElementSnapshot[]` — the same matches PLUS `visible`, `enabled`, and the layout `box`/`hitTarget` a driver with a layout engine can answer. The read for a decision ABOUT an element |
|
|
94
94
|
| `page.frame(nameOrSelector)` | a `ScrapeFrame`, re-resolved on every call through it |
|
|
95
95
|
| `page.offline(enabled)` | cuts the BROWSER's network, or restores it |
|
|
96
|
+
| `page.colorScheme(scheme)` | what the browser reports as the user's OS colour preference — `'light'`, `'dark'`, or `'no-preference'` to CLEAR the override |
|
|
96
97
|
|
|
97
98
|
`query()` exists because there is exactly one definition of "visible" in this framework and it is
|
|
98
99
|
the port's. A caller that had to compute its own wrote
|
|
@@ -111,6 +112,31 @@ NAVIGATE a frame: a recorded frame is one static document, so a click inside one
|
|
|
111
112
|
browser's own requests, so a driver that quietly answered "done" would let "a like taken offline is
|
|
112
113
|
queued" pass against an app that was online for the whole test.
|
|
113
114
|
|
|
115
|
+
**`page.colorScheme()` sets the INPUT to a theme decision, never its outcome.** An attribute on the
|
|
116
|
+
document — `data-theme`, `class="dark"` — is the outcome, and the component owns it: one that
|
|
117
|
+
resolves `'system'` itself overwrites or deletes the attribute on mount, so a harness that set it is
|
|
118
|
+
silently overruled. Measured on `examples/dummy`, `As of 2026-08`: `x shot --island` photographed
|
|
119
|
+
every state in both themes and the two files came back byte-identical, same md5, from two addresses
|
|
120
|
+
that really did serve different documents. Emulating the preference is what reaches a component that
|
|
121
|
+
decides for itself; set the attribute as well for one that reads a theme it does not own.
|
|
122
|
+
|
|
123
|
+
**`'no-preference'` CLEARS the override, and is not a third value.** CDP treats an explicit
|
|
124
|
+
`prefers-color-scheme: no-preference` as an override and an EMPTY feature list as a reset, so that
|
|
125
|
+
is what this sends. Measured on Chrome 150 headless, `As of 2026-08`: after either one,
|
|
126
|
+
`(prefers-color-scheme: dark)` is false and `(prefers-color-scheme: light)` is true — the same
|
|
127
|
+
answers an untouched page gives. They diverge on a browser that has a real preference, where the
|
|
128
|
+
override forces the light answer and the reset gives the machine's own back, which is what this
|
|
129
|
+
value promises. (`no-preference` left the `prefers-color-scheme` query itself in 2020, so nothing
|
|
130
|
+
matches it in any of those readings.)
|
|
131
|
+
|
|
132
|
+
Accepted and RECORDED on the offline drivers rather than refused, which is the opposite of
|
|
133
|
+
`offline()` — and the line between them is which side of a capture the verb is on. An offline
|
|
134
|
+
*assertion* is reachable on a driver that answers content, so a resolved `setOfflineMode` would let
|
|
135
|
+
it pass against an app that never went offline; a colour preference has no such assertion, and the
|
|
136
|
+
only thing it could be wrong about is a picture. So the offline drivers answer **different
|
|
137
|
+
deterministic bytes per scheme**, exactly as they already do per `clip` — a fake returning one
|
|
138
|
+
constant for both themes is precisely what let the defect above ship.
|
|
139
|
+
|
|
114
140
|
## What the page reports back, and the one thing a picture cannot say
|
|
115
141
|
|
|
116
142
|
Three bounded rings, read off `ScrapePage`. Bounded because a ten-thousand-page run that kept
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,16 +23,16 @@
|
|
|
23
23
|
"LICENSE"
|
|
24
24
|
],
|
|
25
25
|
"engines": {
|
|
26
|
-
"bun": ">=1.
|
|
26
|
+
"bun": ">=1.4.0"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
30
|
"test": "bun test"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/jobs": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
36
|
-
"@ultimat3/storage": "
|
|
33
|
+
"@ultimat3/core": "18.0.0",
|
|
34
|
+
"@ultimat3/jobs": "18.0.0",
|
|
35
|
+
"@ultimat3/schema": "18.0.0",
|
|
36
|
+
"@ultimat3/storage": "18.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/cdp-fake.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// Precedent: `packages/storage/src/driver-s3-fixture.ts` ships the same way.
|
|
10
10
|
|
|
11
11
|
import type { CdpBrowserLike, CdpFrameLike, CdpLauncherLike, CdpPageLike } from './cdp-port';
|
|
12
|
+
import { COLOR_SCHEME_FEATURE } from './color-scheme';
|
|
12
13
|
import { queryHtml } from './html-query';
|
|
13
14
|
import type { ElementSnapshot, ScrapeCookie } from './target';
|
|
14
15
|
|
|
@@ -70,6 +71,12 @@ export interface FakeCdpBrowser extends CdpBrowserLike {
|
|
|
70
71
|
readonly closed: boolean;
|
|
71
72
|
/** What `page.setOfflineMode()` last set, so a test asserts on the CONDITION, not on a call. */
|
|
72
73
|
readonly offline: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* What `page.emulateMediaFeatures()` last set `prefers-color-scheme` to, for `offline`'s reason
|
|
76
|
+
* — the condition the page is now in, never the fact that a method was called. `null` until
|
|
77
|
+
* something sets one, which is the launcher's own default and not a value this fake invents.
|
|
78
|
+
*/
|
|
79
|
+
readonly colorScheme: string | null;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
/** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
|
|
@@ -113,6 +120,7 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
113
120
|
let html = init.html;
|
|
114
121
|
let closed = false;
|
|
115
122
|
let offline = false;
|
|
123
|
+
let colorScheme: string | null = null;
|
|
116
124
|
const covered = new Set(init.covered ?? []);
|
|
117
125
|
const storage: Record<string, string> = { ...init.storage };
|
|
118
126
|
let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
|
|
@@ -202,6 +210,20 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
202
210
|
offline = enabled;
|
|
203
211
|
return Promise.resolve();
|
|
204
212
|
},
|
|
213
|
+
// CDP's own two rules, both of them. An EMPTY list is a RESET — measured on Chrome 150, after
|
|
214
|
+
// `emulateMediaFeatures([])` the page answers exactly what an untouched one does — so it must
|
|
215
|
+
// clear here rather than be ignored, or `'no-preference'` records the previous scheme forever.
|
|
216
|
+
// And a non-empty list is read by NAME, never `features[0].value`: the port's shape is a list,
|
|
217
|
+
// and a caller setting `prefers-reduced-motion` beside the scheme must not overwrite it.
|
|
218
|
+
emulateMediaFeatures: (features: readonly { name: string; value: string }[]) => {
|
|
219
|
+
if (features.length === 0) {
|
|
220
|
+
colorScheme = null;
|
|
221
|
+
return Promise.resolve();
|
|
222
|
+
}
|
|
223
|
+
const found = features.find((feature) => feature.name === COLOR_SCHEME_FEATURE);
|
|
224
|
+
if (found !== undefined) colorScheme = found.value;
|
|
225
|
+
return Promise.resolve();
|
|
226
|
+
},
|
|
205
227
|
on: (event: string, handler: (payload: unknown) => void) => {
|
|
206
228
|
const listeners = handlers.get(event) ?? [];
|
|
207
229
|
listeners.push(handler);
|
|
@@ -250,6 +272,9 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
250
272
|
get offline(): boolean {
|
|
251
273
|
return offline;
|
|
252
274
|
},
|
|
275
|
+
get colorScheme(): string | null {
|
|
276
|
+
return colorScheme;
|
|
277
|
+
},
|
|
253
278
|
};
|
|
254
279
|
}
|
|
255
280
|
|
package/src/cdp-port.ts
CHANGED
|
@@ -69,6 +69,22 @@ export interface CdpPageLike {
|
|
|
69
69
|
* a type error for a capability the port cannot make them have.
|
|
70
70
|
*/
|
|
71
71
|
setOfflineMode?(enabled: boolean): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* The browser's own media features — `Emulation.setEmulatedMedia` under CDP. `prefers-color-scheme`
|
|
74
|
+
* is the one this package sets; the array shape is the library's and is restated, not narrowed,
|
|
75
|
+
* because this file is the shape of somebody ELSE's object.
|
|
76
|
+
*
|
|
77
|
+
* OPTIONAL, read defensively, for `setOfflineMode`'s reason: a provider SDK or a launcher that
|
|
78
|
+
* predates the method must still satisfy the port. It does not go unwired by being optional —
|
|
79
|
+
* `cdp-target.ts` refuses BY NAME with `X_NOT_IMPLEMENTED` and a fix when a launcher lacks it.
|
|
80
|
+
*
|
|
81
|
+
* `name` stays a bare `string`. A union of the features this package sets would be this file
|
|
82
|
+
* naming somebody else's vocabulary, and a launcher accepting more would then fail to satisfy
|
|
83
|
+
* the port for no reason — the argument `on(event: string, …)` already makes.
|
|
84
|
+
*/
|
|
85
|
+
emulateMediaFeatures?(
|
|
86
|
+
features: readonly { readonly name: string; readonly value: string }[],
|
|
87
|
+
): Promise<void>;
|
|
72
88
|
/**
|
|
73
89
|
* `event` stays a bare `string` — a union of the four names this package subscribes to would be
|
|
74
90
|
* this file naming somebody else's event vocabulary, which is the thing it exists not to do, and
|
package/src/cdp-target.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { browserRecord } from './browser-record';
|
|
|
8
8
|
import type { CdpBrowserLike, CdpFrameLike, CdpPageLike, CdpRequestLike } from './cdp-port';
|
|
9
9
|
import { clearExpression, parseSnapshots, snapshotExpression } from './cdp-snapshot';
|
|
10
10
|
import type { ScrapeClock } from './clock';
|
|
11
|
+
import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
|
|
11
12
|
import { browserUnreachable, pageCrashed, scrapeNotImplemented } from './error-throws';
|
|
12
13
|
import type { InterceptRules } from './intercept';
|
|
13
14
|
import { interceptVerdict, refusalEntry } from './intercept';
|
|
@@ -361,6 +362,23 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
361
362
|
}
|
|
362
363
|
await source.setOfflineMode(enabled);
|
|
363
364
|
}),
|
|
365
|
+
setColorScheme: (scheme: ColorScheme): Promise<void> =>
|
|
366
|
+
guard('setColorScheme', async () => {
|
|
367
|
+
// `CdpPageLike` already declares the member optional, so it is READ, never re-declared in
|
|
368
|
+
// a local cast: a second copy of somebody else's shape is a copy that can drift from the
|
|
369
|
+
// seam it is standing in for (axiom 2). `setOfflineMode` above still casts and is the
|
|
370
|
+
// older spelling.
|
|
371
|
+
const emulate = init.page.emulateMediaFeatures;
|
|
372
|
+
if (typeof emulate !== 'function') {
|
|
373
|
+
throw scrapeNotImplemented(
|
|
374
|
+
'setColorScheme() on a CDP page with no emulateMediaFeatures() method',
|
|
375
|
+
'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
// `.call`, because the member is read off the object: an unbound method loses `this` and
|
|
379
|
+
// puppeteer's own `Page` needs it.
|
|
380
|
+
await emulate.call(init.page, colorSchemeFeatures(scheme));
|
|
381
|
+
}),
|
|
364
382
|
screenshot: (options: CaptureOptions) =>
|
|
365
383
|
guard('screenshot', async () => {
|
|
366
384
|
// `fullPage` is OMITTED when a clip is given rather than sent as `false`: the two are
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The OS-level colour preference a page is shown, and the CDP media feature that carries it.
|
|
2
|
+
//
|
|
3
|
+
// Its own module for `capture-clip.ts`'s reason: the vocabulary belongs to EVERY driver
|
|
4
|
+
// identically, and `target.ts` is a port declaration, which is not a place a decision lives.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a browser is told the user prefers — the INPUT to a theme decision, never its outcome.
|
|
8
|
+
*
|
|
9
|
+
* Setting `data-theme` on the document models the outcome, and the component owns that: a
|
|
10
|
+
* component that resolves `'system'` itself deletes or overwrites the attribute on mount, so a
|
|
11
|
+
* harness that set it is silently overruled and both themes converge on one picture. Measured on
|
|
12
|
+
* `examples/dummy`'s settings island (issue #338): `<state>-light.png` and `<state>-dark.png` came
|
|
13
|
+
* back byte-identical, same md5, from two addresses that really did serve different documents.
|
|
14
|
+
*
|
|
15
|
+
* The set is closed at THREE. `'light'` and `'dark'` are CSS's own values; `'no-preference'` is
|
|
16
|
+
* this vocabulary's way of saying **clear the override**, and it earns its place because without
|
|
17
|
+
* it a preference, once set, is permanent for the session.
|
|
18
|
+
*
|
|
19
|
+
* It is a CLEAR and not a value, and the difference is measured. CDP's `Emulation.setEmulatedMedia`
|
|
20
|
+
* treats an explicit `prefers-color-scheme: no-preference` as an OVERRIDE and an EMPTY feature list
|
|
21
|
+
* as a reset, so `colorSchemeFeatures` sends the empty list. On Chrome 150 headless the two are
|
|
22
|
+
* observationally identical — after either, `(prefers-color-scheme: dark)` is false,
|
|
23
|
+
* `(prefers-color-scheme: light)` is true, and `(prefers-color-scheme: no-preference)` is false,
|
|
24
|
+
* which is also what an untouched page answers. They diverge on a browser that HAS a real
|
|
25
|
+
* preference: the override forces the light-equivalent answer, while the reset gives the machine's
|
|
26
|
+
* own back — and "the launcher's default" is what this value promises.
|
|
27
|
+
*
|
|
28
|
+
* `no-preference` was dropped from the `prefers-color-scheme` media query in 2020, which is why
|
|
29
|
+
* the third row above is false in every one of those readings. It is a control word here, never a
|
|
30
|
+
* query a stylesheet can match.
|
|
31
|
+
*/
|
|
32
|
+
export const COLOR_SCHEMES = ['light', 'dark', 'no-preference'] as const;
|
|
33
|
+
export type ColorScheme = (typeof COLOR_SCHEMES)[number];
|
|
34
|
+
|
|
35
|
+
/** CSS's own feature name, and CDP's. One constant, because two spellings is a silent no-op. */
|
|
36
|
+
export const COLOR_SCHEME_FEATURE = 'prefers-color-scheme';
|
|
37
|
+
|
|
38
|
+
export function isColorScheme(value: unknown): value is ColorScheme {
|
|
39
|
+
return typeof value === 'string' && COLOR_SCHEMES.includes(value as ColorScheme);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The CDP feature list for one scheme — the ONE place the clear is spelled, so a driver and a fake
|
|
44
|
+
* cannot disagree about what `'no-preference'` means.
|
|
45
|
+
*/
|
|
46
|
+
export const colorSchemeFeatures = (
|
|
47
|
+
scheme: ColorScheme,
|
|
48
|
+
): readonly { readonly name: string; readonly value: string }[] =>
|
|
49
|
+
scheme === 'no-preference' ? [] : [{ name: COLOR_SCHEME_FEATURE, value: scheme }];
|
package/src/html-target.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { CaptureClip } from './capture-clip';
|
|
9
9
|
import type { ScrapeClock } from './clock';
|
|
10
|
+
import type { ColorScheme } from './color-scheme';
|
|
10
11
|
import {
|
|
11
12
|
browserUnreachable,
|
|
12
13
|
downloadTimeout,
|
|
@@ -38,15 +39,27 @@ import type {
|
|
|
38
39
|
const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
* A
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* A capture answers DIFFERENT deterministic bytes for every framing fact that was set — one
|
|
43
|
+
* picture per rectangle, one per colour preference. CI has no Chrome, so the offline drivers are
|
|
44
|
+
* the only place a framing knob can be proved at all: a fake that answered the same eight bytes
|
|
45
|
+
* whatever the rectangle would let a driver that silently drops the clip pass every test there is.
|
|
46
|
+
*
|
|
47
|
+
* The colour scheme joined that rule on 2026-08-26, and it is the same defect one axis over. `x
|
|
48
|
+
* shot --island` photographed every state twice and delivered the second picture as a byte-for-
|
|
49
|
+
* byte COPY of the first (issue #338), and no test could see it because this fake answered one
|
|
50
|
+
* constant for both themes.
|
|
51
|
+
*
|
|
52
|
+
* Bytes with NO framing fact set are unchanged, and clip-only bytes are unchanged too — the notes
|
|
53
|
+
* append in a fixed order, so every digest asserted before this existed still holds.
|
|
45
54
|
*/
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
const framedPng = (clip: CaptureClip | undefined, scheme: ColorScheme | null): Uint8Array => {
|
|
56
|
+
const notes =
|
|
57
|
+
(clip === undefined
|
|
58
|
+
? ''
|
|
59
|
+
: ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`) +
|
|
60
|
+
(scheme === null ? '' : ` scheme ${scheme}`);
|
|
61
|
+
if (notes === '') return FAKE_PNG;
|
|
62
|
+
const suffix = new TextEncoder().encode(notes);
|
|
50
63
|
const out = new Uint8Array(FAKE_PNG.length + suffix.length);
|
|
51
64
|
out.set(FAKE_PNG);
|
|
52
65
|
out.set(suffix, FAKE_PNG.length);
|
|
@@ -127,6 +140,9 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
|
127
140
|
const frameOverlays = new Map<string, Map<string, string>>();
|
|
128
141
|
let page: PageRecording = init.start ?? EMPTY;
|
|
129
142
|
let armed: string | undefined;
|
|
143
|
+
// `null` and not `'no-preference'`: nothing set one, which is the launcher's own default and not
|
|
144
|
+
// a value this driver invents — and it is what keeps an unframed picture's bytes unchanged.
|
|
145
|
+
let colorScheme: ColorScheme | null = null;
|
|
130
146
|
let closed = false;
|
|
131
147
|
let session: SessionSnapshot = init.session ?? {
|
|
132
148
|
...EMPTY_SESSION,
|
|
@@ -333,8 +349,34 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
|
333
349
|
'run this assertion on localBrowser()/remoteBrowser(), whose setOfflineMode() reaches a real browser — an offline driver has no network to cut, so it cannot prove an offline behaviour',
|
|
334
350
|
);
|
|
335
351
|
},
|
|
352
|
+
/**
|
|
353
|
+
* ACCEPTED and recorded, where `setOfflineMode` refuses — and the line between them is which
|
|
354
|
+
* side of a capture the verb is on, not whether this driver has a browser.
|
|
355
|
+
*
|
|
356
|
+
* `setOfflineMode` is refused because an offline ASSERTION is reachable here: this driver
|
|
357
|
+
* answers content, so `expect(page).toShow('queued')` would go green against an app that was
|
|
358
|
+
* online the whole time. A colour preference has no such assertion to pass. Nothing here
|
|
359
|
+
* evaluates CSS, and the only thing a preference could be wrong ABOUT is a picture — which on
|
|
360
|
+
* this driver is `FAKE_PNG`, a constant that claims nothing about a theme, exactly as `pdf()`
|
|
361
|
+
* answers `FAKE_PDF` rather than refusing.
|
|
362
|
+
*
|
|
363
|
+
* Refusing would cost the capability its unit test. `x shot --island` is proved end to end on
|
|
364
|
+
* a machine with no Chrome by driving this driver, so a refusal here makes the command
|
|
365
|
+
* untestable offline — and the picture it would be protecting does not exist.
|
|
366
|
+
*
|
|
367
|
+
* Observable through the PICTURE, which is the only place it could ever be wrong: `framedPng`
|
|
368
|
+
* answers different deterministic bytes per scheme, exactly as it already does per clip, so a
|
|
369
|
+
* driver that silently dropped the preference fails a test rather than passing every one.
|
|
370
|
+
*/
|
|
371
|
+
setColorScheme(scheme: ColorScheme): Promise<void> {
|
|
372
|
+
// `'no-preference'` is a CLEAR on the real driver, so it is a clear here — and the picture
|
|
373
|
+
// goes back to the unframed bytes, which is the same fact stated in the one place a fake can
|
|
374
|
+
// state it.
|
|
375
|
+
colorScheme = scheme === 'no-preference' ? null : scheme;
|
|
376
|
+
return Promise.resolve();
|
|
377
|
+
},
|
|
336
378
|
screenshot: (options: CaptureOptions): Promise<Uint8Array> =>
|
|
337
|
-
Promise.resolve(options.clip
|
|
379
|
+
Promise.resolve(framedPng(options.clip, colorScheme)),
|
|
338
380
|
pdf: (_options: CaptureOptions): Promise<Uint8Array> => Promise.resolve(FAKE_PDF),
|
|
339
381
|
cookies: (): Promise<readonly ScrapeCookie[]> => Promise.resolve(session.cookies),
|
|
340
382
|
session: (): Promise<SessionSnapshot> => Promise.resolve(session),
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,8 @@ export type { CdpTargetInit } from './cdp-target';
|
|
|
34
34
|
export { CDP_DRIVER, cdpTarget } from './cdp-target';
|
|
35
35
|
export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
|
|
36
36
|
export { deadline, systemScrapeClock, testClock, throwIfAborted } from './clock';
|
|
37
|
+
export type { ColorScheme } from './color-scheme';
|
|
38
|
+
export { COLOR_SCHEME_FEATURE, COLOR_SCHEMES, isColorScheme } from './color-scheme';
|
|
37
39
|
export {
|
|
38
40
|
cookieDomainMatches,
|
|
39
41
|
cookieHeaderFor,
|
package/src/page-over-target.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { awaitActionable } from './actionability';
|
|
|
10
10
|
import { assertCaptureFraming } from './capture-clip';
|
|
11
11
|
import type { ScrapeClock } from './clock';
|
|
12
12
|
import { deadline } from './clock';
|
|
13
|
+
import type { ColorScheme } from './color-scheme';
|
|
13
14
|
import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
|
|
14
15
|
import { hostDecision } from './hosts';
|
|
15
16
|
import type {
|
|
@@ -243,6 +244,11 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
|
|
|
243
244
|
async offline(enabled: boolean): Promise<void> {
|
|
244
245
|
await target.setOfflineMode(enabled);
|
|
245
246
|
},
|
|
247
|
+
// `async`, for `offline()`'s reason: a driver that throws synchronously from a promise-typed
|
|
248
|
+
// method would escape the caller's `.catch()`.
|
|
249
|
+
async colorScheme(scheme: ColorScheme): Promise<void> {
|
|
250
|
+
await target.setColorScheme(scheme);
|
|
251
|
+
},
|
|
246
252
|
cookies: (): Promise<readonly ScrapeCookie[]> => target.cookies(),
|
|
247
253
|
session: () => target.session(),
|
|
248
254
|
// Redacted BY VALUE on the way out, the same pass `html()` makes. A console line and a request
|
package/src/page.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { Secret } from '@ultimat3/core';
|
|
11
11
|
import type { ActionabilityState } from './actionability';
|
|
12
12
|
import type { CaptureFraming } from './capture-clip';
|
|
13
|
+
import type { ColorScheme } from './color-scheme';
|
|
13
14
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
14
15
|
import type { SessionSnapshot } from './session-state';
|
|
15
16
|
import type { ElementSnapshot, ScrapeCookie, ScrapeDownloadFile } from './target';
|
|
@@ -109,6 +110,21 @@ export interface ScrapePage extends ScrapeFrame {
|
|
|
109
110
|
* "done" would let an offline assertion pass against an app that never went offline.
|
|
110
111
|
*/
|
|
111
112
|
offline(enabled: boolean): Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* Tell the browser what the user's OS colour preference is, so a component that resolves its own
|
|
115
|
+
* theme resolves to this one. `'no-preference'` CLEARS the override and gives the browser's own
|
|
116
|
+
* answer back — it is not a third value a stylesheet can match.
|
|
117
|
+
*
|
|
118
|
+
* The INPUT, never the outcome: an attribute on the document is the outcome of a theme decision
|
|
119
|
+
* and the component owns that — one honouring `'system'` overwrites or deletes it on mount, and
|
|
120
|
+
* the picture then shows the theme the component chose rather than the one that was asked for.
|
|
121
|
+
*
|
|
122
|
+
* ACCEPTED on every driver, unlike `offline()`: the offline drivers evaluate no CSS, but they
|
|
123
|
+
* answer different deterministic bytes per scheme, so the one thing a preference could be wrong
|
|
124
|
+
* about is still under test. `X_NOT_IMPLEMENTED` is reserved for a LAUNCHER that lacks the
|
|
125
|
+
* method, which is a fact about the build rather than about the driver.
|
|
126
|
+
*/
|
|
127
|
+
colorScheme(scheme: ColorScheme): Promise<void>;
|
|
112
128
|
/**
|
|
113
129
|
* The handoff, made explicit: what the HTTP leg will send, as a value an author can inspect and
|
|
114
130
|
* a fixture can assert on. `http` uses it automatically — this is for seeing what carried over.
|
package/src/target.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// fixture replayer and the fake alike.
|
|
5
5
|
|
|
6
6
|
import type { CaptureFraming } from './capture-clip';
|
|
7
|
+
import type { ColorScheme } from './color-scheme';
|
|
7
8
|
import type { ConsoleRing, NetworkRing, PageErrorRing } from './rings';
|
|
8
9
|
import type { SessionSnapshot } from './session-state';
|
|
9
10
|
|
|
@@ -141,6 +142,28 @@ export interface ScrapeTarget {
|
|
|
141
142
|
* green against an app that was online the whole time.
|
|
142
143
|
*/
|
|
143
144
|
setOfflineMode(enabled: boolean): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* What the browser reports as the user's OS colour preference — the INPUT to a theme decision.
|
|
147
|
+
* `'no-preference'` CLEARS the override rather than setting one (`color-scheme.ts`).
|
|
148
|
+
*
|
|
149
|
+
* The INPUT and never the outcome. Setting `data-theme` on the document models the outcome, and
|
|
150
|
+
* the component owns that: one that resolves `'system'` itself overwrites or deletes the
|
|
151
|
+
* attribute on mount, so a harness that set it is silently overruled and every theme converges
|
|
152
|
+
* on one picture — measured, and byte-identical, in issue #338.
|
|
153
|
+
*
|
|
154
|
+
* REQUIRED here where `CdpPageLike.emulateMediaFeatures` is optional, for `setOfflineMode`'s
|
|
155
|
+
* reason: the asymmetry is the enforcement, and a driver author gets a type error naming this
|
|
156
|
+
* member rather than a silent no-op. A launcher without the method is `X_NOT_IMPLEMENTED`.
|
|
157
|
+
*
|
|
158
|
+
* A DRIVER WITH NO CSS ENGINE ACCEPTS IT, which is the one place this parts company with
|
|
159
|
+
* `setOfflineMode`, and the line between them is which side of a capture the verb is on. An
|
|
160
|
+
* offline ASSERTION is reachable on a driver that answers content, so a resolved
|
|
161
|
+
* `setOfflineMode` would let it pass against an app that never went offline. A colour preference
|
|
162
|
+
* has no such assertion: the only thing it could be wrong about is a picture, and the offline
|
|
163
|
+
* drivers answer different deterministic bytes per scheme — exactly as they do per `clip` —
|
|
164
|
+
* so a driver that dropped the preference fails a test rather than passing every one.
|
|
165
|
+
*/
|
|
166
|
+
setColorScheme(scheme: ColorScheme): Promise<void>;
|
|
144
167
|
screenshot(options: CaptureOptions): Promise<Uint8Array>;
|
|
145
168
|
pdf(options: CaptureOptions): Promise<Uint8Array>;
|
|
146
169
|
cookies(): Promise<readonly ScrapeCookie[]>;
|