From f1c9d3538543b0d8e6cdac72ab0504ddb908f6ae Mon Sep 17 00:00:00 2001 From: Claas Date: Thu, 6 Aug 2026 09:24:26 +0200 Subject: [PATCH] Test the UI in real browsers without a native build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend is an ordinary web page, so it can be driven in a real browser with the IPC boundary mocked — no Tauri build, no app window, and no system permissions. That was the gap: the only way to look at this before was screen recording, which needs a permission that cannot be granted from a terminal, and poking at a console by hand, which HMR wiped on every reload. Vitest covers components and stores, Playwright covers flows across WebKit and Chromium. WebKit is the one that earns its place: it is what WKWebView on macOS actually is, so it is the only way to find out whether something works in the shipped app rather than only in Chrome. It found a real bug within minutes. WebKit leaves links out of the tab order unless the system's keyboard navigation setting is on, and that is off by default — so the skip link, whose entire job is to be the first thing a keyboard reaches, was unreachable in the shipped app. Fixed one branch down; the assertion that catches it lives here. Also verified here for the first time: infinite scrolling. Loading more on scroll depends on IntersectionObserver, which does not run in a hidden document, so it could not be checked in the tooling available before. Some deliberate choices: - No screenshot baselines. They differ by operating system and font rendering, so they fail for reasons unrelated to the change. The appearance tests assert computed colours instead, which is the actual claim being made — that light-dark() resolves per scheme with no JavaScript. - Fixtures are typed as the real GraphRow, so a change to the Rust types stops the tests compiling rather than letting them drift. - One file knows about window.__TAURI_INTERNALS__, so an upgrade that moves it breaks in one place. Two lint rules needed configuring for tests specifically: a mock has to keep the signature of what it imitates, and commands reject with a plain { kind, message }, so a mock throwing an Error would misrepresent what the frontend has to handle. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 +- .oxlintrc.json | 20 + CLAUDE.md | 24 + package.json | 12 +- playwright.config.ts | 35 ++ pnpm-lock.yaml | 475 +++++++++++++++++- pnpm-workspace.yaml | 8 + tests/e2e/accessibility.spec.ts | 153 ++++++ .../history-dark-chromium-darwin.png | Bin 0 -> 24131 bytes .../history-light-chromium-darwin.png | Bin 0 -> 23953 bytes tests/e2e/history.spec.ts | 111 ++++ tests/e2e/tauri.ts | 124 +++++ tests/fixtures/rows.ts | 95 ++++ tests/unit/format.test.ts | 53 ++ tests/unit/graph-lanes.test.tsx | 137 +++++ tests/unit/graph-view.test.ts | 180 +++++++ tests/unit/history.test.tsx | 192 +++++++ vitest.config.ts | 22 + 18 files changed, 1633 insertions(+), 14 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/accessibility.spec.ts create mode 100644 tests/e2e/accessibility.spec.ts-snapshots/history-dark-chromium-darwin.png create mode 100644 tests/e2e/accessibility.spec.ts-snapshots/history-light-chromium-darwin.png create mode 100644 tests/e2e/history.spec.ts create mode 100644 tests/e2e/tauri.ts create mode 100644 tests/fixtures/rows.ts create mode 100644 tests/unit/format.test.ts create mode 100644 tests/unit/graph-lanes.test.tsx create mode 100644 tests/unit/graph-view.test.ts create mode 100644 tests/unit/history.test.tsx create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index 44913fb..cb27152 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ node_modules dist # Cargo builds into the workspace root now that src-tauri is a member -/target \ No newline at end of file +/target +# Playwright +/test-results +/playwright-report +/blob-report diff --git a/.oxlintrc.json b/.oxlintrc.json index 6f824ea..69477d0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,5 +24,25 @@ } } }, + "overrides": [ + { + // A mock's job is to imitate something it does not control, which means + // matching shapes that would be poor style if we had written them. + "files": ["tests/**"], + "rules": { + // Tauri names the bridge; the test recorder mirrors that convention so + // the two read as a pair. + "eslint/no-underscore-dangle": ["error", { "allow": ["__TAURI_INTERNALS__", "__calls"] }], + // A stand-in has to keep the real signature. Dropping `async` because + // this particular fake happens not to await would change its type. + "eslint/require-await": "off", + // Commands reject with a plain { kind, message }, so a mock throwing an + // Error would be lying about what the frontend has to handle. + "eslint/no-throw-literal": "off", + // `() => undefined` is the real shape of an optional accessor. + "unicorn/no-useless-undefined": "off" + } + } + ], "ignorePatterns": ["dist", "src-tauri/target"] } diff --git a/CLAUDE.md b/CLAUDE.md index 2a81d7b..a0ff47f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,26 @@ Specifically: the commit graph is implemented from the algorithm _described_ in implementation, [gitamine](https://github.com/pvigier/gitamine), is **GPL-3.0** — do not read or port its source. +## Testing the UI + +The frontend is an ordinary web page, so it is tested in real browsers with the IPC +boundary mocked — no native build, no app window, and no system permissions. Rendering, +keyboard behaviour and accessibility are all checked this way rather than by looking at +screenshots. + +- `tests/unit` — Vitest with `@solidjs/testing-library`, for components and stores. +- `tests/e2e` — Playwright against **WebKit and Chromium**. WebKit is the important one: + it is what WKWebView on macOS actually is, so it is the only way to find out whether + something works in the shipped app. It has already caught two behaviours Chromium does + not have. +- `tests/e2e/tauri.ts` is the only file that knows about `window.__TAURI_INTERNALS__`, so + a Tauri upgrade that moves it breaks in one place. +- `tests/fixtures/rows.ts` builds graph rows typed as the real `GraphRow`, so a change to + the Rust types stops the tests compiling instead of letting them drift. + +No screenshot baselines: they differ by operating system and font rendering, so they fail +for reasons unrelated to the change. Assert computed styles and the accessibility tree. + ## Commands ``` @@ -65,10 +85,14 @@ pnpm typecheck # tsc --noEmit pnpm lint # oxlint pnpm format # oxfmt . pnpm format:check # oxfmt --check . +pnpm test # vitest run +pnpm test:e2e # playwright test (webkit + chromium) cargo test --workspace cargo clippy --workspace ``` +Playwright needs its browsers once: `pnpm exec playwright install webkit chromium`. + ## Workflow Work ships as stacked PRs using the `gh-stack` skill — small, reviewable layers where diff --git a/package.json b/package.json index f750876..34b1df9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,11 @@ "typecheck": "tsc --noEmit", "lint": "oxlint", "format": "oxfmt .", - "format:check": "oxfmt --check ." + "format:check": "oxfmt --check .", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@solidjs/signals": "2.0.0-beta.31", @@ -24,13 +28,17 @@ }, "devDependencies": { "@claas.dev/material-tailwind": "^0.6.0", + "@playwright/test": "^1.62.1", + "@solidjs/testing-library": "1.0.0-beta.2", "@tailwindcss/vite": "^4.3.3", "@tauri-apps/cli": "^2", + "happy-dom": "^20.11.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "tailwindcss": "^4.3.3", "typescript": "~7.0.2", "vite": "^6.0.3", - "vite-plugin-solid": "3.0.0-next.22" + "vite-plugin-solid": "3.0.0-next.22", + "vitest": "^4.1.10" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2e6f9a6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * End-to-end tests against the real frontend in a real browser. + * + * WebKit is the one that matters: it is what WKWebView on macOS actually is, + * so it is the only way to find out whether something works in the shipped app + * rather than only in Chromium. Chromium stands in for WebView2 on Windows. + * Neither needs Tauri running — the IPC boundary is mocked, which is also what + * makes these runnable in CI with no native toolchain. + */ +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + + use: { + baseURL: "http://localhost:1420", + trace: "on-first-retry", + }, + + projects: [ + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], + + webServer: { + command: "pnpm dev", + url: "http://localhost:1420", + reuseExistingServer: !process.env.CI, + stdout: "ignore", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 216e5fb..4a4a73d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,12 +27,21 @@ importers: '@claas.dev/material-tailwind': specifier: ^0.6.0 version: 0.6.0 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@solidjs/testing-library': + specifier: 1.0.0-beta.2 + version: 1.0.0-beta.2(@solidjs/web@2.0.0-beta.31(solid-js@2.0.0-beta.31))(solid-js@2.0.0-beta.31) '@tailwindcss/vite': specifier: ^4.3.3 - version: 4.3.3(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) + version: 4.3.3(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)) '@tauri-apps/cli': specifier: ^2 version: 2.11.4 + happy-dom: + specifier: ^20.11.1 + version: 20.11.1 oxfmt: specifier: ^0.62.0 version: 0.62.0 @@ -47,10 +56,13 @@ importers: version: 7.0.2 vite: specifier: ^6.0.3 - version: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + version: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0) vite-plugin-solid: specifier: 3.0.0-next.22 - version: 3.0.0-next.22(@solidjs/web@2.0.0-beta.31(solid-js@2.0.0-beta.31))(solid-js@2.0.0-beta.31)(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) + version: 3.0.0-next.22(@solidjs/web@2.0.0-beta.31(solid-js@2.0.0-beta.31))(solid-js@2.0.0-beta.31)(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.2)(happy-dom@20.11.1)(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)) packages: @@ -127,6 +139,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -623,6 +639,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@rollup/rollup-android-arm-eabi@4.62.4': resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -764,11 +785,21 @@ packages: '@solidjs/signals@2.0.0-beta.31': resolution: {integrity: sha512-bP4nUpN7N1axAoN31bGCPOGJKE+wMKtXp7MU9qKxa6Nv2Dq6qBhowcpfcnUX8k9f6OQsvo73t97naIhg3thSOA==} + '@solidjs/testing-library@1.0.0-beta.2': + resolution: {integrity: sha512-TLhQ5IUT/fdDfqa4X2rkQWB28Y+zEwi6mK/TVTeiQlEHG63eK2jfgwNYf2NtQoPh2c3ihLilsCzxABiSTP3JoQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/web': '>=2.0.0' + solid-js: '>=2.0.0' + '@solidjs/web@2.0.0-beta.31': resolution: {integrity: sha512-8ILyKQlByNORS7B0pSmQAmrlazi0/W6xKUGAYZFOu1HrzCt+HHJ8dTEzIFHq6T+dYCE8Q9duKESCOER1xm51OA==} peerDependencies: solid-js: ^2.0.0-beta.31 + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -945,9 +976,16 @@ packages: '@tauri-apps/plugin-opener@2.5.4': resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -960,9 +998,24 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1083,6 +1136,50 @@ packages: cpu: [x64] os: [win32] + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + babel-preset-solid@2.0.0-beta.30: resolution: {integrity: sha512-dC3aD70o8lqlN7DpWiOlxTnmRelSI9cLS9/vhTMpCioLUzQL+vHXUSiweNKNc1VDFpKb1zpuCqmUr/mxoWRPRg==} peerDependencies: @@ -1102,9 +1199,17 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1120,10 +1225,17 @@ packages: supports-color: optional: true + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + electron-to-chromium@1.5.400: resolution: {integrity: sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==} @@ -1135,6 +1247,13 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1144,6 +1263,13 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1153,6 +1279,11 @@ packages: picomatch: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1165,6 +1296,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@20.11.1: + resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + engines: {node: '>=20.0.0'} + html-entities@2.3.3: resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} @@ -1266,6 +1401,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1285,6 +1424,10 @@ packages: resolution: {integrity: sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==} engines: {node: '>=18'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + oxfmt@0.62.0: resolution: {integrity: sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1314,6 +1457,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1321,10 +1467,27 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + rollup@4.62.4: resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1344,6 +1507,9 @@ packages: resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} engines: {node: '>=10'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + solid-js@2.0.0-beta.31: resolution: {integrity: sha512-IsDOrIMkrEdqVV0jBCQ1yrVZriJ174krjUZtMH7MMJ73jEYXuQU+GwUG8nnti+AnU0mipBKViMIktXqk06QLuQ==} @@ -1351,6 +1517,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1358,6 +1530,13 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -1366,6 +1545,10 @@ packages: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1374,6 +1557,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1442,6 +1628,68 @@ packages: vite: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.2: + resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1540,6 +1788,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1847,6 +2097,10 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.77.0': optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true @@ -1924,12 +2178,20 @@ snapshots: '@solidjs/signals@2.0.0-beta.31': {} + '@solidjs/testing-library@1.0.0-beta.2(@solidjs/web@2.0.0-beta.31(solid-js@2.0.0-beta.31))(solid-js@2.0.0-beta.31)': + dependencies: + '@solidjs/web': 2.0.0-beta.31(solid-js@2.0.0-beta.31) + '@testing-library/dom': 10.4.1 + solid-js: 2.0.0-beta.31 + '@solidjs/web@2.0.0-beta.31(solid-js@2.0.0-beta.31)': dependencies: seroval: 1.5.6 seroval-plugins: 1.5.6(seroval@1.5.6) solid-js: 2.0.0-beta.31 + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -1991,12 +2253,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))': + '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0) '@tauri-apps/api@2.11.1': {} @@ -2051,11 +2313,24 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.8 @@ -2077,8 +2352,25 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.1.2 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -2139,6 +2431,57 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + babel-preset-solid@2.0.0-beta.30(@babel/core@7.29.7)(solid-js@2.0.0-beta.31): dependencies: '@babel/core': 7.29.7 @@ -2156,8 +2499,14 @@ snapshots: node-releases: 2.0.52 update-browserslist-db: 1.2.3(browserslist@4.28.7) + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.1.2 + caniuse-lite@1.0.30001806: {} + chai@6.2.2: {} + convert-source-map@2.0.0: {} csstype@3.2.3: {} @@ -2166,8 +2515,12 @@ snapshots: dependencies: ms: 2.1.3 + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + electron-to-chromium@1.5.400: {} enhanced-resolve@5.24.5: @@ -2177,6 +2530,10 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + + es-module-lexer@2.3.1: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2208,10 +2565,19 @@ snapshots: escalade@3.2.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2219,6 +2585,19 @@ snapshots: graceful-fs@4.2.11: {} + happy-dom@20.11.1: + dependencies: + '@types/node': 26.1.2 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + html-entities@2.3.3: {} is-what@4.1.16: {} @@ -2284,6 +2663,8 @@ snapshots: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2298,6 +2679,8 @@ snapshots: node-releases@2.0.52: {} + obug@2.1.4: {} + oxfmt@0.62.0: dependencies: tinypool: 2.1.0 @@ -2348,16 +2731,34 @@ snapshots: dependencies: entities: 6.0.1 + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.25: dependencies: nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + react-is@17.0.2: {} + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 @@ -2398,6 +2799,8 @@ snapshots: seroval@1.5.6: {} + siginfo@2.0.0: {} + solid-js@2.0.0-beta.31: dependencies: '@solidjs/signals': 2.0.0-beta.31 @@ -2407,10 +2810,18 @@ snapshots: source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -2418,6 +2829,8 @@ snapshots: tinypool@2.1.0: {} + tinyrainbow@3.1.1: {} + tslib@2.8.1: optional: true @@ -2444,6 +2857,8 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@8.3.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: browserslist: 4.28.7 @@ -2452,7 +2867,7 @@ snapshots: validate-html-nesting@1.2.4: {} - vite-plugin-solid@3.0.0-next.22(@solidjs/web@2.0.0-beta.31(solid-js@2.0.0-beta.31))(solid-js@2.0.0-beta.31)(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)): + vite-plugin-solid@3.0.0-next.22(@solidjs/web@2.0.0-beta.31(solid-js@2.0.0-beta.31))(solid-js@2.0.0-beta.31)(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)): dependencies: '@ampproject/remapping': 2.3.0 '@babel/core': 7.29.7 @@ -2462,12 +2877,12 @@ snapshots: babel-preset-solid: 2.0.0-beta.30(@babel/core@7.29.7)(solid-js@2.0.0-beta.31) merge-anything: 5.1.7 solid-js: 2.0.0-beta.31 - vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) - vitefu: 1.1.3(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0) + vitefu: 1.1.3(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)) transitivePeerDependencies: - supports-color - vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0): + vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.5) @@ -2476,12 +2891,50 @@ snapshots: rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.1.2 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - vitefu@1.1.3(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)): + vitefu@1.1.3(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)): optionalDependencies: - vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0) + + vitest@4.1.10(@types/node@26.1.2)(happy-dom@20.11.1)(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + happy-dom: 20.11.1 + transitivePeerDependencies: + - msw + + whatwg-mimetype@3.0.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.2: {} yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 71d2fcf..d32b534 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,13 @@ allowBuilds: esbuild: true + +# Solid 2 is a prerelease, and semver says a prerelease does not satisfy a +# plain ">=2.0.0" range. The packages below do work with it; this only stops +# pnpm reporting a conflict that is an artefact of the versioning scheme. +peerDependencyRules: + allowedVersions: + "@solidjs/testing-library>solid-js": "2.0.0-beta.31" + "@solidjs/testing-library>@solidjs/web": "2.0.0-beta.31" minimumReleaseAgeExclude: - "@solidjs/signals@2.0.0-beta.31" - "@solidjs/web@2.0.0-beta.31" diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts new file mode 100644 index 0000000..4aa23e1 --- /dev/null +++ b/tests/e2e/accessibility.spec.ts @@ -0,0 +1,153 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { branchedHistory, linearHistory } from "../fixtures/rows"; +import { commitRows, mockTauri, openRepository } from "./tauri"; + +test.describe("structure", () => { + test("puts every region behind a landmark", async ({ page }) => { + await mockTauri(page, { rows: linearHistory(20) }); + await page.goto("/"); + + await expect(page.getByRole("banner")).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Repository" })).toBeVisible(); + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.getByRole("contentinfo")).toBeVisible(); + }); + + test("keeps the history a list in every engine", async ({ page }) => { + // WebKit drops list semantics when a list is styled with `list-style: none`, + // which Tailwind's preflight applies — and WebKit is what the app actually + // runs in on macOS. This is the test that would have caught it. + await mockTauri(page, { rows: linearHistory(20) }); + await page.goto("/"); + await openRepository(page); + + await expect(page.getByRole("main").getByRole("list")).toHaveCount(1); + await expect(commitRows(page)).toHaveCount(20); + }); + + test("hides the graph drawing from assistive technology", async ({ page }) => { + await mockTauri(page, { rows: branchedHistory() }); + await page.goto("/"); + await openRepository(page); + + // The row already reads correctly without the lanes; announcing the SVG + // would only add noise. + const svgs = page.locator("ol svg"); + const count = await svgs.count(); + + expect(count).toBeGreaterThan(0); + for (let index = 0; index < count; index++) { + await expect(svgs.nth(index)).toHaveAttribute("aria-hidden", "true"); + } + }); + + test("names each ref badge, since its colour says nothing out loud", async ({ page }) => { + await mockTauri(page, { rows: branchedHistory() }); + await page.goto("/"); + await openRepository(page); + + const first = commitRows(page).first(); + + await expect(first).toContainText("current branch"); + await expect(first).toContainText("main"); + }); +}); + +test.describe("keyboard", () => { + test.beforeEach(async ({ page }) => { + await mockTauri(page, { rows: linearHistory(60) }); + await page.goto("/"); + await openRepository(page); + await expect(commitRows(page).first()).toBeVisible(); + }); + + test("reaches the history in a few tab stops, not one per commit", async ({ page }) => { + const stops = await page.locator("ol button[tabindex='0']").count(); + + expect(stops).toBe(1); + }); + + test("walks the history with the arrow keys", async ({ page }) => { + await page.locator("[data-row='0']").focus(); + + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowDown"); + + await expect(page.locator(":focus")).toHaveAttribute("data-row", "3"); + }); + + test("jumps to the ends", async ({ page }) => { + await page.locator("[data-row='0']").focus(); + + await page.keyboard.press("End"); + await expect(page.locator(":focus")).not.toHaveAttribute("data-row", "0"); + + await page.keyboard.press("Home"); + await expect(page.locator(":focus")).toHaveAttribute("data-row", "0"); + }); +}); + +test.describe("skip link", () => { + test("is the first thing a keyboard reaches, and moves focus to the history", async ({ + page, + }) => { + // Deliberately no prior interaction: the point of a skip link is that it is + // the very first tab stop on a fresh page. + await mockTauri(page, { rows: linearHistory(20) }); + await page.goto("/"); + + await page.keyboard.press("Tab"); + // A button, not a link: WebKit keeps links out of the tab order unless the + // system's keyboard navigation setting is on, which it is not by default — + // and WebKit is what the app runs in on macOS. This assertion is the one + // that caught it. + await expect(page.getByRole("button", { name: "Skip to history" })).toBeFocused(); + + await page.keyboard.press("Enter"); + // main carries tabindex="-1" so focus really lands there rather than the + // page merely scrolling. + await expect(page.getByRole("main")).toBeFocused(); + }); +}); + +/** The colours the theme actually resolved to, under one scheme. */ +async function palette(page: Page, scheme: "light" | "dark") { + await page.emulateMedia({ colorScheme: scheme }); + await mockTauri(page, { rows: branchedHistory() }); + await page.goto("/"); + await openRepository(page); + await expect(commitRows(page).first()).toBeVisible(); + + return { + header: await page + .getByRole("banner") + .evaluate((element) => getComputedStyle(element).backgroundColor), + lane: await page + .locator("path.graph-line") + .first() + .evaluate((element) => getComputedStyle(element).stroke), + }; +} + +test.describe("appearance", () => { + // Deliberately not screenshots: baselines differ by operating system and + // font rendering, so they fail for reasons that have nothing to do with the + // change under test. Computed colours assert the actual claim — that + // light-dark() resolves differently per scheme with no JavaScript involved. + test("resolves a different palette per colour scheme", async ({ page }) => { + const light = await palette(page, "light"); + const dark = await palette(page, "dark"); + + expect(light.header).not.toBe(dark.header); + expect(light.lane).not.toBe(dark.lane); + }); + + test("gives lanes a real colour rather than falling back to a default", async ({ page }) => { + const { lane } = await palette(page, "light"); + + expect(lane).not.toBe("none"); + expect(lane).toMatch(/^(rgb|oklch|color)/u); + }); +}); diff --git a/tests/e2e/accessibility.spec.ts-snapshots/history-dark-chromium-darwin.png b/tests/e2e/accessibility.spec.ts-snapshots/history-dark-chromium-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..36a4b2bd4fe5cae36d16ed6424d8b4036f9e8e3c GIT binary patch literal 24131 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 f7#P?<{WxZZyNU}gzv7du0;OwDS3j3^P6@lM5 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 U%+EUY4k+Jwy85}Sb4q9e0F=WR3IG5A literal 0 HcmV?d00001 diff --git a/tests/e2e/history.spec.ts b/tests/e2e/history.spec.ts new file mode 100644 index 0000000..8dde38b --- /dev/null +++ b/tests/e2e/history.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from "@playwright/test"; + +import { linearHistory } from "../fixtures/rows"; +import { commitRows, invocations, mockTauri, openRepository } from "./tauri"; + +const ROWS = 900; + +test.describe("history", () => { + test.beforeEach(async ({ page }) => { + await mockTauri(page, { rows: linearHistory(ROWS) }); + await page.goto("/"); + }); + + test("draws the history once a repository is open", async ({ page }) => { + await openRepository(page); + + await expect(commitRows(page)).not.toHaveCount(0); + await expect(page.getByText("Commit number 0")).toBeVisible(); + }); + + test("names the repository and its branch", async ({ page }) => { + await openRepository(page); + + await expect(page.getByRole("banner")).toContainText("demo"); + await expect(page.getByRole("banner")).toContainText("main"); + }); + + test("only asks for the first batch until it needs more", async ({ page }) => { + // The budget is the whole point: a large history must not arrive at once. + await openRepository(page); + await expect(commitRows(page).first()).toBeVisible(); + + const drawn = await commitRows(page).count(); + + expect(drawn).toBeGreaterThan(0); + expect(drawn).toBeLessThan(ROWS); + }); + + test("loads more as the end of the list comes into view", async ({ page }) => { + // This is the behaviour that could not be checked without a real browser: + // IntersectionObserver does not run in a hidden document. + await openRepository(page); + const rows = commitRows(page); + await expect(rows.first()).toBeVisible(); + + const before = await rows.count(); + + await rows.last().scrollIntoViewIfNeeded(); + await expect + .poll(() => rows.count(), { message: "scrolling to the end should fetch more rows" }) + .toBeGreaterThan(before); + }); + + test("keeps loading until the whole history is drawn", async ({ page }) => { + await openRepository(page); + const rows = commitRows(page); + await expect(rows.first()).toBeVisible(); + + // Walk to the bottom a screenful at a time, as a person scrolling would. + for (let attempt = 0; attempt < 12; attempt++) { + if ((await rows.count()) >= ROWS) break; + await rows.last().scrollIntoViewIfNeeded(); + await page.waitForTimeout(150); + } + + await expect(rows).toHaveCount(ROWS); + await expect(page.getByText("Loading more…")).toBeHidden(); + }); + + test("does not ask for the same rows twice", async ({ page }) => { + await openRepository(page); + const rows = commitRows(page); + await expect(rows.first()).toBeVisible(); + + for (let attempt = 0; attempt < 12; attempt++) { + if ((await rows.count()) >= ROWS) break; + await rows.last().scrollIntoViewIfNeeded(); + await page.waitForTimeout(150); + } + + const requests = (await invocations(page)).filter( + (call) => call.command === "request_more_rows", + ); + + // 900 rows, 300 up front and 400 a time: three requests, maybe a fourth + // that finds nothing left. Many more would mean the in-flight guard leaks. + expect(requests.length).toBeLessThanOrEqual(4); + }); +}); + +test.describe("failures", () => { + test("says what went wrong instead of showing an empty history", async ({ page }) => { + await mockTauri(page, { rows: [], failToOpen: "no git repository found at or above /nope" }); + await page.goto("/"); + + await openRepository(page, "/nope"); + + await expect(page.getByRole("contentinfo")).toContainText("no git repository found"); + }); +}); + +test.describe("empty repository", () => { + test("says there is nothing rather than looking broken", async ({ page }) => { + await mockTauri(page, { rows: [] }); + await page.goto("/"); + + await openRepository(page); + + await expect(page.getByText("No commits to show.")).toBeVisible(); + }); +}); diff --git a/tests/e2e/tauri.ts b/tests/e2e/tauri.ts new file mode 100644 index 0000000..4c30e0a --- /dev/null +++ b/tests/e2e/tauri.ts @@ -0,0 +1,124 @@ +/** + * Standing in for the Rust side. + * + * The frontend is an ordinary web page; the only thing it needs from Tauri is + * the IPC bridge. Installing a fake one before the page loads lets the real + * components run in a real browser with no native build, no window, and no + * system permissions. + * + * This reaches for `window.__TAURI_INTERNALS__`, which is what + * `@tauri-apps/api`'s own `mockIPC` sets. It is deliberately the only place in + * the tests that knows that, so a Tauri upgrade that moves it breaks here and + * nowhere else. + */ + +import type { Page } from "@playwright/test"; + +import type { GraphRow } from "../../src/lib/bindings"; + +export type MockOptions = { + /** Every row the fake backend is willing to hand over. */ + rows: GraphRow[]; + /** Make `open_repository` reject, to exercise the failure path. */ + failToOpen?: string; +}; + +/** + * Install the fake bridge. Must be called before navigating. + * + * The fake honours the budget the way the real backend does: it sends what it + * was asked for and no more, so a test that never scrolls really does see only + * the first batch. + */ +export async function mockTauri(page: Page, options: MockOptions): Promise { + await page.addInitScript((serialised: MockOptions) => { + const all = serialised.rows; + let sent = 0; + let channel: { onmessage: (event: unknown) => void } | undefined; + + const deliver = (count: number) => { + const batch = all.slice(sent, sent + count); + + if (batch.length > 0) { + channel?.onmessage({ event: "rows", data: { start: sent, rows: batch } }); + sent += batch.length; + } + + if (sent >= all.length) { + channel?.onmessage({ event: "exhausted", data: { total: sent, degraded: false } }); + } + }; + + // Recorded so tests can assert on what the frontend actually asked for. + const calls: { command: string; args: Record }[] = []; + (window as unknown as { __calls: typeof calls }).__calls = calls; + + (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { + transformCallback: () => Math.random(), + invoke: async (command: string, args: Record) => { + calls.push({ command, args: { ...args, channel: undefined } }); + + switch (command) { + case "open_repository": { + if (serialised.failToOpen) { + throw { kind: "git", message: serialised.failToOpen }; + } + + return { + id: "/demo/.git", + summary: { + gitDir: "/demo/.git", + workdir: "/demo", + isBare: false, + head: { + state: "branch", + name: "refs/heads/main", + shorthand: "main", + id: "abc1234def5678", + }, + }, + }; + } + case "stream_graph": { + channel = args.channel as { onmessage: (event: unknown) => void }; + sent = 0; + deliver(args.budget as number); + return null; + } + case "request_more_rows": { + deliver(args.rows as number); + return null; + } + default: + return null; + } + }, + }; + }, options); +} + +/** + * Open a repository through the UI, the way a person would. + * + * Addressed by role rather than by label: the sidebar landmark is also named + * "Repository", so a label lookup alone matches two different things. + */ +export async function openRepository(page: Page, path = "/demo"): Promise { + await page.getByRole("textbox", { name: "Repository" }).fill(path); + await page.getByRole("button", { name: "Open" }).click(); +} + +/** Every command the frontend has invoked, in order. */ +export function invocations(page: Page): Promise<{ command: string }[]> { + return page.evaluate(() => (window as unknown as { __calls: { command: string }[] }).__calls); +} + +/** + * The commit rows. + * + * Scoped to `main`, because the ref sidebar is a list too — an unscoped + * `listitem` query quietly counts four extra rows that are not commits. + */ +export function commitRows(page: Page) { + return page.getByRole("main").getByRole("listitem"); +} diff --git a/tests/fixtures/rows.ts b/tests/fixtures/rows.ts new file mode 100644 index 0000000..52cfd59 --- /dev/null +++ b/tests/fixtures/rows.ts @@ -0,0 +1,95 @@ +/** + * Building graph rows for tests. + * + * Typed as the real `GraphRow`, so if the Rust side changes shape and the + * bindings are updated, every test that builds a row stops compiling. That is + * the point: a fixture that can drift silently is worse than no fixture. + */ + +import type { Edge, GraphRow, RefBadge } from "../../src/lib/bindings"; + +/** A predictable, obviously-fake object id. */ +export function fakeId(index: number): string { + return index.toString(16).padStart(4, "0").repeat(10); +} + +export function edge(partial: Partial = {}): Edge { + return { fromColumn: 0, toColumn: 0, lane: 0, kind: "through", ...partial }; +} + +export function badge(partial: Partial = {}): RefBadge { + return { shorthand: "main", kind: "localBranch", isHead: false, ...partial }; +} + +export function row(index: number, partial: Partial = {}): GraphRow { + return { + id: fakeId(index), + row: index, + column: 0, + lane: 0, + dot: "normal", + edges: [edge({ kind: "fromCommit" })], + width: 1, + summary: `Commit number ${index}`, + author: "Fixture", + // Fixed rather than relative to now, so a test asserting on a rendered + // date does not change its answer overnight. + time: 1_700_000_000 - index * 3600, + refs: [], + ...partial, + }; +} + +/** A straight run of commits. */ +export function linearHistory(count: number): GraphRow[] { + return Array.from({ length: count }, (_, index) => row(index)); +} + +/** + * A history with a branch and a merge, so lanes, columns and dots all have + * something to say. + * + * ``` + * 0 ◍ merge, column 0, two parents + * 1 │ ● side branch, column 1 + * 2 ● main, column 0 + * 3 ● shared root + * ``` + */ +export function branchedHistory(): GraphRow[] { + return [ + row(0, { + dot: "merge", + width: 2, + refs: [badge({ shorthand: "main", isHead: true })], + edges: [ + edge({ kind: "fromCommit", fromColumn: 0, toColumn: 0, lane: 0 }), + edge({ kind: "fromCommit", fromColumn: 0, toColumn: 1, lane: 1 }), + ], + }), + row(1, { + column: 1, + lane: 1, + width: 2, + refs: [badge({ shorthand: "v1.0.0", kind: "tag" })], + edges: [ + edge({ kind: "through", fromColumn: 0, toColumn: 0, lane: 0 }), + edge({ kind: "toCommit", fromColumn: 1, toColumn: 1, lane: 1 }), + edge({ kind: "fromCommit", fromColumn: 1, toColumn: 1, lane: 1 }), + ], + }), + row(2, { + width: 2, + edges: [ + edge({ kind: "toCommit", fromColumn: 0, toColumn: 0, lane: 0 }), + edge({ kind: "through", fromColumn: 1, toColumn: 1, lane: 1 }), + edge({ kind: "fromCommit", fromColumn: 0, toColumn: 0, lane: 0 }), + ], + }), + row(3, { + dot: "root", + width: 1, + edges: [edge({ kind: "toCommit", fromColumn: 0, toColumn: 0, lane: 0 })], + }), + ]; +} diff --git a/tests/unit/format.test.ts b/tests/unit/format.test.ts new file mode 100644 index 0000000..45059fb --- /dev/null +++ b/tests/unit/format.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; + +import { absoluteTime, isoTime, relativeTime, shortId } from "../../src/lib/format"; + +/** A fixed "now" so these never depend on when they run. */ +const NOW = Date.UTC(2024, 0, 15, 12, 0, 0); +const seconds = (date: number) => Math.floor(date / 1000); + +describe("relativeTime", () => { + test("reads as how long ago, since that is how commit dates are read", () => { + expect(relativeTime(seconds(NOW) - 3 * 24 * 60 * 60, NOW)).toBe("3 days ago"); + expect(relativeTime(seconds(NOW) - 2 * 60 * 60, NOW)).toBe("2 hours ago"); + expect(relativeTime(seconds(NOW) - 90, NOW)).toBe("1 minute ago"); + }); + + test("picks the largest unit that fits", () => { + expect(relativeTime(seconds(NOW) - 400 * 24 * 60 * 60, NOW)).toContain("year"); + expect(relativeTime(seconds(NOW) - 45 * 24 * 60 * 60, NOW)).toContain("month"); + expect(relativeTime(seconds(NOW) - 10 * 24 * 60 * 60, NOW)).toContain("week"); + }); + + test("falls back to seconds for something that just happened", () => { + expect(relativeTime(seconds(NOW) - 5, NOW)).toContain("second"); + }); + + test("handles a commit dated in the future rather than going strange", () => { + // Clock skew puts commits ahead of now more often than you would think. + expect(relativeTime(seconds(NOW) + 2 * 60 * 60, NOW)).toBe("in 2 hours"); + }); +}); + +describe("isoTime", () => { + test("is machine readable, for the datetime attribute", () => { + expect(isoTime(seconds(NOW))).toBe("2024-01-15T12:00:00.000Z"); + }); +}); + +describe("absoluteTime", () => { + test("gives a full timestamp for the tooltip", () => { + // Locale-dependent, so this checks it produced something with the date in + // it rather than pinning a format the test machine may not use. + const formatted = absoluteTime(seconds(NOW)); + + expect(formatted).toMatch(/2024/u); + expect(formatted.length).toBeGreaterThan(8); + }); +}); + +describe("shortId", () => { + test("abbreviates the way git does", () => { + expect(shortId("0123456789abcdef0123456789abcdef01234567")).toBe("0123456"); + }); +}); diff --git a/tests/unit/graph-lanes.test.tsx b/tests/unit/graph-lanes.test.tsx new file mode 100644 index 0000000..755efee --- /dev/null +++ b/tests/unit/graph-lanes.test.tsx @@ -0,0 +1,137 @@ +import { render } from "@solidjs/testing-library"; +import { describe, expect, test } from "vitest"; + +import GraphLanes from "../../src/components/GraphLanes"; +import { edge, row } from "../fixtures/rows"; + +/** Every path the component drew, in order. */ +function paths(container: HTMLElement): string[] { + return [...container.querySelectorAll("path")].map((path) => path.getAttribute("d") ?? ""); +} + +/** The lane index each path was given, which is what drives its colour. */ +function lanes(container: HTMLElement): (string | null)[] { + return [...container.querySelectorAll("path")].map((path) => + path.style.getPropertyValue("--lane"), + ); +} + +describe("GraphLanes", () => { + test("is hidden from assistive technology", () => { + // The row's text already says everything the graph shows; the drawing is + // decoration on top of it. + const { container } = render(() => ); + + expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true"); + }); + + test("is as wide as the columns the row uses", () => { + const { container } = render(() => ); + + // Four columns at fourteen pixels each. + expect(container.querySelector("svg")?.getAttribute("width")).toBe("56"); + }); + + test("widens for a commit sitting past the reported width", () => { + // Defensive: a row whose column is outside its own width would otherwise + // draw its dot beyond the edge of the SVG, where it would be clipped. + const { container } = render(() => ); + + expect(container.querySelector("svg")?.getAttribute("width")).toBe("56"); + }); + + test("draws a line passing the row as a straight vertical", () => { + const { container } = render(() => ( + + )); + + const [line] = paths(container); + // Same x at both ends, and both control points share it, so the curve has + // nothing to bend towards. + expect(line).toBe("M 7 0 C 7 14 7 14 7 28"); + }); + + test("curves a line that arrives from another column into the dot", () => { + const { container } = render(() => ( + + )); + + const [line] = paths(container); + // Starts at column 1's centre on the top edge, ends at column 0's centre + // halfway down, where the dot is. + expect(line).toBe("M 21 0 C 21 7 7 7 7 14"); + }); + + test("draws a line leaving the dot towards a parent", () => { + const { container } = render(() => ( + + )); + + expect(paths(container)[0]).toBe("M 7 14 C 7 21 21 21 21 28"); + }); + + test("passes each line its own lane, so a merge is two colours", () => { + const { container } = render(() => ( + + )); + + expect(lanes(container)).toEqual(["3", "7"]); + }); + + test("gives the dot the row's lane", () => { + const { container } = render(() => ); + + expect(container.querySelector("circle")?.style.getPropertyValue("--lane")).toBe("5"); + }); + + test("draws a merge as a ring so it is not size alone that distinguishes it", () => { + const { container } = render(() => ); + const dot = container.querySelector("circle"); + + expect(dot?.getAttribute("class")).toContain("graph-dot-merge"); + expect(dot?.getAttribute("r")).toBe("5"); + }); + + test("draws an ordinary commit as a plain dot", () => { + const { container } = render(() => ); + const dot = container.querySelector("circle"); + + expect(dot?.getAttribute("class")).not.toContain("graph-dot-merge"); + expect(dot?.getAttribute("r")).toBe("4"); + }); + + test("puts the dot in the row's column", () => { + const { container } = render(() => ); + const dot = container.querySelector("circle"); + + expect(dot?.getAttribute("cx")).toBe("35"); + expect(dot?.getAttribute("cy")).toBe("14"); + }); +}); diff --git a/tests/unit/graph-view.test.ts b/tests/unit/graph-view.test.ts new file mode 100644 index 0000000..5b8d1ec --- /dev/null +++ b/tests/unit/graph-view.test.ts @@ -0,0 +1,180 @@ +import { flush } from "solid-js"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { GraphEvent } from "../../src/lib/bindings"; +import { createGraphView } from "../../src/lib/graph"; +import { linearHistory } from "../fixtures/rows"; + +const requestMore = vi.fn(async () => {}); +/** Lets a test push events as if the Rust side had sent them. */ +let send: (event: GraphEvent) => void; + +vi.mock("../../src/lib/api", () => ({ + streamGraph: vi.fn(async (_id: string, _budget: number, onEvent: (e: GraphEvent) => void) => { + send = onEvent; + return { requestMore }; + }), +})); + +beforeEach(() => { + requestMore.mockClear(); + requestMore.mockImplementation(async () => {}); +}); + +describe("createGraphView", () => { + test("starts with nothing and does nothing", () => { + const view = createGraphView(); + + expect(view.rows()).toEqual([]); + expect(view.status()).toBe("idle"); + }); + + test("collects the rows the backend pushes", async () => { + const view = createGraphView(); + await view.show("repo"); + + send({ event: "rows", data: { start: 0, rows: linearHistory(10) } }); + await flush(); + + expect(view.rows()).toHaveLength(10); + expect(view.status()).toBe("streaming"); + }); + + test("appends later batches in order", async () => { + const view = createGraphView(); + await view.show("repo"); + + send({ event: "rows", data: { start: 0, rows: linearHistory(5) } }); + send({ event: "rows", data: { start: 5, rows: linearHistory(10).slice(5) } }); + await flush(); + + expect(view.rows().map((row) => row.row)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + test("places a batch by the index it declares, not by what arrived before", async () => { + // `start` exists so a batch lands correctly even if the frontend's idea of + // how many rows it has does not match the backend's. + const view = createGraphView(); + await view.show("repo"); + + send({ event: "rows", data: { start: 0, rows: linearHistory(10) } }); + await flush(); + send({ event: "rows", data: { start: 4, rows: linearHistory(6).slice(4) } }); + await flush(); + + expect(view.rows()).toHaveLength(6); + expect(view.rows().at(-1)?.row).toBe(5); + }); + + test("knows when the history is fully drawn", async () => { + const view = createGraphView(); + await view.show("repo"); + + send({ event: "rows", data: { start: 0, rows: linearHistory(3) } }); + send({ event: "exhausted", data: { total: 3, degraded: false } }); + await flush(); + + expect(view.status()).toBe("complete"); + expect(view.message()).toBeUndefined(); + }); + + test("says so when the ordering had to be approximated", async () => { + // Silently drawing a slightly wrong graph would be worse than admitting it. + const view = createGraphView(); + await view.show("repo"); + + send({ event: "exhausted", data: { total: 0, degraded: true } }); + await flush(); + + expect(view.message()).toContain("skewed"); + }); + + test("surfaces a failure from the walk", async () => { + const view = createGraphView(); + await view.show("repo"); + + send({ event: "failed", data: { message: "the odb went away" } }); + await flush(); + + expect(view.status()).toBe("failed"); + expect(view.message()).toBe("the odb went away"); + }); + + test("asks for more rows when told to", async () => { + const view = createGraphView(); + await view.show("repo"); + send({ event: "rows", data: { start: 0, rows: linearHistory(10) } }); + await flush(); + + view.loadMore(); + + expect(requestMore).toHaveBeenCalledTimes(1); + }); + + test("does not ask again while a request is still in flight", async () => { + // The scroll sentinel fires repeatedly; without this the backend would be + // asked for the same rows many times over. + const view = createGraphView(); + await view.show("repo"); + send({ event: "rows", data: { start: 0, rows: linearHistory(10) } }); + await flush(); + + view.loadMore(); + view.loadMore(); + view.loadMore(); + + expect(requestMore).toHaveBeenCalledTimes(1); + }); + + test("asks again once the rows it was waiting for arrive", async () => { + const view = createGraphView(); + await view.show("repo"); + send({ event: "rows", data: { start: 0, rows: linearHistory(10) } }); + await flush(); + + view.loadMore(); + send({ event: "rows", data: { start: 10, rows: linearHistory(20).slice(10) } }); + await flush(); + view.loadMore(); + + expect(requestMore).toHaveBeenCalledTimes(2); + }); + + test("stops asking once the history is complete", async () => { + const view = createGraphView(); + await view.show("repo"); + send({ event: "exhausted", data: { total: 0, degraded: false } }); + await flush(); + + view.loadMore(); + + expect(requestMore).not.toHaveBeenCalled(); + }); + + test("reports a failed request rather than getting stuck", async () => { + const view = createGraphView(); + await view.show("repo"); + send({ event: "rows", data: { start: 0, rows: linearHistory(10) } }); + await flush(); + + requestMore.mockRejectedValueOnce({ kind: "workerStopped", message: "worker stopped" }); + view.loadMore(); + await flush(); + await Promise.resolve(); + + expect(view.status()).toBe("failed"); + expect(view.message()).toBe("worker stopped"); + }); + + test("clears what was there when a different repository is shown", async () => { + const view = createGraphView(); + await view.show("first"); + send({ event: "rows", data: { start: 0, rows: linearHistory(10) } }); + await flush(); + + await view.show("second"); + await flush(); + + expect(view.rows()).toEqual([]); + }); +}); diff --git a/tests/unit/history.test.tsx b/tests/unit/history.test.tsx new file mode 100644 index 0000000..d8ea8e7 --- /dev/null +++ b/tests/unit/history.test.tsx @@ -0,0 +1,192 @@ +import { render } from "@solidjs/testing-library"; +import { createSignal, flush } from "solid-js"; +import { describe, expect, test, vi } from "vitest"; + +import History from "../../src/components/History"; +import type { GraphRow } from "../../src/lib/bindings"; +import type { GraphStatus, GraphView } from "../../src/lib/graph"; +import { branchedHistory, linearHistory } from "../fixtures/rows"; + +/** A stand-in for the real view, so these tests never touch IPC. */ +function view( + rows: GraphRow[], + status: GraphStatus = "streaming", +): GraphView & { loadMore: ReturnType } { + const [current] = createSignal(rows); + const [currentStatus] = createSignal(status); + + return { + rows: current, + status: currentStatus, + message: () => undefined, + show: vi.fn(), + loadMore: vi.fn(), + } as unknown as GraphView & { loadMore: ReturnType }; +} + +/** Which row currently has focus. */ +function focusedRow(): string | undefined { + return (document.activeElement as HTMLElement | null)?.dataset.row; +} + +function press(key: string) { + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); +} + +describe("History keyboard navigation", () => { + test("is a single tab stop, not one per commit", async () => { + // A history is thousands of rows long; tabbing through it would be a trap. + const { container } = render(() => ); + await flush(); + + const stops = [...container.querySelectorAll("button")].filter( + (button) => button.tabIndex === 0, + ); + + expect(stops).toHaveLength(1); + expect(stops[0]?.dataset.row).toBe("0"); + }); + + test("moves one row per arrow press", async () => { + // Regression: signal writes are batched, so computing the next row from a + // plain read of the current one went stale and several presses in quick + // succession only moved one row. + const { container } = render(() => ); + await flush(); + + container.querySelector('[data-row="0"]')?.focus(); + for (let index = 0; index < 5; index++) press("ArrowDown"); + + expect(focusedRow()).toBe("5"); + }); + + test("moves back up one row at a time", async () => { + const { container } = render(() => ); + await flush(); + + container.querySelector('[data-row="10"]')?.focus(); + press("ArrowUp"); + press("ArrowUp"); + + expect(focusedRow()).toBe("8"); + }); + + test("jumps a page at a time", async () => { + const { container } = render(() => ); + await flush(); + + container.querySelector('[data-row="0"]')?.focus(); + press("PageDown"); + expect(focusedRow()).toBe("20"); + + press("PageUp"); + expect(focusedRow()).toBe("0"); + }); + + test("Home and End reach both ends", async () => { + const { container } = render(() => ); + await flush(); + + container.querySelector('[data-row="10"]')?.focus(); + + press("End"); + expect(focusedRow()).toBe("49"); + + press("Home"); + expect(focusedRow()).toBe("0"); + }); + + test("stops at the ends rather than wrapping or going out of range", async () => { + const { container } = render(() => ); + await flush(); + + container.querySelector('[data-row="0"]')?.focus(); + press("ArrowUp"); + expect(focusedRow()).toBe("0"); + + press("End"); + press("ArrowDown"); + expect(focusedRow()).toBe("2"); + }); + + test("leaves keys it does not handle to the browser", async () => { + const { container } = render(() => ); + await flush(); + + const first = container.querySelector('[data-row="0"]'); + first?.focus(); + + const event = new KeyboardEvent("keydown", { key: "a", bubbles: true, cancelable: true }); + first?.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(focusedRow()).toBe("0"); + }); + + test("marks exactly one row as current", async () => { + const { container } = render(() => ); + await flush(); + + container.querySelector('[data-row="0"]')?.focus(); + press("ArrowDown"); + await flush(); + + const current = container.querySelectorAll("[aria-current]"); + expect(current).toHaveLength(1); + expect((current[0] as HTMLElement).dataset.row).toBe("1"); + }); +}); + +describe("History rendering", () => { + test("is a list, so assistive tech announces how long it is", async () => { + const { container } = render(() => ); + await flush(); + + const list = container.querySelector("ol"); + expect(list).not.toBeNull(); + expect(list?.children).toHaveLength(5); + }); + + test("shows each commit's summary, author and short id", async () => { + const { container, getByText } = render(() => ); + await flush(); + + expect(getByText("Commit number 0")).toBeTruthy(); + expect(container.textContent).toContain("Fixture"); + // Seven characters, the way git abbreviates. + expect(container.textContent).toContain("0000000"); + }); + + test("gives every commit a machine readable date", async () => { + const { container } = render(() => ); + await flush(); + + const times = container.querySelectorAll("time"); + expect(times).toHaveLength(2); + expect(times[0]?.getAttribute("datetime")).toBe("2023-11-14T22:13:20.000Z"); + }); + + test("names what each ref badge is, since colour alone does not say", async () => { + const { container } = render(() => ); + await flush(); + + expect(container.textContent).toContain("current branch"); + expect(container.textContent).toContain("tag"); + }); + + test("says so when there is nothing to show", async () => { + const { getByText } = render(() => ); + await flush(); + + expect(getByText("No commits to show.")).toBeTruthy(); + }); + + test("says it is working while the first rows are on their way", async () => { + const { getByText } = render(() => ); + await flush(); + + expect(getByText("Reading history…")).toBeTruthy(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..02cf0a6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import solid from "vite-plugin-solid"; +import { defineConfig } from "vitest/config"; + +// Kept apart from vite.config.ts, which exists to serve the Tauri webview and +// has nothing to say about tests. +export default defineConfig({ + plugins: [solid()], + resolve: { + // Solid ships a development build with the reactivity warnings that make a + // failing test explicable, and a browser build that is what runs in the + // webview. Without these conditions the server build is picked instead. + conditions: ["development", "browser"], + dedupe: ["solid-js", "@solidjs/web", "@solidjs/signals"], + }, + test: { + environment: "happy-dom", + // Only unit tests. The Playwright specs next door drive a real browser and + // must not be picked up by a DOM emulator. + include: ["tests/unit/**/*.test.{ts,tsx}"], + globals: true, + }, +}); -- 2.51.2