@smi-digital/create-smi-app 2.13.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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smi-digital/create-smi-app",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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 = () => {};
@@ -1,7 +1,17 @@
1
1
  {
2
- "_comment": "Config for the A/B check in the shared CI pipeline. Both sides of every dependency PR are built and compared against this list, so it is what makes a bump verifiable. Starts with '/' only — extend it as the site grows. Aim for one route per page TYPE, not per page; five to ten is the useful range. A route is either \"/path\" (must answer 200) or {\"path\": \"/x\", \"status\": 404} for pages that legitimately do not. Listing a route that does not exist yet fails the check, so add them as you build them.",
3
- "routes": ["/"],
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": [],
4
12
  "viewports": [375, 1440],
5
- "_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 that this project does not have; a probe that never matches anything is reported as a configuration error rather than passing silently.",
6
- "interactions": ["mobile-nav"]
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": []
7
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
+ });
@@ -50,6 +50,38 @@
50
50
  {
51
51
  "template": "frontend/test/visual.config.json.template",
52
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"
53
85
  }
54
86
  ],
55
87
  "cdFiles": [