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, + }, +});