@smi-digital/create-smi-app 2.12.0 → 2.14.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/dist/index.js +30 -0
- package/package.json +1 -1
- package/templates/integrations/strapi-astro/.github/workflows/pr-validation.yml.template +12 -4
- package/templates/integrations/strapi-astro/AGENTS.md.template +118 -0
- package/templates/integrations/strapi-astro/frontend/src/components/islands/ExampleCounter/ExampleCounter.test.tsx.template +77 -0
- package/templates/integrations/strapi-astro/frontend/src/components/islands/ExampleCounter/ExampleCounter.tsx.template +47 -0
- package/templates/integrations/strapi-astro/frontend/test/islands-without-tests.json.template +5 -0
- package/templates/integrations/strapi-astro/frontend/test/jsdom-ssr.ts.template +24 -0
- package/templates/integrations/strapi-astro/frontend/test/render-astro.ts.template +28 -0
- package/templates/integrations/strapi-astro/frontend/test/setup.ts.template +164 -0
- package/templates/integrations/strapi-astro/frontend/test/visual.config.json.template +17 -0
- package/templates/integrations/strapi-astro/frontend/vitest.config.ts.template +53 -0
- package/templates/integrations/strapi-astro/integration.config.json +36 -0
package/dist/index.js
CHANGED
|
@@ -513,6 +513,36 @@ async function createApps(projectRoot, targets) {
|
|
|
513
513
|
)
|
|
514
514
|
);
|
|
515
515
|
}
|
|
516
|
+
if (target.framework === "astro") {
|
|
517
|
+
await runStep(
|
|
518
|
+
"Adding the unit test toolchain",
|
|
519
|
+
async () => runCommandQuiet(
|
|
520
|
+
"npm",
|
|
521
|
+
[
|
|
522
|
+
"install",
|
|
523
|
+
"--save-dev",
|
|
524
|
+
"vitest",
|
|
525
|
+
"jsdom",
|
|
526
|
+
"@testing-library/react",
|
|
527
|
+
"@testing-library/user-event",
|
|
528
|
+
"@testing-library/jest-dom"
|
|
529
|
+
],
|
|
530
|
+
join4(projectRoot, target.directory)
|
|
531
|
+
)
|
|
532
|
+
);
|
|
533
|
+
await runStep("Adding the test scripts", async () => {
|
|
534
|
+
await runCommandQuiet(
|
|
535
|
+
"npm",
|
|
536
|
+
["pkg", "set", "scripts.test=vitest run"],
|
|
537
|
+
join4(projectRoot, target.directory)
|
|
538
|
+
);
|
|
539
|
+
await runCommandQuiet(
|
|
540
|
+
"npm",
|
|
541
|
+
["pkg", "set", "scripts.test:watch=vitest"],
|
|
542
|
+
join4(projectRoot, target.directory)
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
516
546
|
await runSequentially(index + 1);
|
|
517
547
|
};
|
|
518
548
|
await runSequentially(0);
|
package/package.json
CHANGED
|
@@ -9,11 +9,14 @@ concurrency:
|
|
|
9
9
|
group: pr-validation-${{ github.event.pull_request.number || github.ref }}
|
|
10
10
|
cancel-in-progress: true
|
|
11
11
|
|
|
12
|
-
# Least-privilege token.
|
|
13
|
-
# detection needs
|
|
12
|
+
# Least-privilege token.
|
|
13
|
+
# pull-requests: write — change detection needs to read the PR's changed files,
|
|
14
|
+
# and the A/B check posts its report as a PR comment. A reusable workflow can
|
|
15
|
+
# never exceed its caller's permissions, so downgrading this to `read` silently
|
|
16
|
+
# turns the A/B report into a skipped step with a warning.
|
|
14
17
|
permissions:
|
|
15
18
|
contents: read
|
|
16
|
-
pull-requests:
|
|
19
|
+
pull-requests: write
|
|
17
20
|
|
|
18
21
|
jobs:
|
|
19
22
|
ci:
|
|
@@ -21,6 +24,11 @@ jobs:
|
|
|
21
24
|
# Defaults match this scaffold's layout (frontend/ + backend/, Node 24).
|
|
22
25
|
# See the smi-digital/ci-library README for all available inputs
|
|
23
26
|
# (frontend_dir, backend_dir, node_version, audit_level,
|
|
24
|
-
# backend_audit_mode, run_integration, run_sast).
|
|
27
|
+
# backend_audit_mode, run_integration, run_sast, run_ab_check).
|
|
28
|
+
#
|
|
29
|
+
# The A/B check builds the frontend twice and diffs the result. It activates
|
|
30
|
+
# as soon as frontend/test/visual.config.json exists — which this scaffold
|
|
31
|
+
# ships — and is what makes a dependency bump verifiable. Extend the route
|
|
32
|
+
# list there as the site grows; see AGENTS.md.
|
|
25
33
|
#
|
|
26
34
|
# Branch protection: require the single check "ci / CI Status" on main.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Working in this project
|
|
2
|
+
|
|
3
|
+
Most of the code here is written by an agent that reads this file at the start
|
|
4
|
+
of every session, and by a human who read it once. It is written for the first
|
|
5
|
+
reader: concrete steps, in the order they are done, with the reason attached
|
|
6
|
+
where the reason is what makes the step correct.
|
|
7
|
+
|
|
8
|
+
## The two checks that gate a pull request
|
|
9
|
+
|
|
10
|
+
**The A/B visual check** builds the frontend twice — once from the merge-base,
|
|
11
|
+
once from your branch — against identical content, and diffs the rendered HTML,
|
|
12
|
+
screenshots, hydration probes and console output.
|
|
13
|
+
|
|
14
|
+
It **fails** only when no source changed, because then a difference can only
|
|
15
|
+
have come from a dependency. On a pull request that touches `src/`, it drops to
|
|
16
|
+
**report-only**: a source change is supposed to change the page, so its diff is
|
|
17
|
+
review material rather than a verdict.
|
|
18
|
+
|
|
19
|
+
**The unit tests** cover exactly the case the A/B check cannot. On a feature
|
|
20
|
+
branch they are the only thing gating behaviour, which is why the rules below
|
|
21
|
+
are structural rather than advisory.
|
|
22
|
+
|
|
23
|
+
## Adding an island
|
|
24
|
+
|
|
25
|
+
An island is an interactive component under `src/components/islands/<Name>/`.
|
|
26
|
+
|
|
27
|
+
1. Write the test first, at `src/components/islands/<Name>/<Name>.test.tsx`.
|
|
28
|
+
Follow `ExampleCounter` — it is a worked example, not a placeholder, and its
|
|
29
|
+
test lists what to assert and what not to.
|
|
30
|
+
2. Write the component.
|
|
31
|
+
3. Run `npm test`.
|
|
32
|
+
|
|
33
|
+
**Every island directory must contain a test file.** This is checked against the
|
|
34
|
+
file tree on every pull request, not against your diff — so it asks nothing of
|
|
35
|
+
a copy edit, and it cannot be satisfied by touching an unrelated test.
|
|
36
|
+
|
|
37
|
+
Islands that predate the rule are listed in `test/islands-without-tests.json`.
|
|
38
|
+
That list only ever shrinks. Adding a line to make CI pass defeats the rule;
|
|
39
|
+
write the test instead.
|
|
40
|
+
|
|
41
|
+
### What to assert
|
|
42
|
+
|
|
43
|
+
- it renders the props it was given
|
|
44
|
+
- the interaction responds — the click changes what the click should change
|
|
45
|
+
- it honours `prefers-reduced-motion` wherever it branches on it
|
|
46
|
+
- it survives the CMS returning nothing, or a field left empty
|
|
47
|
+
|
|
48
|
+
### What not to assert
|
|
49
|
+
|
|
50
|
+
Markup snapshots and styling. Both change for good reasons and become churn,
|
|
51
|
+
and the A/B check already covers them against a real browser.
|
|
52
|
+
|
|
53
|
+
### Two failures worth knowing about in advance
|
|
54
|
+
|
|
55
|
+
**Reading `prefers-reduced-motion` in an effect.** An effect runs after the
|
|
56
|
+
first render, so an entrance animation has already started by the time the flag
|
|
57
|
+
arrives — it is then abandoned part way, leaving the element at neither its
|
|
58
|
+
start nor its end state. Read the preference synchronously in a lazy
|
|
59
|
+
`useState` initialiser if anything animates on mount. This has happened here
|
|
60
|
+
before.
|
|
61
|
+
|
|
62
|
+
**Throwing when a CMS list is empty.** `list[activeIndex]` is `undefined` for
|
|
63
|
+
an empty list, and an island that throws during render takes the whole page
|
|
64
|
+
down with it. Return `null` instead. This has happened here before, in three
|
|
65
|
+
islands at once.
|
|
66
|
+
|
|
67
|
+
## Adding a page
|
|
68
|
+
|
|
69
|
+
Nothing to configure. The A/B check **derives** its route list from
|
|
70
|
+
`src/pages/**`, so a new page is in the gate the moment it exists.
|
|
71
|
+
|
|
72
|
+
`test/visual.config.json` holds only the exceptions:
|
|
73
|
+
|
|
74
|
+
- `exclude` — a route to keep out of the gate, **with a reason written next to
|
|
75
|
+
it**. The usual grounds are content that legitimately differs between two
|
|
76
|
+
builds minutes apart: live third-party data, a feed, a map. Excluding
|
|
77
|
+
anything else hides it.
|
|
78
|
+
- `statusOverrides` — both an expected status and a way to add a path that is
|
|
79
|
+
not a file under `src/pages`, such as the 404 probe.
|
|
80
|
+
- `include` — a stable CMS slug standing in for a dynamic route. Dynamic
|
|
81
|
+
`[param]` routes are skipped by discovery, because a path that exists today
|
|
82
|
+
may not next month and the status assert is absolute.
|
|
83
|
+
|
|
84
|
+
## Adding something that animates forever
|
|
85
|
+
|
|
86
|
+
A logo marquee, a ticker, an autoplaying carousel — anything JavaScript moves
|
|
87
|
+
on every animation frame never reaches a final state, so each A/B run
|
|
88
|
+
photographs it at a different offset and the diff is noise that buries real
|
|
89
|
+
findings.
|
|
90
|
+
|
|
91
|
+
Add its selector to `freeze` in `test/visual.config.json`. Two mechanisms need
|
|
92
|
+
it, and they are not interchangeable:
|
|
93
|
+
|
|
94
|
+
- a **transform** stepped per frame (framer-motion's `useAnimationFrame`)
|
|
95
|
+
- **`scrollLeft`** stepped per frame on a container
|
|
96
|
+
|
|
97
|
+
The element is still captured and compared; freezing only pins where it sits.
|
|
98
|
+
|
|
99
|
+
## Adding a hydration probe
|
|
100
|
+
|
|
101
|
+
A screenshot cannot tell you an island stopped mounting — the server-rendered
|
|
102
|
+
markup is identical either way. The probes in `interactions` are the only thing
|
|
103
|
+
that catches a dead island across a framework major.
|
|
104
|
+
|
|
105
|
+
The shared ones are `faq-accordion`, `mobile-nav` and `consent-banner`. Remove
|
|
106
|
+
any this project does not have: a probe that never matches anything is reported
|
|
107
|
+
as a configuration error rather than passing quietly. A project-specific probe
|
|
108
|
+
means extending the shared action in `ci-library`.
|
|
109
|
+
|
|
110
|
+
## Running things
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm test # unit tests, once
|
|
114
|
+
npm run test:watch
|
|
115
|
+
npm run lint
|
|
116
|
+
npx astro check # types, including .astro files
|
|
117
|
+
npm run build
|
|
118
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import ExampleCounter from "./ExampleCounter";
|
|
5
|
+
import { REDUCED_MOTION, setMediaQuery } from "../../../../test/setup";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The worked example. Copy its shape for real islands, then delete this
|
|
9
|
+
* directory — island and test together, since the island-has-a-test check
|
|
10
|
+
* counts directories.
|
|
11
|
+
*
|
|
12
|
+
* WHY these tests exist at all. The A/B visual check drops to report-only the
|
|
13
|
+
* moment a pull request touches source — correctly, since a source change is
|
|
14
|
+
* supposed to change the page — so on a feature branch these are the only
|
|
15
|
+
* thing gating behaviour. They cover what a screenshot cannot: that the island
|
|
16
|
+
* mounted, that clicking it does what clicking it should, and that it honours
|
|
17
|
+
* a stated preference.
|
|
18
|
+
*
|
|
19
|
+
* WHAT to assert, in roughly this order:
|
|
20
|
+
*
|
|
21
|
+
* 1. it renders the props it was given
|
|
22
|
+
* 2. the interaction responds — the click changes what the click should
|
|
23
|
+
* 3. it honours `prefers-reduced-motion` wherever it branches on it
|
|
24
|
+
* 4. it survives the CMS returning nothing, or a field left empty
|
|
25
|
+
*
|
|
26
|
+
* WHAT NOT to assert: markup snapshots and styling. Both change for good
|
|
27
|
+
* reasons and turn into churn, and the A/B check already covers them against a
|
|
28
|
+
* real browser rather than jsdom's approximation of one.
|
|
29
|
+
*/
|
|
30
|
+
describe("ExampleCounter", () => {
|
|
31
|
+
it("renders the label it was given", () => {
|
|
32
|
+
render(<ExampleCounter label="Zähler erhöhen" />);
|
|
33
|
+
|
|
34
|
+
expect(screen.getByRole("button", { name: "Zähler erhöhen" })).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("starts at zero", () => {
|
|
38
|
+
render(<ExampleCounter label="Zähler erhöhen" />);
|
|
39
|
+
|
|
40
|
+
expect(screen.getByRole("status").textContent).toBe("0");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts up when clicked", async () => {
|
|
44
|
+
const user = userEvent.setup();
|
|
45
|
+
render(<ExampleCounter label="Zähler erhöhen" />);
|
|
46
|
+
|
|
47
|
+
await user.click(screen.getByRole("button"));
|
|
48
|
+
await user.click(screen.getByRole("button"));
|
|
49
|
+
|
|
50
|
+
expect(screen.getByRole("status").textContent).toBe("2");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("counts by the step it was given", async () => {
|
|
54
|
+
const user = userEvent.setup();
|
|
55
|
+
render(<ExampleCounter label="Zähler erhöhen" step={5} />);
|
|
56
|
+
|
|
57
|
+
await user.click(screen.getByRole("button"));
|
|
58
|
+
|
|
59
|
+
expect(screen.getByRole("status").textContent).toBe("5");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("stops announcing every change under reduced motion", async () => {
|
|
63
|
+
// `setMediaQuery` is backed by a real preference the stub reports, so this
|
|
64
|
+
// asserts that the component HONOURS the query rather than merely
|
|
65
|
+
// surviving it.
|
|
66
|
+
setMediaQuery(REDUCED_MOTION, true);
|
|
67
|
+
render(<ExampleCounter label="Zähler erhöhen" />);
|
|
68
|
+
|
|
69
|
+
expect(screen.getByRole("status").getAttribute("aria-live")).toBe("off");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("announces changes when motion is allowed", () => {
|
|
73
|
+
render(<ExampleCounter label="Zähler erhöhen" />);
|
|
74
|
+
|
|
75
|
+
expect(screen.getByRole("status").getAttribute("aria-live")).toBe("polite");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
interface ExampleCounterProps {
|
|
4
|
+
label: string;
|
|
5
|
+
step?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A worked example of the shape every island in this project follows, and the
|
|
10
|
+
* thing its test is written against. Delete both once you have a real island —
|
|
11
|
+
* but delete them together, because the island-has-a-test check counts
|
|
12
|
+
* directories, not files.
|
|
13
|
+
*
|
|
14
|
+
* It is deliberately small and deliberately not decorative: it holds state,
|
|
15
|
+
* responds to a click, reads a media query, and reports its state to assistive
|
|
16
|
+
* technology. Those four are what the tests next door assert, and they are the
|
|
17
|
+
* four things a framework major most often breaks.
|
|
18
|
+
*/
|
|
19
|
+
export default function ExampleCounter({
|
|
20
|
+
label,
|
|
21
|
+
step = 1,
|
|
22
|
+
}: ExampleCounterProps): ReactElement {
|
|
23
|
+
const [count, setCount] = useState(0);
|
|
24
|
+
const [reducedMotion, setReducedMotion] = useState(false);
|
|
25
|
+
|
|
26
|
+
// Read once on mount and then on change. Reading it in an effect is fine
|
|
27
|
+
// HERE because nothing animates before the flag is known — an island that
|
|
28
|
+
// starts an entrance animation must read the preference synchronously on the
|
|
29
|
+
// first render instead, or it begins the animation for someone who asked not
|
|
30
|
+
// to have one and then abandons it half way.
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
33
|
+
const sync = (): void => setReducedMotion(query.matches);
|
|
34
|
+
sync();
|
|
35
|
+
query.addEventListener("change", sync);
|
|
36
|
+
return () => query.removeEventListener("change", sync);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div>
|
|
41
|
+
<output aria-live={reducedMotion ? "off" : "polite"}>{count}</output>
|
|
42
|
+
<button type="button" onClick={() => setCount((n) => n + step)}>
|
|
43
|
+
{label}
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Islands that predate the island-has-a-test rule. A new project has none, and that is the state to keep it in — every island here is one a change can break with nothing to notice.",
|
|
3
|
+
"_ratchet": "This list only ever SHRINKS. Deleting a line is the unit of progress. Adding one to make CI pass defeats the rule the file exists to phase out, so add a test instead. The check also warns when an entry has since gained a test, or names a directory that no longer exists — a stale exemption is one a later regression inherits for a reason that expired.",
|
|
4
|
+
"grandfathered": []
|
|
5
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsdom, but with modules resolved the way the server resolves them.
|
|
3
|
+
*
|
|
4
|
+
* Astro's Vite plugin compiles a `.astro` file two different ways and picks
|
|
5
|
+
* between them by Vite environment name: the server build is a component
|
|
6
|
+
* factory, the client build is a stub that throws "Astro components cannot be
|
|
7
|
+
* used in the browser". Vitest's `jsdom` environment asks Vite for the client
|
|
8
|
+
* build, so `container.renderToString(Component)` receives the stub — and
|
|
9
|
+
* reports it as a component with no matching renderer, which reads like a
|
|
10
|
+
* missing framework integration and is nothing of the kind.
|
|
11
|
+
*
|
|
12
|
+
* `viteEnvironment: "ssr"` changes which build of a module is loaded. It does
|
|
13
|
+
* not change where the test runs: the environment is still jsdom's, `document`
|
|
14
|
+
* is still there, and DOM behaviour is still exercised against it.
|
|
15
|
+
*
|
|
16
|
+
* Point `test.environment` at this file rather than at "jsdom".
|
|
17
|
+
*/
|
|
18
|
+
import { builtinEnvironments } from 'vitest/runtime';
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
name: 'jsdom-ssr',
|
|
22
|
+
viteEnvironment: 'ssr' as const,
|
|
23
|
+
setup: builtinEnvironments.jsdom.setup,
|
|
24
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a real `.astro` component into the test document.
|
|
3
|
+
*
|
|
4
|
+
* Islands here are a `.astro` file that emits markup plus a `.ts` module that
|
|
5
|
+
* wires behaviour to it by selector. Testing the module against a hand-written
|
|
6
|
+
* fixture would test the fixture: the selectors would agree with each other and
|
|
7
|
+
* both could disagree with what ships. Astro's container API renders the
|
|
8
|
+
* component the build renders, so a class renamed in the template breaks the
|
|
9
|
+
* test that depends on it, which is the whole point.
|
|
10
|
+
*
|
|
11
|
+
* The container does NOT run the component's `<script>` tag — that is a client
|
|
12
|
+
* bundle, and jsdom is not running the bundler. So a test imports the init
|
|
13
|
+
* function and calls it itself, which is also what makes the call explicit and
|
|
14
|
+
* lets a test assert what happens before it.
|
|
15
|
+
*/
|
|
16
|
+
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
|
|
17
|
+
|
|
18
|
+
export async function renderIsland(
|
|
19
|
+
component: Parameters<AstroContainer['renderToString']>[0],
|
|
20
|
+
options: Parameters<AstroContainer['renderToString']>[1] = {},
|
|
21
|
+
): Promise<HTMLElement> {
|
|
22
|
+
const container = await AstroContainer.create();
|
|
23
|
+
document.body.innerHTML = await container.renderToString(
|
|
24
|
+
component,
|
|
25
|
+
options,
|
|
26
|
+
);
|
|
27
|
+
return document.body;
|
|
28
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test environment setup, loaded before every test file.
|
|
3
|
+
*
|
|
4
|
+
* jsdom implements the DOM, not the browser. Several things components here
|
|
5
|
+
* rely on are simply absent from it, and each one throws on first use rather
|
|
6
|
+
* than degrading — so without these stubs a test fails for a reason that has
|
|
7
|
+
* nothing to do with the component.
|
|
8
|
+
*
|
|
9
|
+
* These are stubs with behaviour, not silencers: `matchMedia` is backed by a
|
|
10
|
+
* settable preference, so a test can assert that a component honours
|
|
11
|
+
* `prefers-reduced-motion` instead of merely surviving the query.
|
|
12
|
+
*/
|
|
13
|
+
import { beforeEach, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
/** Media queries currently reported as matching. Reset before every test. */
|
|
16
|
+
let matchingQueries = new Set<string>();
|
|
17
|
+
|
|
18
|
+
const listeners = new Map<string, Set<(e: MediaQueryListEvent) => void>>();
|
|
19
|
+
|
|
20
|
+
export const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Make a media query match for the current test.
|
|
24
|
+
*
|
|
25
|
+
* Pass the query exactly as the component writes it — matching is by string,
|
|
26
|
+
* not by parsing, which keeps the stub honest: a test that claims to exercise
|
|
27
|
+
* reduced motion has to name the query the component actually asks about.
|
|
28
|
+
*/
|
|
29
|
+
export function setMediaQuery(query: string, matches: boolean): void {
|
|
30
|
+
if (matches) matchingQueries.add(query);
|
|
31
|
+
else matchingQueries.delete(query);
|
|
32
|
+
for (const listener of listeners.get(query) ?? []) {
|
|
33
|
+
listener({ matches, media: query } as MediaQueryListEvent);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reports every observed element as intersecting, on the next task.
|
|
39
|
+
*
|
|
40
|
+
* jsdom has no layout, so a real implementation could only ever report
|
|
41
|
+
* "not intersecting" — every scroll-revealed section would stay hidden and
|
|
42
|
+
* every test of one would assert against an empty container. Deferred rather
|
|
43
|
+
* than synchronous because callers observe during setup and expect the callback
|
|
44
|
+
* no earlier than the next task, as the real observer guarantees.
|
|
45
|
+
*
|
|
46
|
+
* Same tradeoff the A/B capture makes, and for the same reason: reveal logic is
|
|
47
|
+
* covered by the browser, not by jsdom.
|
|
48
|
+
*/
|
|
49
|
+
class ImmediateIntersectionObserver implements IntersectionObserver {
|
|
50
|
+
readonly root: Element | Document | null;
|
|
51
|
+
readonly rootMargin: string;
|
|
52
|
+
readonly thresholds: ReadonlyArray<number>;
|
|
53
|
+
private readonly callback: IntersectionObserverCallback;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
callback: IntersectionObserverCallback,
|
|
57
|
+
options: IntersectionObserverInit = {},
|
|
58
|
+
) {
|
|
59
|
+
this.callback = callback;
|
|
60
|
+
this.root = options.root ?? null;
|
|
61
|
+
this.rootMargin = options.rootMargin ?? '0px';
|
|
62
|
+
this.thresholds = [options.threshold ?? 0].flat();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
observe(target: Element): void {
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
const rect = target.getBoundingClientRect();
|
|
68
|
+
this.callback(
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
target,
|
|
72
|
+
isIntersecting: true,
|
|
73
|
+
intersectionRatio: 1,
|
|
74
|
+
boundingClientRect: rect,
|
|
75
|
+
intersectionRect: rect,
|
|
76
|
+
rootBounds: null,
|
|
77
|
+
time: 0,
|
|
78
|
+
} as IntersectionObserverEntry,
|
|
79
|
+
],
|
|
80
|
+
this,
|
|
81
|
+
);
|
|
82
|
+
}, 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
unobserve(): void {}
|
|
86
|
+
disconnect(): void {}
|
|
87
|
+
takeRecords(): IntersectionObserverEntry[] {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function installGlobals(): void {
|
|
93
|
+
vi.stubGlobal('matchMedia', (query: string): MediaQueryList => {
|
|
94
|
+
const register = (fn: (e: MediaQueryListEvent) => void) => {
|
|
95
|
+
if (!listeners.has(query)) listeners.set(query, new Set());
|
|
96
|
+
listeners.get(query)!.add(fn);
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
get matches() {
|
|
100
|
+
return matchingQueries.has(query);
|
|
101
|
+
},
|
|
102
|
+
media: query,
|
|
103
|
+
onchange: null,
|
|
104
|
+
addEventListener: (_: string, fn: EventListener) =>
|
|
105
|
+
register(fn as never),
|
|
106
|
+
removeEventListener: (_: string, fn: EventListener) =>
|
|
107
|
+
listeners.get(query)?.delete(fn as never),
|
|
108
|
+
// Deprecated pair, still used by some libraries.
|
|
109
|
+
addListener: (fn: never) => register(fn),
|
|
110
|
+
removeListener: (fn: never) => listeners.get(query)?.delete(fn),
|
|
111
|
+
dispatchEvent: () => true,
|
|
112
|
+
} as MediaQueryList;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver);
|
|
116
|
+
|
|
117
|
+
/** Never fires: jsdom elements never resize, so a callback would be a lie. */
|
|
118
|
+
vi.stubGlobal(
|
|
119
|
+
'ResizeObserver',
|
|
120
|
+
class {
|
|
121
|
+
observe(): void {}
|
|
122
|
+
unobserve(): void {}
|
|
123
|
+
disconnect(): void {}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
vi.stubGlobal('scrollTo', () => {});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Drive requestAnimationFrame from a timer rather than jsdom's default.
|
|
131
|
+
*
|
|
132
|
+
* Animation loops here are `const step = () => { …; requestAnimationFrame(step) }`.
|
|
133
|
+
* Under fake timers jsdom's rAF never fires and the loop stalls; under real
|
|
134
|
+
* timers it fires as fast as it can and a runaway loop hangs the test file.
|
|
135
|
+
* A setTimeout-backed frame is advanceable by `vi.advanceTimersByTime`, so a
|
|
136
|
+
* test can step an animation deliberately and assert what it did.
|
|
137
|
+
*/
|
|
138
|
+
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) =>
|
|
139
|
+
setTimeout(() => fn(performance.now()), 16),
|
|
140
|
+
);
|
|
141
|
+
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Installed at module load AND before every test, and it has to be both.
|
|
145
|
+
//
|
|
146
|
+
// At module load, because libraries reach for these while they are being
|
|
147
|
+
// imported — GSAP's ScrollTrigger calls `window.matchMedia` from
|
|
148
|
+
// `ScrollTrigger.register`, which runs at import time, long before any hook.
|
|
149
|
+
//
|
|
150
|
+
// Before every test, because a test that stubs a global of its own and tidies
|
|
151
|
+
// up with `vi.unstubAllGlobals()` removes EVERY stub, including these — so the
|
|
152
|
+
// next test in the file would run against a jsdom with no `matchMedia` and
|
|
153
|
+
// fail with "matchMedia is not a function", nowhere near the cause.
|
|
154
|
+
installGlobals();
|
|
155
|
+
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
matchingQueries = new Set();
|
|
158
|
+
listeners.clear();
|
|
159
|
+
installGlobals();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// jsdom throws "Not implemented" on this. Assigned to the prototype rather than
|
|
163
|
+
// stubbed as a global, so it survives an unstubAllGlobals in any test.
|
|
164
|
+
Element.prototype.scrollIntoView = () => {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Exceptions only. The A/B check DERIVES the route list from src/pages, so adding a page adds it to the gate automatically and there is no list here to keep in sync. This file exists for the cases the rule cannot know about.",
|
|
3
|
+
"_dynamic_routes": "Discovery skips `[param]` routes, because they resolve from Strapi — a path that exists today may not next month, and the status assert is absolute. Pin the ones worth covering under `include`, one per page TYPE rather than one per page.",
|
|
4
|
+
"_exclude": "Remove a route from the gate ONLY with a reason written next to it. The usual grounds are content that legitimately differs between two builds minutes apart — live third-party data, a feed, a map — which would otherwise report as a regression on every run. A stale entry here silently drops a page from the gate, so the check warns when an excluded path is not a route.",
|
|
5
|
+
"exclude": {},
|
|
6
|
+
"_statusOverrides": "Both a status expectation and a way in: a path that is not a file under src/pages is added by listing it here. The 404 below is the standard case — a 404 page is a page and belongs in the comparison.",
|
|
7
|
+
"statusOverrides": {
|
|
8
|
+
"/nicht-vorhanden": 404
|
|
9
|
+
},
|
|
10
|
+
"_include": "Extra paths to cover, typically a stable CMS slug standing in for a dynamic route.",
|
|
11
|
+
"include": [],
|
|
12
|
+
"viewports": [375, 1440],
|
|
13
|
+
"_interactions": "Hydration probes. A screenshot cannot tell you an island stopped mounting — the server markup is identical either way — so these are the only thing that catches a dead island across a framework major. Supported: faq-accordion, mobile-nav, consent-banner. Remove any this project does not have; a probe that never matches anything is reported as a configuration error rather than passing silently.",
|
|
14
|
+
"interactions": ["mobile-nav"],
|
|
15
|
+
"_freeze": "Selectors for elements JavaScript moves on every animation frame — a logo marquee is the standard case. They never reach a final state, so each run photographs a different offset and the diff is noise. Two mechanisms need listing here: a transform stepped per frame (framer-motion's useAnimationFrame) and `scrollLeft` stepped per frame. The element is still captured and compared; this only pins where it sits.",
|
|
16
|
+
"freeze": []
|
|
17
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/// <reference types="vitest/config" />
|
|
2
|
+
/**
|
|
3
|
+
* Unit test configuration.
|
|
4
|
+
*
|
|
5
|
+
* `getViteConfig` rather than a standalone Vite config: it loads this project's
|
|
6
|
+
* astro.config.mjs, so tests resolve `@/` aliases, `.scss` and integrations
|
|
7
|
+
* exactly as the build does. A hand-rolled config would be a second, drifting
|
|
8
|
+
* description of how this project resolves modules, and the first symptom would
|
|
9
|
+
* be a test importing something subtly different from what ships.
|
|
10
|
+
*
|
|
11
|
+
* Why these tests exist at all: the A/B visual check drops to report-only as
|
|
12
|
+
* soon as a pull request touches source, which is precisely when source is most
|
|
13
|
+
* likely to be broken. These are the only behavioural gate a feature branch has.
|
|
14
|
+
*/
|
|
15
|
+
import { getViteConfig } from 'astro/config';
|
|
16
|
+
|
|
17
|
+
export default getViteConfig({
|
|
18
|
+
test: {
|
|
19
|
+
// Not "jsdom" — see test/jsdom-ssr.ts. Same environment, but modules
|
|
20
|
+
// are resolved as the server resolves them, which is what makes Astro's
|
|
21
|
+
// container API able to render a real `.astro` component here.
|
|
22
|
+
environment: './test/jsdom-ssr.ts',
|
|
23
|
+
globals: true,
|
|
24
|
+
setupFiles: ['./test/setup.ts'],
|
|
25
|
+
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
|
26
|
+
// CSS is deliberately NOT processed. Tests here assert behaviour and
|
|
27
|
+
// text, never styling — that is the A/B check's job, and it does it
|
|
28
|
+
// against a real browser rather than jsdom's approximation of one.
|
|
29
|
+
// Leaving it off also keeps a Sass compile out of every test run.
|
|
30
|
+
css: false,
|
|
31
|
+
// Restricted deliberately. Vitest's default set includes
|
|
32
|
+
// `queueMicrotask`, and faking that deadlocks anything awaiting a
|
|
33
|
+
// promise — `userEvent` hangs until the test times out, five seconds
|
|
34
|
+
// later, with no indication that the timers were the cause.
|
|
35
|
+
//
|
|
36
|
+
// `performance` is faked so the fake clock is the only source of time
|
|
37
|
+
// a test has to reason about. Note it does NOT make an eased animation
|
|
38
|
+
// reach its target: advancing timers steps the frames, but the elapsed
|
|
39
|
+
// time those frames read does not keep up, so a `performance.now()`
|
|
40
|
+
// easing stalls part way however far the clock is moved. Assert that
|
|
41
|
+
// such an animation RUNS, and assert its result through the state it
|
|
42
|
+
// writes rather than through the number it is easing towards.
|
|
43
|
+
fakeTimers: {
|
|
44
|
+
toFake: [
|
|
45
|
+
'setTimeout',
|
|
46
|
+
'clearTimeout',
|
|
47
|
+
'setInterval',
|
|
48
|
+
'clearInterval',
|
|
49
|
+
'Date',
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -46,6 +46,42 @@
|
|
|
46
46
|
{
|
|
47
47
|
"template": "backend/src/index.ts.template",
|
|
48
48
|
"target": "backend/src/index.ts"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"template": "frontend/test/visual.config.json.template",
|
|
52
|
+
"target": "frontend/test/visual.config.json"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"template": "frontend/test/islands-without-tests.json.template",
|
|
56
|
+
"target": "frontend/test/islands-without-tests.json"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"template": "frontend/test/setup.ts.template",
|
|
60
|
+
"target": "frontend/test/setup.ts"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"template": "frontend/test/jsdom-ssr.ts.template",
|
|
64
|
+
"target": "frontend/test/jsdom-ssr.ts"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"template": "frontend/test/render-astro.ts.template",
|
|
68
|
+
"target": "frontend/test/render-astro.ts"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"template": "frontend/vitest.config.ts.template",
|
|
72
|
+
"target": "frontend/vitest.config.ts"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"template": "frontend/src/components/islands/ExampleCounter/ExampleCounter.tsx.template",
|
|
76
|
+
"target": "frontend/src/components/islands/ExampleCounter/ExampleCounter.tsx"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"template": "frontend/src/components/islands/ExampleCounter/ExampleCounter.test.tsx.template",
|
|
80
|
+
"target": "frontend/src/components/islands/ExampleCounter/ExampleCounter.test.tsx"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"template": "AGENTS.md.template",
|
|
84
|
+
"target": "AGENTS.md"
|
|
49
85
|
}
|
|
50
86
|
],
|
|
51
87
|
"cdFiles": [
|