@avi2dg/checks 0.61.0 → 0.62.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/CHANGELOG.md +16 -0
- package/README.md +1 -0
- package/docs/design.md +3 -2
- package/docs/gates/checks-browser.md +1 -0
- package/docs/gates/checks-changelog.md +2 -0
- package/docs/how-to/test-a-blocks-screens.md +81 -0
- package/package.json +3 -1
- package/src/delivery/changelog-write.ts +22 -8
- package/src/delivery/changelog.ts +6 -5
- package/src/testing/block-screens.ts +286 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.62.0
|
|
6
|
+
|
|
7
|
+
Released 2026-11-02.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **testing:** picture one block at each screen size in chromium and webkit [#24](https://github.com/avi2d/checks/pull/24)
|
|
12
|
+
|
|
13
|
+
## 0.61.1
|
|
14
|
+
|
|
15
|
+
Released 2026-11-02.
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
|
|
19
|
+
- **changelog:** keep a release's section when its commit gets its tag [#23](https://github.com/avi2d/checks/pull/23)
|
|
20
|
+
|
|
5
21
|
## 0.61.0
|
|
6
22
|
|
|
7
23
|
Released 2026-11-01.
|
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ A repository the kit holds ends with `checks-lint: <count> gate(s) pass`.
|
|
|
41
41
|
| write every file the gates ask for | [Set up a repository](docs/how-to/set-up-a-repository.md) |
|
|
42
42
|
| move to a newer release | [Upgrade the kit](docs/how-to/upgrade-the-kit.md) |
|
|
43
43
|
| turn on the CSS checks | [Turn on the CSS families](docs/how-to/turn-on-the-css-families.md) |
|
|
44
|
+
| picture one block at each screen size and approve its pictures | [Test the screens of one block](docs/how-to/test-a-blocks-screens.md) |
|
|
44
45
|
| look up a gate or a bin | [The bins](docs/bins.md) |
|
|
45
46
|
| look up a shared config | [Native settings](docs/configs/native-settings.md) |
|
|
46
47
|
| find a file the package ships | [The shipped files](docs/shipped-files.md) |
|
package/docs/design.md
CHANGED
|
@@ -84,11 +84,12 @@ Nothing would then say which gate a helper serves.
|
|
|
84
84
|
A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
|
|
85
85
|
The testing directory is named `testing` rather than `tests`, because a `src/tests/` beside the root `tests/` would read as a second suite.
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
Eight `exports` keys name a path the file does not sit at, because consumers resolve them by that name.
|
|
88
88
|
`@avi2dg/checks/scripts/test-skips.ts`, `@avi2dg/checks/scripts/comment-matchers.ts` and `@avi2dg/checks/scripts/prose-matchers.ts` point at their files under `src/`.
|
|
89
89
|
`@avi2dg/checks/browser-hooks.ts` points at `src/quality/browser/hooks.ts`, whose types a product's browser hooks import.
|
|
90
90
|
`@avi2dg/checks/browser-checks.json` points at `src/quality/browser/browser-checks.json`, the starter a product copies.
|
|
91
91
|
`@avi2dg/checks/screen-sizes.ts` points at `src/quality/browser/screen-sizes.ts`, whose sizes a product imports.
|
|
92
|
+
`@avi2dg/checks/block-screens.ts` points at `src/testing/block-screens.ts`, whose screenshot suite a product's pixel tests call.
|
|
92
93
|
`@avi2dg/checks/templates/*` points at `dist/templates/`.
|
|
93
94
|
A path read without the resolver, such as `node_modules/@avi2dg/checks/scripts/test-skips.ts`, does not exist.
|
|
94
95
|
|
|
@@ -114,7 +115,7 @@ A shell reaches it through `bun run`, which never falls back to the registry the
|
|
|
114
115
|
The `.ts` bins keep a `bun` shebang and need no build step, unlike the oxlint plugins that Node loads.
|
|
115
116
|
|
|
116
117
|
The bins are written in Effect.
|
|
117
|
-
So `effect` is a peer dependency, and `@effect/platform-bun`, which
|
|
118
|
+
So `effect` is a peer dependency, and `@effect/platform-bun`, which the bins and the block screenshot helper use, is a dependency.
|
|
118
119
|
The Stryker test order helper runs under Node, so it takes its services from `@effect/platform-node-shared`, which is a direct dependency.
|
|
119
120
|
That dependency also pins its version.
|
|
120
121
|
`@effect/platform-bun` asks for it with a `^` range, so a newer minor of it could resolve and peer on a newer `effect` than the exact version consumers install.
|
|
@@ -162,6 +162,7 @@ Each size in a set is a viewport named for its device and its size, with `device
|
|
|
162
162
|
A preset sits beside a product's own viewports.
|
|
163
163
|
A name both declare is refused as declared twice, and so is a size that two listed presets both hold.
|
|
164
164
|
A product's own code takes the sizes by importing `SCREEN_SIZES`, `screenSizeName` and `ENLARGED_TEXT_PX` from `@avi2dg/checks/screen-sizes.ts`.
|
|
165
|
+
A block's screenshot suite runs at the same sizes, as [Test the screens of one block](../how-to/test-a-blocks-screens.md) says.
|
|
165
166
|
|
|
166
167
|
<!-- generated screen-sizes: bun run build writes it from SCREEN_SIZES in src/quality/browser/screen-sizes.ts and scripts/doc-blocks.ts -->
|
|
167
168
|
|
|
@@ -27,6 +27,8 @@ It finds releases in the version bumps of `package.json` across all of `HEAD` an
|
|
|
27
27
|
It reads a commit whose `package.json` is missing or has no version as unversioned, so the commit that adds the version is the first release and a repository that adopts a version late still writes its changelog.
|
|
28
28
|
A bump to a version no newer than the release before it is a revert.
|
|
29
29
|
A revert cancels every release above the version it returns to, except a release a `v` tag marks as published.
|
|
30
|
+
A `v` tag publishes only the newest bump of its version that the tagged commit reaches.
|
|
31
|
+
So a version bumped again after a cancel keeps its cancelled bump cancelled, and the tag leaves the changelog unchanged.
|
|
30
32
|
A version older than the newest in the committed `CHANGELOG.md` and absent from it was never published, so its commits go into the next release.
|
|
31
33
|
It refuses a shallow checkout, since the releases reach back past its history.
|
|
32
34
|
It refuses a `package.json` with no repository address, since each entry links its pull request under it.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: how-to
|
|
3
|
+
audience: consumers
|
|
4
|
+
---
|
|
5
|
+
# Test the screens of one block
|
|
6
|
+
|
|
7
|
+
A repository owner follows these steps to picture one block at the kit's screen sizes in Chromium and WebKit, and to approve its pictures.
|
|
8
|
+
|
|
9
|
+
## Before you begin
|
|
10
|
+
|
|
11
|
+
- `playwright-core` installed at the version the kit's `package.json` lists among its `peerDependencies`.
|
|
12
|
+
- Playwright's Chromium headless shell and WebKit, installed with `bunx playwright-core install --with-deps chromium-headless-shell webkit`.
|
|
13
|
+
- A `test:pixel` script set to `checks-test --tier=pixel`, as [checks-test-layout](../gates/checks-test-layout.md) says.
|
|
14
|
+
|
|
15
|
+
## Add a suite
|
|
16
|
+
|
|
17
|
+
To add a suite for one block:
|
|
18
|
+
|
|
19
|
+
1. Write a test file for Chromium under `tests/pixel/`, named for the block and the engine:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { blockScreens } from "@avi2dg/checks/block-screens.ts";
|
|
24
|
+
|
|
25
|
+
blockScreens("chromium", {
|
|
26
|
+
block: "footer",
|
|
27
|
+
fixtures: join(import.meta.dir, "..", "fixtures", "footer-screens"),
|
|
28
|
+
url: "http://localhost:4321/",
|
|
29
|
+
selector: ".footer",
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
1. Write the same file for `"webkit"` beside it.
|
|
34
|
+
1. Add `*.new.png` to `.gitignore`.
|
|
35
|
+
1. Run `bun run test:pixel`, which fails at each size until its first pictures are approved.
|
|
36
|
+
1. Approve them as [Approve new pictures](#approve-new-pictures) says.
|
|
37
|
+
|
|
38
|
+
`blockScreens` registers one test for each size, and each test opens the page in a fresh browser context for its device.
|
|
39
|
+
It waits for the fonts and every running animation that ends, runs `setup` and takes the picture with animations stopped.
|
|
40
|
+
|
|
41
|
+
| Key | What it takes |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `block` | the name each test opens with |
|
|
44
|
+
| `fixtures` | the folder of approved pictures, which holds one folder for each engine |
|
|
45
|
+
| `url` | the page that shows the block, or a function that returns a promise of it |
|
|
46
|
+
| `selector` | the element in the picture, which is the whole window when the key is left out |
|
|
47
|
+
| `setup` | a function that gets the page after it settles and before its picture, such as one that opens a menu |
|
|
48
|
+
| `sizes` | `anchor`, the default, or `full`, the sets [Screen sizes](../gates/checks-browser.md#screen-sizes) lists |
|
|
49
|
+
| `devices` | more devices of the repository's own, each made with `emulatedDevice` or `windowDevice` |
|
|
50
|
+
| `tolerance` | `channel`, how far a color channel may move before its pixel counts as changed, and `share`, the share of pixels that may change, both 0 when the key is left out |
|
|
51
|
+
| `timeoutMs` | how long each size may take, 60 seconds when the key is left out |
|
|
52
|
+
|
|
53
|
+
Each picture is compared with `<fixtures>/<engine>/<device>.png`, where the device name is lowercased and each run of other characters becomes one hyphen, as in `iphone-se-375x667.png`.
|
|
54
|
+
A phone picture comes from a phone the kit emulates, such as an iPhone SE at 375 by 667.
|
|
55
|
+
A picture that differs from its approved picture, or that has none, fails its test.
|
|
56
|
+
The failure names the device, its size and the engine, and the new picture is written beside the approved one as `<device>.new.png`.
|
|
57
|
+
A picture that matches removes any `.new.png` beside it.
|
|
58
|
+
|
|
59
|
+
## Approve new pictures
|
|
60
|
+
|
|
61
|
+
To approve the pictures of a block that changed on purpose:
|
|
62
|
+
|
|
63
|
+
1. Run `bun run test:pixel` and look at each `.new.png` it writes.
|
|
64
|
+
1. Run `APPROVE_SCREENS=1 bun run test:pixel`, which replaces each approved picture with the new one and removes its `.new.png`.
|
|
65
|
+
1. Look at every changed file in the fixtures folder before you commit it.
|
|
66
|
+
|
|
67
|
+
Approve mode passes every size, so it runs only once each new picture looks right.
|
|
68
|
+
|
|
69
|
+
## Build a suite from the parts
|
|
70
|
+
|
|
71
|
+
A repository whose test already opens the page uses the parts `blockScreens` is built from:
|
|
72
|
+
|
|
73
|
+
1. Take the devices from `screenDevices("anchor")`, and add the repository's own with `emulatedDevice` or `windowDevice`.
|
|
74
|
+
1. Open each page in a browser context with the device's `options`.
|
|
75
|
+
1. Wait with `settled(page)`, and take the picture with `pictureOf(page, selector)`.
|
|
76
|
+
1. Pass the picture to `matchesApproved(picture, { fixtures, engine, device, tolerance })`, which resolves to `AS_APPROVED` or to the failure, and expect `AS_APPROVED`.
|
|
77
|
+
|
|
78
|
+
## Related topics
|
|
79
|
+
|
|
80
|
+
- [checks-browser](../gates/checks-browser.md)
|
|
81
|
+
- [checks-test-layout](../gates/checks-test-layout.md)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "avi2d",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"src/delivery/release-tagging.ts",
|
|
51
51
|
"src/delivery/github.ts",
|
|
52
52
|
"src/testing/test-layout.ts",
|
|
53
|
+
"src/testing/block-screens.ts",
|
|
53
54
|
"src/testing/test.ts",
|
|
54
55
|
"src/testing/test-skips.ts",
|
|
55
56
|
"src/core/swc.ts",
|
|
@@ -144,6 +145,7 @@
|
|
|
144
145
|
"./browser-hooks.ts": "./src/quality/browser/hooks.ts",
|
|
145
146
|
"./browser-checks.json": "./src/quality/browser/browser-checks.json",
|
|
146
147
|
"./screen-sizes.ts": "./src/quality/browser/screen-sizes.ts",
|
|
148
|
+
"./block-screens.ts": "./src/testing/block-screens.ts",
|
|
147
149
|
"./oxlint": {
|
|
148
150
|
"types": "./dist/presets/oxlint.d.ts",
|
|
149
151
|
"default": "./dist/presets/oxlint.js"
|
|
@@ -64,9 +64,26 @@ const readBumps = Effect.fn("readBumps")(function* (root: string) {
|
|
|
64
64
|
return bumps;
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
const
|
|
67
|
+
const reaches = (root: string, ancestor: string, descendant: string) =>
|
|
68
|
+
git(["merge-base", "--is-ancestor", ancestor, descendant], root).pipe(
|
|
69
|
+
Effect.as(true),
|
|
70
|
+
Effect.catchTag("GitFailure", () => Effect.succeed(false)),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// A tag publishes the newest bump of its version that it reaches, so a cancelled older bump of that version stays cancelled.
|
|
74
|
+
const readPublishedShas = Effect.fn("readPublishedShas")(function* (root: string, bumps: readonly Bump[]) {
|
|
68
75
|
const tags = yield* git(["for-each-ref", "--format=%(refname:strip=2)", `refs/tags/${TAG_PREFIX}*`], root);
|
|
69
|
-
|
|
76
|
+
const published = new Set<string>();
|
|
77
|
+
for (const tag of tags.split("\n").filter((line) => line.startsWith(TAG_PREFIX))) {
|
|
78
|
+
const version = tag.slice(TAG_PREFIX.length);
|
|
79
|
+
for (const bump of bumps.filter((one) => one.version === version).toReversed()) {
|
|
80
|
+
if (yield* reaches(root, bump.sha, `refs/tags/${tag}`)) {
|
|
81
|
+
published.add(bump.sha);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return published;
|
|
70
87
|
});
|
|
71
88
|
|
|
72
89
|
const mergedTips = Effect.fn("mergedTips")(function* (root: string, through: string) {
|
|
@@ -78,11 +95,7 @@ const mergedTips = Effect.fn("mergedTips")(function* (root: string, through: str
|
|
|
78
95
|
for (const [, ...parents] of merges.split("\n").filter((line) => line !== "").map((line) => line.split(" "))) {
|
|
79
96
|
for (const parent of parents) {
|
|
80
97
|
// A parent past the bump belongs to a later release, so only a parent beside it joins this one.
|
|
81
|
-
|
|
82
|
-
Effect.as(true),
|
|
83
|
-
Effect.catchTag("GitFailure", () => Effect.succeed(false)),
|
|
84
|
-
);
|
|
85
|
-
if (!past) tips.push(parent);
|
|
98
|
+
if (!(yield* reaches(root, through, parent))) tips.push(parent);
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
return tips;
|
|
@@ -113,7 +126,8 @@ const write = Effect.gen(function* () {
|
|
|
113
126
|
}
|
|
114
127
|
const recorded = (yield* fs.exists(target)) ? releaseDates(yield* fs.readFileString(target)) : new Map<string, string>();
|
|
115
128
|
const pending = version === (yield* versionAt(root, "HEAD")) ? undefined : { sha: "HEAD", version, date: yield* today };
|
|
116
|
-
const
|
|
129
|
+
const bumps = yield* readBumps(root);
|
|
130
|
+
const released = cuts(bumps, recorded, yield* readPublishedShas(root, bumps), pending);
|
|
117
131
|
const tip = released.at(-1);
|
|
118
132
|
const extra = tip === undefined || tip.through === "HEAD" ? [] : yield* mergedTips(root, tip.through);
|
|
119
133
|
const found = (yield* Effect.forEach(released, (cut, index) => subjectsOf(root, cut, index === released.length - 1 ? extra : [])))
|
|
@@ -57,12 +57,13 @@ function entryOf(subject: string, repositoryUrl: string): Entry | undefined {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// A bump not newer than the release before it is a revert: it cancels every release above the version it
|
|
60
|
-
// returns to, except a published one.
|
|
61
|
-
|
|
60
|
+
// returns to, except a published one. A version cancelled and bumped again holds two bumps, so a bump is
|
|
61
|
+
// published by its sha and never by its version.
|
|
62
|
+
function standing(bumps: readonly Bump[], publishedShas: ReadonlySet<string>): readonly Bump[] {
|
|
62
63
|
return bumps.reduce<readonly Bump[]>((kept, bump) => {
|
|
63
64
|
const before = kept.at(-1);
|
|
64
65
|
if (before === undefined || Bun.semver.order(bump.version, before.version) === 1) return [...kept, bump];
|
|
65
|
-
return kept.filter(({ version }) =>
|
|
66
|
+
return kept.filter(({ sha, version }) => publishedShas.has(sha) || Bun.semver.order(version, bump.version) !== 1);
|
|
66
67
|
}, []);
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -71,11 +72,11 @@ function standing(bumps: readonly Bump[], published: ReadonlySet<string>): reado
|
|
|
71
72
|
export function cuts(
|
|
72
73
|
bumps: readonly Bump[],
|
|
73
74
|
recorded: ReadonlyMap<string, string>,
|
|
74
|
-
|
|
75
|
+
publishedShas: ReadonlySet<string>,
|
|
75
76
|
pending: Bump | undefined,
|
|
76
77
|
): readonly Cut[] {
|
|
77
78
|
const newest = [...recorded.keys()].toSorted(Bun.semver.order).at(-1);
|
|
78
|
-
const candidates = standing(pending === undefined ? bumps : [...bumps, pending],
|
|
79
|
+
const candidates = standing(pending === undefined ? bumps : [...bumps, pending], publishedShas);
|
|
79
80
|
const released = candidates.filter(
|
|
80
81
|
({ version }, index) =>
|
|
81
82
|
index === candidates.length - 1 || newest === undefined || recorded.has(version) || Bun.semver.order(version, newest) !== -1,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { BunServices } from "@effect/platform-bun";
|
|
2
|
+
import { afterAll, expect, test } from "bun:test";
|
|
3
|
+
import { Config, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
|
+
import { chromium, devices, webkit, type Browser, type Page } from "playwright-core";
|
|
5
|
+
import { SCREEN_SIZES, screenSizeName, type ScreenSize, type ScreenSizeSet } from "../quality/browser/screen-sizes.ts";
|
|
6
|
+
|
|
7
|
+
export const SCREEN_ENGINES = ["chromium", "webkit"] as const;
|
|
8
|
+
|
|
9
|
+
export type ScreenEngine = (typeof SCREEN_ENGINES)[number];
|
|
10
|
+
|
|
11
|
+
export type Viewport = { readonly width: number; readonly height: number };
|
|
12
|
+
|
|
13
|
+
export type DeviceOptions = {
|
|
14
|
+
readonly userAgent?: string;
|
|
15
|
+
readonly deviceScaleFactor: number;
|
|
16
|
+
readonly isMobile: boolean;
|
|
17
|
+
readonly hasTouch: boolean;
|
|
18
|
+
readonly viewport: Viewport;
|
|
19
|
+
readonly screen: Viewport;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ScreenDevice = { readonly name: string; readonly options: DeviceOptions };
|
|
23
|
+
|
|
24
|
+
type Devices = typeof devices;
|
|
25
|
+
|
|
26
|
+
export type DeviceDescriptor = keyof { [Name in keyof Devices as string extends Name ? never : Name]: Devices[Name] };
|
|
27
|
+
|
|
28
|
+
const sized = (label: string, { width, height }: Viewport): string => `${label} ${String(width)}x${String(height)}`;
|
|
29
|
+
|
|
30
|
+
export function emulatedDevice(label: string, descriptor: DeviceDescriptor, viewport: Viewport, deviceScaleFactor?: number): ScreenDevice {
|
|
31
|
+
const { userAgent, isMobile, hasTouch, ...described } = devices[descriptor];
|
|
32
|
+
return {
|
|
33
|
+
name: sized(label, viewport),
|
|
34
|
+
options: { userAgent, deviceScaleFactor: deviceScaleFactor ?? described.deviceScaleFactor, isMobile, hasTouch, viewport, screen: viewport },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const windowDevice = (viewport: Viewport): ScreenDevice => emulatedDevice("desktop", "Desktop Chrome", viewport);
|
|
39
|
+
|
|
40
|
+
const IPHONES: Readonly<Partial<Record<string, { readonly label: string; readonly descriptor: DeviceDescriptor }>>> = {
|
|
41
|
+
"phone-375x667": { label: "iPhone SE", descriptor: "iPhone SE (3rd gen)" },
|
|
42
|
+
"phone-393x852": { label: "iPhone 15 and 16", descriptor: "iPhone 16" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function sizeDevice(size: ScreenSize): ScreenDevice {
|
|
46
|
+
const viewport = { width: size.width, height: size.height };
|
|
47
|
+
const iphone = IPHONES[screenSizeName(size)];
|
|
48
|
+
if (iphone !== undefined) return emulatedDevice(iphone.label, iphone.descriptor, viewport);
|
|
49
|
+
switch (size.device) {
|
|
50
|
+
case "phone":
|
|
51
|
+
return emulatedDevice("Android", "Pixel 7", viewport);
|
|
52
|
+
case "tablet":
|
|
53
|
+
return { name: sized("iPad", viewport), options: { viewport, screen: viewport, deviceScaleFactor: 2, isMobile: true, hasTouch: true } };
|
|
54
|
+
case "desktop":
|
|
55
|
+
return windowDevice(viewport);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const screenDevices = (set: ScreenSizeSet): readonly ScreenDevice[] => SCREEN_SIZES[set].map(sizeDevice);
|
|
60
|
+
|
|
61
|
+
export const pictureFile = (engine: ScreenEngine, device: ScreenDevice): string =>
|
|
62
|
+
`${engine}/${device.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}.png`;
|
|
63
|
+
|
|
64
|
+
class PictureFailure extends Schema.TaggedError<PictureFailure>()("PictureFailure", {
|
|
65
|
+
message: Schema.String,
|
|
66
|
+
}) {}
|
|
67
|
+
|
|
68
|
+
type Picture = { readonly width: number; readonly height: number; readonly rgb: Uint8Array };
|
|
69
|
+
|
|
70
|
+
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
71
|
+
const CHANNELS_OF_COLOR_TYPE: Readonly<Partial<Record<number, number>>> = { 0: 1, 2: 3, 4: 2, 6: 4 };
|
|
72
|
+
|
|
73
|
+
type Header = { readonly width: number; readonly height: number; readonly channels: number };
|
|
74
|
+
|
|
75
|
+
type Chunks = { readonly header: Header; readonly deflated: Uint8Array<ArrayBuffer> };
|
|
76
|
+
|
|
77
|
+
const chunksOf = (png: Uint8Array): Chunks | string => {
|
|
78
|
+
if (!PNG_SIGNATURE.every((byte, at) => png[at] === byte)) return "it is not a PNG";
|
|
79
|
+
const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
80
|
+
const data: Uint8Array[] = [];
|
|
81
|
+
let header: Header | string = "it has no IHDR chunk";
|
|
82
|
+
for (let at = PNG_SIGNATURE.length; at + 8 <= png.length; ) {
|
|
83
|
+
const length = view.getUint32(at);
|
|
84
|
+
const type = String.fromCharCode(...png.subarray(at + 4, at + 8));
|
|
85
|
+
const body = png.subarray(at + 8, at + 8 + length);
|
|
86
|
+
if (type === "IHDR") header = headerOf(new DataView(body.buffer, body.byteOffset, body.byteLength));
|
|
87
|
+
if (type === "IDAT") data.push(body);
|
|
88
|
+
at += 12 + length;
|
|
89
|
+
}
|
|
90
|
+
return typeof header === "string" ? header : { header, deflated: joined(data) };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const headerOf = (view: DataView): Header | string => {
|
|
94
|
+
const [depth, colorType, interlace] = [view.getUint8(8), view.getUint8(9), view.getUint8(12)];
|
|
95
|
+
const channels = CHANNELS_OF_COLOR_TYPE[colorType];
|
|
96
|
+
if (depth !== 8 || channels === undefined || interlace !== 0) return `it is a ${String(depth)}-bit PNG of color type ${String(colorType)} with interlace ${String(interlace)}, and only 8-bit gray, RGB and RGBA without interlace decode`;
|
|
97
|
+
return { width: view.getUint32(0), height: view.getUint32(4), channels };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const joined = (parts: readonly Uint8Array[]): Uint8Array<ArrayBuffer> => {
|
|
101
|
+
const whole = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));
|
|
102
|
+
parts.reduce((at, part) => (whole.set(part, at), at + part.length), 0);
|
|
103
|
+
return whole;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const paeth = (left: number, up: number, upLeft: number): number => {
|
|
107
|
+
const [leftDistance, upDistance, upLeftDistance] = [Math.abs(up - upLeft), Math.abs(left - upLeft), Math.abs(left + up - 2 * upLeft)];
|
|
108
|
+
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left;
|
|
109
|
+
return upDistance <= upLeftDistance ? up : upLeft;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const PREDICTORS: readonly ((left: number, up: number, upLeft: number) => number)[] = [
|
|
113
|
+
() => 0,
|
|
114
|
+
(left) => left,
|
|
115
|
+
(_left, up) => up,
|
|
116
|
+
(left, up) => Math.floor((left + up) / 2),
|
|
117
|
+
paeth,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
type Row = { readonly filtered: Uint8Array; readonly raw: Uint8Array; readonly row: number; readonly stride: number; readonly channels: number };
|
|
121
|
+
|
|
122
|
+
const unfilterRow = ({ filtered, raw, row, stride, channels }: Row): string | undefined => {
|
|
123
|
+
const predict = PREDICTORS[filtered[row * (stride + 1)] ?? 0];
|
|
124
|
+
if (predict === undefined) return `row ${String(row)} names an unknown filter`;
|
|
125
|
+
for (let at = 0; at < stride; at += 1) {
|
|
126
|
+
const here = row * stride + at;
|
|
127
|
+
const left = at < channels ? 0 : (raw[here - channels] ?? 0);
|
|
128
|
+
const up = row === 0 ? 0 : (raw[here - stride] ?? 0);
|
|
129
|
+
const upLeft = row === 0 || at < channels ? 0 : (raw[here - stride - channels] ?? 0);
|
|
130
|
+
raw[here] = ((filtered[row * (stride + 1) + 1 + at] ?? 0) + predict(left, up, upLeft)) & 0xff;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const unfiltered = (filtered: Uint8Array, { width, height, channels }: Header): Uint8Array | string => {
|
|
136
|
+
const stride = width * channels;
|
|
137
|
+
if (filtered.length < height * (stride + 1)) return "its image data ends early";
|
|
138
|
+
const raw = new Uint8Array(stride * height);
|
|
139
|
+
for (let row = 0; row < height; row += 1) {
|
|
140
|
+
const failed = unfilterRow({ filtered, raw, row, stride, channels });
|
|
141
|
+
if (failed !== undefined) return failed;
|
|
142
|
+
}
|
|
143
|
+
return raw;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const rgbOf = (raw: Uint8Array, { width, height, channels }: Header): Uint8Array => {
|
|
147
|
+
const rgb = new Uint8Array(width * height * 3);
|
|
148
|
+
for (let pixel = 0; pixel < width * height; pixel += 1) {
|
|
149
|
+
for (let channel = 0; channel < 3; channel += 1) rgb[pixel * 3 + channel] = raw[pixel * channels + (channels < 3 ? 0 : channel)] ?? 0;
|
|
150
|
+
}
|
|
151
|
+
return rgb;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const inflated = (deflated: Uint8Array<ArrayBuffer>) =>
|
|
155
|
+
Effect.tryPromise({
|
|
156
|
+
try: () => new Response(new Blob([deflated]).stream().pipeThrough(new DecompressionStream("deflate"))).bytes(),
|
|
157
|
+
catch: (cause) => new PictureFailure({ message: `its image data does not inflate: ${String(cause)}` }),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export const decodePng = Effect.fn("decodePng")(function* (png: Uint8Array) {
|
|
161
|
+
const chunks = chunksOf(png);
|
|
162
|
+
if (typeof chunks === "string") return yield* new PictureFailure({ message: chunks });
|
|
163
|
+
const raw = unfiltered(yield* inflated(chunks.deflated), chunks.header);
|
|
164
|
+
if (typeof raw === "string") return yield* new PictureFailure({ message: raw });
|
|
165
|
+
return { width: chunks.header.width, height: chunks.header.height, rgb: rgbOf(raw, chunks.header) } satisfies Picture;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
export type Tolerance = {
|
|
169
|
+
readonly channel: number;
|
|
170
|
+
readonly share: number;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const EXACT: Tolerance = { channel: 0, share: 0 };
|
|
174
|
+
|
|
175
|
+
const differingPixels = (now: Picture, then: Picture, channel: number): number => {
|
|
176
|
+
let differing = 0;
|
|
177
|
+
for (let at = 0; at < now.rgb.length; at += 3) {
|
|
178
|
+
const moved = [0, 1, 2].some((offset) => Math.abs((now.rgb[at + offset] ?? 0) - (then.rgb[at + offset] ?? 0)) > channel);
|
|
179
|
+
if (moved) differing += 1;
|
|
180
|
+
}
|
|
181
|
+
return differing;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const mismatch = (now: Picture, then: Picture, { channel, share }: Tolerance): string | undefined => {
|
|
185
|
+
if (now.width !== then.width || now.height !== then.height)
|
|
186
|
+
return `is ${String(now.width)}x${String(now.height)} and the approved picture is ${String(then.width)}x${String(then.height)}`;
|
|
187
|
+
const differing = differingPixels(now, then, channel);
|
|
188
|
+
return differing <= Math.floor(now.width * now.height * share) ? undefined : `has ${String(differing)} pixel(s) that differ from the approved picture`;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export type ApprovalMode = "compare" | "approve";
|
|
192
|
+
|
|
193
|
+
export const APPROVE_VARIABLE = "APPROVE_SCREENS";
|
|
194
|
+
|
|
195
|
+
const modeFromEnvironment = Config.Boolean(APPROVE_VARIABLE).pipe(
|
|
196
|
+
Config.withDefault(false),
|
|
197
|
+
Config.map((approving): ApprovalMode => (approving ? "approve" : "compare")),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
export type PictureAt = {
|
|
201
|
+
readonly fixtures: string;
|
|
202
|
+
readonly engine: ScreenEngine;
|
|
203
|
+
readonly device: ScreenDevice;
|
|
204
|
+
readonly tolerance?: Tolerance;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const AS_APPROVED = "the picture is as approved";
|
|
208
|
+
|
|
209
|
+
const judged = Effect.fn("judged")(function* (picture: Uint8Array, at: PictureAt, given: ApprovalMode | undefined) {
|
|
210
|
+
const fs = yield* FileSystem.FileSystem;
|
|
211
|
+
const path = yield* Path.Path;
|
|
212
|
+
const approved = path.join(at.fixtures, pictureFile(at.engine, at.device));
|
|
213
|
+
const fresh = approved.replace(/\.png$/, ".new.png");
|
|
214
|
+
const shown = (file: string): string => path.relative(process.cwd(), file);
|
|
215
|
+
const forget = fs.remove(fresh, { force: true });
|
|
216
|
+
const keep = (why: string) =>
|
|
217
|
+
fs.writeFile(fresh, picture).pipe(Effect.as(`${at.device.name} in ${at.engine}: ${why}, so the new picture is at ${shown(fresh)}`));
|
|
218
|
+
yield* fs.makeDirectory(path.dirname(approved), { recursive: true });
|
|
219
|
+
if ((given ?? (yield* modeFromEnvironment)) === "approve") return yield* fs.writeFile(approved, picture).pipe(Effect.andThen(forget), Effect.as(AS_APPROVED));
|
|
220
|
+
if (!(yield* fs.exists(approved))) return yield* keep(`no approved picture is at ${shown(approved)}`);
|
|
221
|
+
const [now, then] = yield* Effect.all([decodePng(picture), Effect.flatMap(fs.readFile(approved), decodePng)]);
|
|
222
|
+
const found = mismatch(now, then, at.tolerance ?? EXACT);
|
|
223
|
+
return yield* found === undefined ? forget.pipe(Effect.as(AS_APPROVED)) : keep(`the picture ${found} at ${shown(approved)}`);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
export const matchesApproved = (picture: Uint8Array, at: PictureAt, mode?: ApprovalMode): Promise<string> =>
|
|
227
|
+
Effect.runPromise(judged(picture, at, mode).pipe(Effect.provide(BunServices.layer)));
|
|
228
|
+
|
|
229
|
+
const PICTURE_OPTIONS = { scale: "css", animations: "disabled", caret: "hide" } as const;
|
|
230
|
+
|
|
231
|
+
export const pictureOf = (page: Page, selector?: string): Promise<Uint8Array> =>
|
|
232
|
+
selector === undefined ? page.screenshot(PICTURE_OPTIONS) : page.locator(selector).screenshot(PICTURE_OPTIONS);
|
|
233
|
+
|
|
234
|
+
const SETTLED = `document.fonts.ready.then(() => Promise.all(document.getAnimations().filter((animation) => animation.playState !== "paused" && animation.timeline === document.timeline && animation.effect?.getTiming().iterations !== Infinity).map((animation) => animation.finished))).then(() => undefined)`;
|
|
235
|
+
|
|
236
|
+
export const settled = (page: Page): Promise<unknown> => page.evaluate(SETTLED);
|
|
237
|
+
|
|
238
|
+
const LAUNCHERS = {
|
|
239
|
+
// Headless shell snaps glyph advances to whole pixels, so a picture would not match one from a full Chromium.
|
|
240
|
+
chromium: () => chromium.launch({ args: ["--font-render-hinting=none"] }),
|
|
241
|
+
webkit: () => webkit.launch(),
|
|
242
|
+
} as const satisfies Record<ScreenEngine, () => Promise<Browser>>;
|
|
243
|
+
|
|
244
|
+
export type BlockSuite = {
|
|
245
|
+
readonly block: string;
|
|
246
|
+
readonly fixtures: string;
|
|
247
|
+
readonly url: string | (() => Promise<string>);
|
|
248
|
+
readonly selector?: string;
|
|
249
|
+
readonly setup?: (page: Page) => Promise<unknown>;
|
|
250
|
+
readonly sizes?: ScreenSizeSet;
|
|
251
|
+
readonly devices?: readonly ScreenDevice[];
|
|
252
|
+
readonly tolerance?: Tolerance;
|
|
253
|
+
readonly timeoutMs?: number;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const blockPicture = (browser: Browser, suite: BlockSuite, device: ScreenDevice): Promise<Uint8Array> =>
|
|
257
|
+
browser.newContext(device.options).then((context) =>
|
|
258
|
+
context
|
|
259
|
+
.newPage()
|
|
260
|
+
.then((page) =>
|
|
261
|
+
(typeof suite.url === "string" ? Promise.resolve(suite.url) : suite.url())
|
|
262
|
+
.then((url) => page.goto(url, { waitUntil: "networkidle" }))
|
|
263
|
+
.then(() => settled(page))
|
|
264
|
+
.then(() => suite.setup?.(page))
|
|
265
|
+
.then(() => pictureOf(page, suite.selector)),
|
|
266
|
+
)
|
|
267
|
+
.finally(() => context.close()),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
export function blockScreens(engine: ScreenEngine, suite: BlockSuite): void {
|
|
271
|
+
let launched: Promise<Browser> | undefined;
|
|
272
|
+
afterAll(() => launched?.then((browser) => browser.close()));
|
|
273
|
+
for (const device of [...screenDevices(suite.sizes ?? "anchor"), ...(suite.devices ?? [])]) {
|
|
274
|
+
test(
|
|
275
|
+
`${suite.block} at ${device.name} in ${engine} looks as approved, instead of changing its look with no picture approving it`,
|
|
276
|
+
() => {
|
|
277
|
+
launched ??= LAUNCHERS[engine]();
|
|
278
|
+
return launched
|
|
279
|
+
.then((browser) => blockPicture(browser, suite, device))
|
|
280
|
+
.then((picture) => matchesApproved(picture, { fixtures: suite.fixtures, engine, device, ...(suite.tolerance === undefined ? {} : { tolerance: suite.tolerance }) }))
|
|
281
|
+
.then((verdict) => expect(verdict).toBe(AS_APPROVED));
|
|
282
|
+
},
|
|
283
|
+
suite.timeoutMs ?? 60_000,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|