@marianmeres/stuic 3.113.0 → 3.115.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.
@@ -0,0 +1,164 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the codebase at commit cc9958b and the live Vitest 4 docs.
5
+ Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # Framework Setup
9
+
10
+ > This is the infrastructure dimension: the one-time work that turns "no DOM, no `$effect`"
11
+ > into a working browser test harness. The single most important takeaway: **a Vitest
12
+ > `projects` split routed by filename** (`*.test.ts` → fast node, `*.svelte.test.ts` → real
13
+ > browser) keeps the 9 existing suites untouched while adding component tests on top. The one
14
+ > hard prerequisite is a **vitest 3 → 4 major upgrade** — `vitest-browser-svelte@^2` peer-requires
15
+ > `vitest ^4`, and Browser Mode only became *stable* in Vitest 4.
16
+
17
+ ## Current state (verified at `cc9958b`)
18
+
19
+ - **Package manager:** pnpm. **Svelte:** 5.56.2. **SvelteKit:** 2.63.0 (adapter-auto). **Vitest:** 3.2.6.
20
+ - **Test config:** none dedicated. Vitest is configured only implicitly through
21
+ [`vite.config.ts`](../../vite.config.ts) (`plugins: [tailwindcss(), sveltekit()]`), and the
22
+ script is `"test": "vitest --dir src/"`.
23
+ - **What runs today:** 9 suites / ~59 tests, all **node environment, pure logic** — validation
24
+ helpers, stack classes (`NotificationsStack`, `AlertConfirmPromptStack`), `tr`, `replace-map`,
25
+ `storage-abstraction`, `checkout-utils`, `max-height`, `phone-validation`. Zero component tests.
26
+ - **Why components can't be tested today:** the `@sveltejs/kit/vite` plugin makes Vitest resolve
27
+ Svelte's **server (SSR) build** in node, so components render as strings and `$effect` / actions /
28
+ lifecycle never run (documented in project memory; an `$effect.root` + `flushSync` probe left the
29
+ effect body unrun even with `resolve.conditions: ['browser']`). Real-browser mode fixes this by
30
+ resolving the **client build** and running in an actual Chromium page.
31
+ - **No CI** (`.github/` absent). No browser-testing deps installed.
32
+
33
+ ## Target state
34
+
35
+ A two-project Vitest config. Tests are routed purely by filename:
36
+
37
+ | Project | Environment | Matches | Runs |
38
+ |---------|-------------|---------|------|
39
+ | `server` | `node` | `src/**/*.test.ts` (excl. `*.svelte.test.ts`) | the existing 9 pure-logic suites — unchanged, fast |
40
+ | `client` | real browser (Chromium via Playwright) | `src/**/*.svelte.test.ts` | new component tests with DOM, `$effect`, layout, focus |
41
+
42
+ ## Step 1 — Upgrade Vitest 3 → 4 (own commit, gating)
43
+
44
+ ```bash
45
+ pnpm add -D vitest@^4
46
+ pnpm test # confirm the 9 existing suites still pass before touching anything else
47
+ ```
48
+
49
+ Vitest 4 breaking changes that touch this repo (budget for them):
50
+
51
+ 1. **Browser providers are separate packages now** — there is no built-in `@vitest/browser` provider; you install `@vitest/browser-playwright` (Step 2).
52
+ 2. **Browser context import moved** — `@vitest/browser/context` → `vitest/browser` (only matters if/when we import `page`, `userEvent`, etc. directly; not needed for the basics).
53
+ 3. `test.workspace` is deprecated in favour of `test.projects` (we use `projects` from the start).
54
+
55
+ > **Risk:** an incidental break in the 9 node suites during the bump. Mitigation: this is its own
56
+ > commit; if `pnpm test` regresses, fix or revert before any browser work. Verify against
57
+ > `@sveltejs/vite-plugin-svelte@6.2.4` and `@tailwindcss/vite` — both are Vite-7-era and compatible
58
+ > with Vitest 4, but confirm the dev server (`pnpm dev`) and `pnpm build` still work after the bump.
59
+
60
+ ## Step 2 — Add browser deps + Chromium
61
+
62
+ ```bash
63
+ pnpm add -D @vitest/browser-playwright vitest-browser-svelte playwright
64
+ pnpm exec playwright install chromium # without this, browser tests fail: "No browsers found"
65
+ ```
66
+
67
+ We do **not** install `@vitest/browser` directly (pulled in transitively by the provider), and we
68
+ do **not** need `@testing-library/svelte` or `@testing-library/jest-dom` — `vitest-browser-svelte`'s
69
+ `render()` + `expect.element` replace both.
70
+
71
+ ## Step 3 — The `projects` config
72
+
73
+ Add a `test` block to [`vite.config.ts`](../../vite.config.ts) (keep the file; just extend it).
74
+ Verified against the live Vitest 4 docs (`provider: playwright()` is an imported **function**, not
75
+ the old `'playwright'` string; `instances` is required):
76
+
77
+ ```ts
78
+ /// <reference types="node" />
79
+ import { sveltekit } from "@sveltejs/kit/vite";
80
+ import { defineConfig } from "vitest/config";
81
+ import tailwindcss from "@tailwindcss/vite";
82
+ import { playwright } from "@vitest/browser-playwright";
83
+ import dotenv from "dotenv";
84
+
85
+ dotenv.config();
86
+
87
+ export default defineConfig({
88
+ plugins: [tailwindcss(), sveltekit()],
89
+ server: {
90
+ port: parseInt(process.env.DEV_PORT || "8886"),
91
+ host: true,
92
+ },
93
+ test: {
94
+ projects: [
95
+ {
96
+ extends: true, // inherit root plugins (tailwind + sveltekit)
97
+ test: {
98
+ name: "server",
99
+ environment: "node",
100
+ include: ["src/**/*.test.ts"],
101
+ exclude: ["src/**/*.svelte.test.ts"],
102
+ },
103
+ },
104
+ {
105
+ extends: true,
106
+ test: {
107
+ name: "client",
108
+ include: ["src/**/*.svelte.test.ts"],
109
+ setupFiles: ["vitest-browser-svelte"],
110
+ testTimeout: 2000,
111
+ browser: {
112
+ enabled: true,
113
+ headless: true,
114
+ provider: playwright(),
115
+ instances: [{ browser: "chromium" }],
116
+ },
117
+ },
118
+ },
119
+ ],
120
+ },
121
+ });
122
+ ```
123
+
124
+ Why this shape:
125
+
126
+ - **`extends: true`** makes each project inherit the root `plugins` array, so both the `tailwindcss()`
127
+ (component CSS / variant classes resolve — several STUIC components assert on variant/size class
128
+ output) and `sveltekit()` plugins apply. In the `client` project Vitest builds for the browser, so
129
+ the **client** Svelte build is used and `$effect`/actions run — the exact thing the node setup can't do.
130
+ - **The filename glob is the routing mechanism** and the single most important config decision. Note
131
+ `src/**/*.test.ts` *also* matches `foo.svelte.test.ts` (it ends in `.test.ts`), so the server
132
+ project's `exclude` is **required**, not optional.
133
+ - **`setupFiles: ['vitest-browser-svelte']`** is the entire setup — it registers auto-cleanup and
134
+ helpers. Browser mode eliminates the 60–250-line jsdom API-mock setup files older guides used.
135
+ - **`headless: true`** for CI; drop it (or `--browser.headless=false`) locally to watch tests run.
136
+
137
+ ## Step 4 — Update the test scripts
138
+
139
+ `--dir src/` is now redundant (the `include` globs scope each project). Suggested:
140
+
141
+ ```jsonc
142
+ "test": "vitest run", // one-shot, used by CI
143
+ "test:watch": "vitest", // watch mode for local dev
144
+ "test:ui": "vitest --ui" // optional: the Vitest UI, handy for browser tests
145
+ ```
146
+
147
+ `pnpm test` then runs **both** projects (node + browser) in one command.
148
+
149
+ ## Step 5 — Smoke test (prove the harness before writing real tests)
150
+
151
+ Create one trivial browser test — e.g. `src/lib/components/Separator/Separator.svelte.test.ts`
152
+ that renders `<Separator />` and asserts it's in the document. If it goes green, the client build +
153
+ Chromium + tailwind + sveltekit-plugin chain all work. **This is where the project-memory
154
+ "server-build" risk is actually disproven or confirmed** — treat a passing smoke test as the gate to
155
+ the rest of the plan.
156
+
157
+ ## Open questions / decisions needed
158
+
159
+ - **Config location** — keep it in `vite.config.ts` (recommended, minimal change; chosen here) vs a
160
+ dedicated `vitest.config.ts`. Purely organizational; nothing functional depends on it.
161
+ - **`@sveltejs/kit/vite` vs plain `@sveltejs/vite-plugin-svelte` in the `client` project** — the
162
+ SvelteKit plugin should be fine in browser mode (the `sv` CLI's own vitest add-on uses it), but if
163
+ the smoke test surfaces SSR/`$lib` alias issues, the fallback is to give the `client` project a
164
+ plain `svelte()` plugin instead of inheriting `sveltekit()`. Decide only if the smoke test fails.
@@ -0,0 +1,148 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the codebase at commit cc9958b and the live
5
+ vitest-browser-svelte docs. Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # Test Conventions
9
+
10
+ > How to write a STUIC browser component test, and — just as important — **what is worth
11
+ > asserting**. The headline: test *behavior the build can't see* (events fire, bindings update,
12
+ > aria/disabled/active states, computed layout), not "does it render" (already gated by
13
+ > `svelte-check` + `publint` + the build). Use `render()` → locators → `expect.element`. In Svelte 5
14
+ > events are props, so you assert them with spies; snippet children come from `createRawSnippet`.
15
+
16
+ ## Reconciling with `docs/testing.md`
17
+
18
+ [`docs/testing.md`](../testing.md) currently states the library **deliberately does not** test full
19
+ component rendering ("50+ components × prop combinations = slow suite with tiny yield... Rendering is
20
+ already gated by svelte-check + publint + the build") and treats interactive/visual behavior as
21
+ out of scope.
22
+
23
+ That reasoning was **correct for what it described and is not actually reversed here** — it just
24
+ predates a capability we didn't have:
25
+
26
+ - "Does it render / compile / export" → still low-yield, still covered by `svelte-check` + `publint` +
27
+ build. We will **not** write tests for that.
28
+ - "Does it *behave*" — click handlers, two-way `bind:`, `aria-*`/`disabled`/`active` state, focus
29
+ traps, viewport-clamped anchor positioning (cf. the recent `9d8c974` annotation-clamp fix) — was
30
+ **previously impossible** (node/server build, no DOM, no `$effect`). Browser mode makes it possible,
31
+ and *this* is the high-yield target.
32
+
33
+ **Task in the roadmap:** update `docs/testing.md` to add this browser-test layer so the docs aren't
34
+ self-contradictory — promote "interactive behavior" from ❌ to ✅-when-it's-a-real-contract, and point
35
+ to this directory. (See [`PROGRESS.md`](./PROGRESS.md), sprint task.)
36
+
37
+ ## File naming & location
38
+
39
+ - One test per component, **co-located** next to the `.svelte` file (matches the existing co-located
40
+ style, e.g. `Input/phone-validation.test.ts`).
41
+ - Name it `ComponentName.svelte.test.ts` — the `.svelte.test.ts` suffix is what routes it into the
42
+ browser `client` project (see [01](./01-framework-setup.md)). A plain `*.test.ts` next to a
43
+ component stays in the fast node project (correct for extracted pure logic like `_internal/*.ts`).
44
+
45
+ ## The canonical test
46
+
47
+ ```ts
48
+ // src/lib/components/Pill/Pill.svelte.test.ts
49
+ import { render } from "vitest-browser-svelte";
50
+ import { expect, test, vi } from "vitest";
51
+ import Pill from "./Pill.svelte";
52
+
53
+ test("renders label and applies intent", async () => {
54
+ const screen = render(Pill, { label: "Beta", intent: "primary" });
55
+ const pill = screen.getByText("Beta");
56
+ await expect.element(pill).toBeVisible();
57
+ await expect.element(pill).toHaveAttribute("data-intent", "primary");
58
+ });
59
+
60
+ test("dismiss button fires ondismiss", async () => {
61
+ const ondismiss = vi.fn();
62
+ const screen = render(Pill, { label: "Beta", ondismiss });
63
+ await screen.getByRole("button", { name: /dismiss/i }).click();
64
+ expect(ondismiss).toHaveBeenCalledOnce();
65
+ });
66
+ ```
67
+
68
+ Key facts (verified against the `vitest-browser-svelte` README):
69
+
70
+ - `render(Component, props)` returns a **screen** object with Playwright-style locator methods
71
+ (`getByRole`, `getByText`, `getByLabelText`, …). `render` is imported from `"vitest-browser-svelte"`.
72
+ - **`expect.element(locator)` auto-retries** — it polls the DOM until the assertion passes or times
73
+ out (`testTimeout`, set to 2000ms in our config). Prefer it over reading values synchronously; it
74
+ removes the need for `act`/manual `tick()` for in-component reactivity.
75
+ - `.click()`, `.fill()`, etc. are awaited interactions on a locator.
76
+ - **No `@testing-library`, no `act`.** `vitest-browser-svelte` intentionally omits `act`.
77
+
78
+ ## Events are props (Svelte 5)
79
+
80
+ There are no `component.$on` listeners in Svelte 5 — events are callback props (`onclick`,
81
+ `onchange`, and STUIC's own `ondismiss`, `onLogout`, etc.). Assert them by passing a spy:
82
+
83
+ ```ts
84
+ const onclick = vi.fn();
85
+ render(Button, { onclick, children: text("Save") });
86
+ await screen.getByRole("button").click();
87
+ expect(onclick).toHaveBeenCalledOnce();
88
+ ```
89
+
90
+ ## Snippet props (`children`, `header`, etc.)
91
+
92
+ Many STUIC components take snippet props (`children`, header/footer snippets). A `.svelte.test.ts`
93
+ file can't contain markup, so build snippets with `createRawSnippet`:
94
+
95
+ ```ts
96
+ import { createRawSnippet } from "svelte";
97
+
98
+ // tiny helper for static text children — keep it in a shared test util
99
+ const text = (s: string) => createRawSnippet(() => ({ render: () => `<span>${s}</span>` }));
100
+
101
+ render(Button, { children: text("Click me") });
102
+ ```
103
+
104
+ For complex snippet scenarios (a snippet that itself renders a STUIC component, takes args, etc.),
105
+ the escape hatch is a **fixture component**: a small `ComponentName.fixture.svelte` next to the test
106
+ that composes the real component with markup, then `render(Fixture, props)`. Prefer `createRawSnippet`
107
+ for the common case; reach for a fixture only when markup composition is the thing under test.
108
+
109
+ ## Two-way binding (`bind:value`, `bind:checked`)
110
+
111
+ To assert that user interaction updates bound state, render a fixture that binds the prop to local
112
+ state and exposes it, **or** assert the observable DOM proxy (e.g. the hidden `<input>`'s `checked`
113
+ for `Switch`, the `<input>`'s `value` for `FieldInput`). Default to asserting the DOM — it's what a
114
+ consumer observes — and use a fixture only when you must read the bound JS value directly.
115
+
116
+ ## What to assert (the high-yield checklist)
117
+
118
+ For each component, prefer these over snapshot dumps:
119
+
120
+ 1. **Prop → DOM contract** — `intent`/`variant`/`size` → `data-*` attribute or class; `href` → renders
121
+ `<a>` vs `<button>`; `disabled` → the `disabled` attribute / blocked clicks.
122
+ 2. **Events** — the right callback fires once, with the right argument, and `disabled` suppresses it.
123
+ 3. **Binding** — interaction updates the value/checked state.
124
+ 4. **A11y** — `role`, `aria-*`, label association, focus order where it's a contract.
125
+ 5. **Computed layout** (browser-only superpower) — width from a `progress` value, viewport clamping
126
+ of anchor-positioned elements, focus actually trapped. These are exactly what jsdom returns zeros
127
+ for, and what regressed in recent commits.
128
+
129
+ Avoid: asserting exact class strings (brittle — assert the `data-*` contract or a single meaningful
130
+ class), and snapshotting full HTML (self-closing-tag / class-order differences make them noisy).
131
+
132
+ ## Cleanup gotcha
133
+
134
+ `vitest-browser-svelte` cleans up **before** each test, not after — so the last rendered component
135
+ stays on screen for debugging. Import from `vitest-browser-svelte/pure` to opt out of auto-cleanup
136
+ when a test needs to manage it manually. Rarely needed.
137
+
138
+ ## Timing / flakiness
139
+
140
+ Rely on `expect.element` retries and awaited locator actions — **never fixed `sleep`s**. In-component
141
+ reactivity resolves through the retry loop; only assertions on *external* universal state living in a
142
+ `*.svelte.ts` module may need `flushSync()` from `svelte`.
143
+
144
+ ## Open questions / decisions needed
145
+
146
+ - **Shared test utilities** — agree on one home for the `text()` snippet helper and any future
147
+ fixtures (suggest `src/lib/test-utils/` or `src/test-helpers.ts`), so it's not redefined per file.
148
+ Decide when the second snippet-needing component lands.
@@ -0,0 +1,92 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Component inventory verified against src/lib/components at commit cc9958b.
5
+ Planning artifact; no code was changed.
6
+ -->
7
+
8
+ # Component Coverage Roadmap
9
+
10
+ > The library has **74 components** (≈105 `.svelte` files incl. sub-components). Roughly **26 are
11
+ > "easy"** (deterministic prop→DOM, no portals/traps), **23 "medium"** (actions, focus jumps, layout
12
+ > reads), **30 "hard/E2E-only"** (portals, focus traps, anchor positioning, drag, Milkdown). The plan:
13
+ > **one commit per component**, starting with the easy primitives that are used everywhere, then a
14
+ > single "hard proof" to validate that browser mode earns its keep (see [04](./04-hard-cases-and-e2e.md)).
15
+
16
+ ## Summary of recommendations
17
+
18
+ | # | Recommendation | Value | Effort | Risk |
19
+ |---|----------------|-------|--------|------|
20
+ | 1 | Cover the **easy tier** primitives first (Button, Pill, Switch, …), one commit each | high | S | low |
21
+ | 2 | Establish reusable assertion patterns on Button (the flagship), reuse everywhere | high | S | low |
22
+ | 3 | Do **one hard proof** early (focus trap or anchor positioning) to de-risk the approach | high | M | med |
23
+ | 4 | Cover the **medium tier** (form fields with actions) once patterns are settled | med | M | low |
24
+ | 5 | Defer the **hard 30** to a later sprint / standalone Playwright E2E layer | med | L | med |
25
+
26
+ ## Tier 1 — Easy, highest-value (the warm-up; one commit each)
27
+
28
+ Deterministic prop→DOM/aria/event surface; no portals, traps, or external geometry. Paths under
29
+ `src/lib/components/`.
30
+
31
+ | Order | Component | Path | What to assert |
32
+ |-------|-----------|------|----------------|
33
+ | 1 | **Button** | `Button/Button.svelte` | `intent`/`variant`/`size` → `data-*`; `disabled` attr + click suppressed; `href` → `<a>`; spinner shows/hides; `roleSwitch` toggle; `onclick` fires once. The flagship — sets every pattern. |
34
+ | 2 | **Pill** | `Pill/Pill.svelte` | intent/variant → `data-*`; `active` state; dismissible click → `ondismiss` (+ `stopPropagation`); `href` → link; disabled blocks click. |
35
+ | 3 | **Switch** | `Switch/Switch.svelte` | `checked` reflected in hidden input; click toggles; `disabled`; intent/size classes; on/off snippets. First real "reactivity in a browser" proof. |
36
+ | 4 | **Spinner** | `Spinner/Spinner.svelte` | size → dims; `count` → number of bars; thickness/direction/duration → style/CSS var. Pure CSS, no motion lib. |
37
+ | 5 | **Skeleton** | `Skeleton/Skeleton.svelte` | variant (text/circle/rect) markup; `lines` count; rounded class; animation off under `prefers-reduced-motion`; width/height inline style. |
38
+ | 6 | **DismissibleMessage** | `DismissibleMessage/DismissibleMessage.svelte` | message renders; `intent` → `data-intent`; dismiss button hidden when `onDismiss=false`; `ondismiss` fires; new message resets dismissed state. |
39
+ | 7 | **Avatar** | `Avatar/Avatar.svelte` | `src` → `<img>` + alt; initials extracted from name; icon fallback; error→fallback switch; `autoColor` deterministic; size class. |
40
+ | 8 | **Progress** | `Progress/Progress.svelte` | `type=bar` vs `circle` markup; value → bar **width** / circle stroke (genuine layout read — jsdom can't). |
41
+ | 9 | **Separator** | `Separator/Separator.svelte` | orientation → classes/role. Near-zero risk — good harness smoke test (see [01](./01-framework-setup.md) Step 5). |
42
+ | 10 | **H** | `H/H.svelte` | `level` → correct `h1`–`h6` tag; size override class. |
43
+ | 11 | **KbdShortcut** | `KbdShortcut/KbdShortcut.svelte` | key labels rendered; modifier order; cross-platform output. |
44
+ | 12 | **ButtonGroupRadio** | `ButtonGroupRadio/ButtonGroupRadio.svelte` | single selection; click changes selection; value binding. |
45
+ | 13 | **ListItemButton** | `ListItemButton/ListItemButton.svelte` | active class; disabled blocks click; `onclick`; icon/avatar slot. |
46
+ | 14 | **Card** | `Card/Card.svelte` | variant → `data-*`; header/footer/children snippets render. |
47
+ | 15 | **TabbedMenu** | `TabbedMenu/TabbedMenu.svelte` | tab click updates bound `activeTab`; active content renders; indicator moves. |
48
+ | 16 | **IconSwap** | `IconSwap/IconSwap.svelte` | `active` swaps icon; animation class applied. |
49
+ | 17 | **Collapsible** | `Collapsible/Collapsible.svelte` | `needsCollapse` true when `scrollHeight > clientHeight` (browser-only); expand removes line-clamp; toggle visible only when needed. |
50
+
51
+ > The first sprint targets **#1–#9 plus one hard proof**; #10–#17 are ranked backlog (still Tier 1,
52
+ > just diminishing marginal value). Adjust freely as patterns settle.
53
+
54
+ ## Tier 2 — Medium value, moderate complexity (next sprint)
55
+
56
+ Use actions (`trim`/`typeahead`/`validate`/`autogrow`/`tooltip`) and/or real focus/geometry, but stay
57
+ deterministic. Form fields are the bulk and share one harness pattern once `FieldInput` is solved.
58
+
59
+ `FieldInput`, `FieldTextarea`, `FieldCheckbox`, `FieldSelect`, `FieldRadios`, `FieldSwitch`,
60
+ `FieldKeyValues`, `FieldObject`, `FieldPhoneNumber`, `FieldLikeButton` · `OtpInput` (focus jumps,
61
+ paste) · `Nav` (expand/collapse) · `ImageCycler` · `AppShell`/`AppShellSimple` · `PricingTable` ·
62
+ `ThemePreview` · `AssetsPreview` · `SlidingPanels` · `Notifications` (timers; portal — borderline) ·
63
+ `TypeaheadInput` · `ColorScheme` (localStorage).
64
+
65
+ Pattern note: for the `validate` action, inject a stub validator (`(k) => k` style, as existing util
66
+ tests do) rather than exercising live validation — keeps these fast and deterministic.
67
+
68
+ ## Tier 3 — Hard / E2E-only (later; see [04](./04-hard-cases-and-e2e.md))
69
+
70
+ Portals & focus traps (`Modal`, `ModalDialog`, `Backdrop`, `Drawer`, `AlertConfirmPrompt`), anchor
71
+ positioning (`DropdownMenu`, `CommandMenu`, `UserAvatarMenu`), drag (`Tree`, `FieldOptions`,
72
+ `FieldFile`, `FieldAssets`), scroll/observer (`Carousel`, `DataTable`, `Book`), heavy 3rd-party
73
+ (`MarkdownEditor`/Milkdown, `CronInput`), and the composite flows (`Checkout/*`, `LoginForm`,
74
+ `RegisterForm`, `EmailVerifyForm`, `LoginOrRegisterForm`, `FieldInputLocalized`).
75
+
76
+ Several of these *can* be done in browser mode (focus traps, anchor positioning, observers all work
77
+ in a real Chromium); only drag-heavy and Milkdown components genuinely want standalone Playwright
78
+ E2E. [04](./04-hard-cases-and-e2e.md) draws that line.
79
+
80
+ ## Cadence
81
+
82
+ - **One component per commit**, message like `test(Button): browser-mode coverage`.
83
+ - Each commit: write `ComponentName.svelte.test.ts`, run `pnpm test` (both projects green), tick the
84
+ row in [`PROGRESS.md`](./PROGRESS.md), commit.
85
+ - Don't chase coverage %. Stop at the behavior contracts in [02](./02-test-conventions.md)'s checklist.
86
+
87
+ ## Open questions / decisions needed
88
+
89
+ - **Depth per component** — assert the headline contracts (recommended) vs exhaustive prop-matrix.
90
+ Default: headline contracts; expand only where a real regression happened.
91
+ - **Where to stop Tier 1 before pausing for the hard proof** — plan assumes after #9; confirm or
92
+ extend.
@@ -0,0 +1,97 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against src/lib at commit cc9958b. Planning artifact; no code was changed.
5
+ -->
6
+
7
+ # Hard Cases & E2E Strategy
8
+
9
+ > ~30 of the 74 components are "hard". But "hard" splits two ways: **most are hard for *jsdom* yet
10
+ > perfectly testable in Vitest browser mode** (focus traps, anchor positioning, ResizeObserver — all
11
+ > work in a real Chromium); only **drag-heavy and Milkdown-class** components genuinely need a
12
+ > separate Playwright **E2E** layer against a running app. The single most valuable near-term action
13
+ > is **one "hard proof"** test now — pick the thing that recently regressed — to prove browser mode
14
+ > earns its setup cost before we invest in the easy tier en masse.
15
+
16
+ ## The two kinds of "hard"
17
+
18
+ | Kind | Examples | Where it's tested |
19
+ |------|----------|-------------------|
20
+ | **Hard for jsdom, fine in browser mode** | focus traps, anchor/viewport positioning, scroll/observer tracking, transitions, computed layout | **Vitest browser mode** (this plan) — a real Chromium page gives real `getBoundingClientRect`, focus, layout |
21
+ | **Hard even in browser mode** | drag-and-drop reorder, file drop, full WYSIWYG editors, multi-step composite flows | **Standalone Playwright E2E** against `pnpm dev`/`preview` (deferred to a later sprint) |
22
+
23
+ This matters because the naive read ("30 components are E2E-only") is wrong — it would defer exactly
24
+ the high-value behaviors (focus, positioning) that motivated this whole effort. Most of the 30 belong
25
+ in browser mode; only a handful are true E2E.
26
+
27
+ ## The "hard proof" — do this once, early
28
+
29
+ Per the agreed scope (easy warm-up → one hard proof), after the first easy components land, write
30
+ **one** test that is impossible in jsdom and protects a real, recent regression. Two strong
31
+ candidates, both verified to exist:
32
+
33
+ ### Candidate A (recommended) — anchor-position viewport clamping
34
+
35
+ - **Code:** [`src/lib/utils/anchor-position.ts`](../../src/lib/utils/anchor-position.ts), consumed by
36
+ `DropdownMenu/DropdownMenu.svelte` (and others).
37
+ - **Why:** this is precisely what regressed in `9d8c974` *"clamp anchor-positioned annotations to
38
+ viewport on all paths"* and `8c52afe`. A test here has immediate, proven value and prevents
39
+ recurrence. It's also the textbook "jsdom returns all-zeros from `getBoundingClientRect`" case — so
40
+ it can only exist in browser mode.
41
+ - **Shape:** render a host + an anchored element positioned near a viewport edge in a real page;
42
+ assert the element's rect stays clamped inside `window.innerWidth/innerHeight`.
43
+ - **Bonus:** the *pure* clamp math in `anchor-position.ts` can additionally get a fast **node** test
44
+ (`anchor-position.test.ts`) — cheap regression net independent of the browser.
45
+
46
+ ### Candidate B (alternative) — focus trap
47
+
48
+ - **Code:** [`src/lib/actions/focus-trap.ts`](../../src/lib/actions/focus-trap.ts), used by
49
+ `ModalDialog`, `Backdrop`, `Drawer`.
50
+ - **Why:** Tab/Shift-Tab cycling and `returnFocus`-on-teardown are core a11y contracts that node
51
+ cannot exercise at all.
52
+ - **Shape:** render a container with the action + 3 focusables; assert Tab from the last wraps to the
53
+ first, Shift-Tab from the first wraps to the last, and focus returns to the opener on destroy.
54
+
55
+ > **Open question (resolve at this task):** A or B as the single hard proof. Recommendation: **A** —
56
+ > it guards a regression that already happened twice. Doing both is fine but only one is required to
57
+ > de-risk the approach.
58
+
59
+ ## Portals & focus traps (browser-mode, later sprint)
60
+
61
+ `Modal`, `ModalDialog`, `Backdrop`, `Drawer`, `AlertConfirmPrompt` — all use
62
+ [`focus-trap.ts`](../../src/lib/actions/focus-trap.ts), scroll-lock, and an Escape-key stack. These
63
+ are testable in browser mode (open → focus trapped → Escape closes → backdrop click closes →
64
+ `returnFocus`). The stack/queue *logic* is already unit-tested (`AlertConfirmPromptStack`,
65
+ `NotificationsStack`); browser tests add the DOM-interaction layer. Higher effort, real value —
66
+ schedule after Tier 1/2.
67
+
68
+ ## Anchor positioning (browser-mode, later sprint)
69
+
70
+ `DropdownMenu`, `CommandMenu`, `UserAvatarMenu` — positioning + keyboard nav + click-outside. Test
71
+ positioning and keyboard nav in browser mode; the search/filter logic should be extracted to
72
+ `_internal/*.ts` and unit-tested in node where practical.
73
+
74
+ ## True E2E candidates (standalone Playwright, deferred)
75
+
76
+ These want a running app, not isolated component mounts:
77
+
78
+ - **Drag & file drop:** `Tree` (reorder + auto-expand + async `onMove`), `FieldOptions`, `FieldFile`,
79
+ `FieldAssets`. Extract and node-test the pure logic (`calcDropPosition`, `isDescendantOf` per
80
+ `docs/testing.md`); E2E the gesture.
81
+ - **Heavy editors:** `MarkdownEditor` (Milkdown + CodeMirror) — E2E only; mock/skip at component
82
+ level. `CronInput` — node-test the cron logic, E2E the builder UI.
83
+ - **Composite flows:** `Checkout/*`, `LoginForm`/`RegisterForm`/`EmailVerifyForm`/
84
+ `LoginOrRegisterForm`, `FieldInputLocalized` — multi-step, cross-component; E2E the flow, browser-
85
+ test the leaf fields individually.
86
+
87
+ A standalone Playwright E2E project (its own `playwright.config.ts` against `pnpm preview`) is a
88
+ **separate later initiative** — explicitly out of scope for the first sprint. Visual-regression
89
+ (Vitest 4 ships `toMatchScreenshot`) is likewise deferred.
90
+
91
+ ## Open questions / decisions needed
92
+
93
+ - **Hard proof A vs B** (above) — recommend A.
94
+ - **When to start the standalone Playwright E2E layer** — not in sprint 1; revisit after Tier 2.
95
+ - **Logic-extraction appetite** — several hard components get much cheaper if pure logic moves to
96
+ `_internal/*.ts` (already endorsed by `docs/testing.md`). Decide per-component whether to extract
97
+ before testing.
@@ -0,0 +1,88 @@
1
+ <!--
2
+ GENERATED ANALYSIS — @marianmeres/stuic real-browser component testing
3
+ Produced 2026-06-08 by multi-agent research → adversarial verify → synthesize.
4
+ Claims verified against the repo at commit cc9958b. Planning artifact; no code was changed.
5
+ -->
6
+
7
+ # CI — GitHub Actions
8
+
9
+ > The repo is on GitHub (`github.com/marianmeres/stuic`) and publishes to npm, so CI pays off: it
10
+ > runs the tests automatically on every push/PR in a clean machine, catching regressions **before**
11
+ > a broken release reaches npm. The plan: **one ~30-line workflow** that installs Chromium and runs
12
+ > `pnpm test`. It's fully decoupled from the tests themselves (those run identically via `pnpm test`
13
+ > locally) and is scheduled **late in the first sprint**, once a few component tests pass locally, so
14
+ > the first CI run verifies something real.
15
+
16
+ ## What CI is, briefly
17
+
18
+ A robot that runs your checks on every push. **GitHub Actions** is GitHub's built-in version: you
19
+ commit a YAML file at `.github/workflows/test.yml` describing *when* to run (push / pull_request) and
20
+ *what steps* to run on a fresh Linux VM. GitHub then shows a green ✓ / red ✗ on each commit and PR.
21
+ Free for this repo's usage.
22
+
23
+ ## The one wrinkle vs a normal Node test job
24
+
25
+ Browser tests need a real Chromium binary on the CI machine, so the workflow adds one step —
26
+ `pnpm exec playwright install --with-deps chromium` — before `pnpm test`. (`--with-deps` also
27
+ installs the OS libraries Chromium needs on the Ubuntu runner.) Everything else is a standard
28
+ pnpm + Node setup.
29
+
30
+ ## The workflow (to create as the sprint's CI task)
31
+
32
+ ```yaml
33
+ # .github/workflows/test.yml
34
+ name: test
35
+
36
+ on:
37
+ push:
38
+ branches: [master]
39
+ pull_request:
40
+
41
+ jobs:
42
+ test:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+
47
+ - uses: pnpm/action-setup@v4
48
+
49
+ - uses: actions/setup-node@v4
50
+ with:
51
+ node-version: 22
52
+ cache: pnpm
53
+
54
+ - run: pnpm install --frozen-lockfile
55
+
56
+ - name: Install Chromium for browser tests
57
+ run: pnpm exec playwright install --with-deps chromium
58
+
59
+ - name: Run tests (node + browser projects)
60
+ run: pnpm test # = "vitest run", runs both projects headless
61
+ ```
62
+
63
+ Notes:
64
+
65
+ - `pnpm test` must be the one-shot `vitest run` (see [01](./01-framework-setup.md) Step 4), not watch
66
+ mode, or CI hangs.
67
+ - `browser.headless: true` is already set in the config, so no display server is needed.
68
+ - Pin `node-version` to whatever you develop on (22 shown). `pnpm/action-setup@v4` reads the pnpm
69
+ version from `package.json`'s `packageManager` field if present — the repo currently has none, so
70
+ either add one (e.g. `"packageManager": "pnpm@9.x"`) or pin `version:` in the action. **Decide this
71
+ when creating the workflow.**
72
+
73
+ ## Alternative considered (not chosen)
74
+
75
+ Running the job inside the official Playwright Docker image
76
+ (`container: mcr.microsoft.com/playwright:v1.xx-jammy`) ships browsers pre-installed and is ~30%
77
+ faster per published benchmarks. For a library this size the absolute difference is seconds; the
78
+ plain workflow above is more readable, so it's the chosen default. The Docker route stays available
79
+ if CI time ever matters.
80
+
81
+ ## Open questions / decisions needed
82
+
83
+ - **`packageManager` field vs pinned pnpm version in the action** — pick one so `pnpm/action-setup`
84
+ is deterministic.
85
+ - **Node version** to pin (suggest 22).
86
+ - **Add `pnpm check` / `pnpm lint` to the same workflow?** — cheap to fold in (the repo already has
87
+ both scripts) and would make CI a full quality gate, but it widens scope beyond testing. Recommend
88
+ yes, as a second job, but only after the test job is green.
@@ -0,0 +1,63 @@
1
+ # Implementation Progress — Component Testing
2
+
3
+ Living tracker for acting on [`00-overview-and-roadmap.md`](./00-overview-and-roadmap.md).
4
+ A fresh conversation should read this file first, then the relevant `NN-*.md` section.
5
+
6
+ **Status legend:** ⬜ not started · 🚧 in progress · ⏸️ blocked/awaiting decision · ✅ done · ⏭️ deferred
7
+
8
+ > Convention: branch `feat/component-testing`; **one commit per task**. Each task resolves its source
9
+ > doc's "Open questions" first (record in the Decisions log), then implement → `pnpm test` green →
10
+ > tick here → commit.
11
+
12
+ ## First sprint (harness + easy tier + one hard proof + CI)
13
+
14
+ Branch: `feat/component-testing`
15
+
16
+ | # | Task | Source | Status | Commit |
17
+ |---|------|--------|--------|--------|
18
+ | 1 | Upgrade vitest 3→4; confirm 9 existing node suites still green | [01](./01-framework-setup.md) Step 1 | ✅ | `71e47e2` |
19
+ | 2 | Browser harness: add deps, `projects` config split, `playwright install chromium`, fix test scripts, Separator smoke test | [01](./01-framework-setup.md) Steps 2–5 | ✅ | `980b323` |
20
+ | 3 | Reconcile [`docs/testing.md`](../testing.md) — add the browser-behavior layer | [02](./02-test-conventions.md) | ✅ | _next_ |
21
+ | 4 | **Button** — flagship; establish assertion patterns | [03](./03-component-coverage-roadmap.md) #1 | ⬜ | — |
22
+ | 5 | **Pill** — intent/active/dismissible event | [03](./03-component-coverage-roadmap.md) #2 | ⬜ | — |
23
+ | 6 | **Switch** — checked binding, toggle, disabled | [03](./03-component-coverage-roadmap.md) #3 | ⬜ | — |
24
+ | 7 | **Spinner** — size/count/direction | [03](./03-component-coverage-roadmap.md) #4 | ⬜ | — |
25
+ | 8 | **Skeleton** — variants, reduced-motion | [03](./03-component-coverage-roadmap.md) #5 | ⬜ | — |
26
+ | 9 | **DismissibleMessage** — intent, dismiss, auto-reset | [03](./03-component-coverage-roadmap.md) #6 | ⬜ | — |
27
+ | 10 | **Avatar** — initials/img/icon fallback, autoColor | [03](./03-component-coverage-roadmap.md) #7 | ⬜ | — |
28
+ | 11 | **Progress** — value→width/stroke (real layout) | [03](./03-component-coverage-roadmap.md) #8 | ⬜ | — |
29
+ | 12 | **Hard proof** — anchor-position viewport clamp (rec.) *or* focus trap | [04](./04-hard-cases-and-e2e.md) | ⏸️ | — |
30
+ | 13 | CI — minimal GitHub Actions `test.yml` | [05](./05-ci.md) | ⬜ | — |
31
+
32
+ ## Backlog (ranked, post-sprint)
33
+
34
+ | Rank | Task | Source | Status |
35
+ |------|------|--------|--------|
36
+ | 14 | Rest of Tier 1 (Separator already smoke-tested · H, KbdShortcut, ButtonGroupRadio, ListItemButton, Card, TabbedMenu, IconSwap, Collapsible) | [03](./03-component-coverage-roadmap.md) #10–17 | ⬜ |
37
+ | 15 | Tier 2 — `FieldInput` first, then the Field* family + OtpInput, Nav, etc. | [03](./03-component-coverage-roadmap.md) | ⬜ |
38
+ | 16 | Portals/focus-traps in browser mode (Modal, ModalDialog, Backdrop, Drawer, AlertConfirmPrompt) | [04](./04-hard-cases-and-e2e.md) | ⬜ |
39
+ | 17 | Anchor-positioned menus (DropdownMenu, CommandMenu, UserAvatarMenu) + extract search logic to `_internal` | [04](./04-hard-cases-and-e2e.md) | ⬜ |
40
+ | 18 | Standalone Playwright E2E layer (drag: Tree/FieldOptions/FieldFile; Milkdown; Checkout/auth flows) | [04](./04-hard-cases-and-e2e.md) | ⏭️ |
41
+ | 19 | Add `pnpm check` + `pnpm lint` as a second CI job | [05](./05-ci.md) | ⬜ |
42
+ | 20 | (Maybe) visual-regression via `toMatchScreenshot`; multi-browser matrix | [00](./00-overview-and-roadmap.md) | ⏭️ |
43
+
44
+ ## Decisions log
45
+
46
+ - **2026-06-08** — Adopt **Vitest 4 Browser Mode + `vitest-browser-svelte` + `@vitest/browser-playwright` (Chromium)** — verified the right default for a component library whose value is DOM/layout/focus behavior the current node/server-build setup can't test.
47
+ - **2026-06-08** — **Take the vitest 3→4 major upgrade now** (gating prerequisite) — `vitest-browser-svelte@^2` peer-requires `vitest ^4`; the vitest-3-compatible `0.1.0` is a dead-end.
48
+ - **2026-06-08** — **Scope:** easy warm-up (Button/Pill/Switch/…) then **one** hard proof — not full coverage up front.
49
+ - **2026-06-08** — **Browsers: Chromium only** — leanest CI; add Firefox/WebKit only if engine-specific behavior demands it.
50
+ - **2026-06-08** — **CI: yes, minimal GitHub Actions**, added late in the sprint once a few component tests pass locally.
51
+ - **2026-06-08** — **Config lives in `vite.config.ts`** (extend with a `test` block) rather than a separate `vitest.config.ts` — minimal change.
52
+ - **2026-06-08** — Task 1 done: **vitest 4.1.8** installed (vite already `^7.3.5`, compatible); all 9 node suites / 59 tests pass unchanged. `--dir src/` still supported in v4.
53
+ - **2026-06-08** — Task 2 done: harness works. Deps: `@vitest/browser-playwright 4.1.8`, `playwright 1.60.0`, `vitest-browser-svelte 2.1.1`. `projects` split added to `vite.config.ts`; scripts now `test` = `vitest run` (+ `test:watch`, `test:ui`). Separator smoke test (3 assertions) passes in Chromium → **10 files / 62 tests green**, `pnpm check` clean (0 errors). **The documented SvelteKit-plugin/server-build blocker is resolved** — browser mode resolves the client build, `toHaveClass("stuic-separator")` confirms tailwind + the client runtime run. No `client`-project plugin fallback needed.
54
+ - **⏸️ Open (task 12):** which single hard proof — anchor-position clamp (recommended, guards the `9d8c974` regression) vs focus trap.
55
+ - **⏸️ Open (task 13):** `packageManager` field vs pinned pnpm version in the CI action; Node version to pin.
56
+
57
+ ## How to resume (for a fresh conversation)
58
+
59
+ 1. Read this file + [`00-overview-and-roadmap.md`](./00-overview-and-roadmap.md).
60
+ 2. Pick the next ⬜ task; open its source doc section for the verified detail.
61
+ 3. Resolve that task's "Open questions" with the owner; record in the Decisions log.
62
+ 4. On `feat/component-testing`: implement → `pnpm test` (both projects green) → update this file →
63
+ commit when the owner asks (one commit per task).