From 4e07f67dc026c3bc966f85f61d2ebebd87736b80 Mon Sep 17 00:00:00 2001 From: hyuraku <32809703+hyuraku@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:34:26 +0900 Subject: [PATCH 1/2] chore(test): add vitest and run unit tests in CI Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/nodejs.yml | 7 +- package.json | 8 +- yarn.lock | 192 ++++++++++++++++++++++++++++++++++- 3 files changed, 200 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 0713ead..6cb8512 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,6 +1,6 @@ -# Static checks (lint / typecheck / build) on every push, and a GitHub Pages -# deploy on master. Cypress e2e lives in its own workflow so the two run in -# parallel. +# Static checks (lint / typecheck / unit tests / build) on every push, and a +# GitHub Pages deploy on master. Cypress e2e lives in its own workflow so the +# two run in parallel. name: Node.js CI @@ -23,6 +23,7 @@ jobs: - run: yarn install --frozen-lockfile - run: yarn lint - run: yarn typecheck + - run: yarn test - run: yarn build - name: prepare deploy if: github.ref == 'refs/heads/master' diff --git a/package.json b/package.json index 9b97c85..f155ab6 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,17 @@ "react": "^19.2.8", "react-dom": "^19.2.8" }, + "resolutions": { + "vite": "^7.3.2" + }, "scripts": { "cy:open": "cypress open", "cy:run": "cypress run", "cy:run:chrome": "cypress run --browser chrome", "cy:run:firefox": "cypress run --browser firefox", "start": "vite", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint src", "typecheck": "tsc --noEmit", "build": "tsc --noEmit && vite build" @@ -34,6 +39,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.66.0", "vite": "^7.3.2", - "vite-plugin-compression": "^0.5.1" + "vite-plugin-compression": "^0.5.1", + "vitest": "^4.1.10" } } diff --git a/yarn.lock b/yarn.lock index b67c303..cccac25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -485,7 +485,7 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== -"@jridgewell/sourcemap-codec@^1.5.0": +"@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== @@ -636,6 +636,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz#f79437939020b83057faf07e98365b1fa51c458b" integrity sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw== +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -669,6 +674,19 @@ dependencies: "@babel/types" "^7.28.2" +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + "@types/esrecurse@^4.3.1": version "4.3.1" resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" @@ -679,7 +697,7 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== -"@types/estree@^1.0.6", "@types/estree@^1.0.8": +"@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": version "1.0.9" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== @@ -831,6 +849,66 @@ "@types/babel__core" "^7.20.5" react-refresh "^0.18.0" +"@vitest/expect@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.10.tgz#799c06fc44bb0cf7e2784137b627c5cc173285d4" + integrity sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA== + dependencies: + "@standard-schema/spec" "^1.1.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.1.10" + "@vitest/utils" "4.1.10" + chai "^6.2.2" + tinyrainbow "^3.1.0" + +"@vitest/mocker@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.10.tgz#2413987ab4cd7fa1c2b614b404c407bf6ad1ead1" + integrity sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow== + dependencies: + "@vitest/spy" "4.1.10" + estree-walker "^3.0.3" + magic-string "^0.30.21" + +"@vitest/pretty-format@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz#75542e7273a08cc10fd4d8dad4e3eb1f16cd958c" + integrity sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q== + dependencies: + tinyrainbow "^3.1.0" + +"@vitest/runner@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.10.tgz#febf0a21a9168421422d1955370e606feab60355" + integrity sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg== + dependencies: + "@vitest/utils" "4.1.10" + pathe "^2.0.3" + +"@vitest/snapshot@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.10.tgz#7e3e9fec7d4d47232e493cfdcbd2170de4371c04" + integrity sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw== + 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": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.10.tgz#5c0bfa97b56bba9e37403c976db776ff6ab56f65" + integrity sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw== + +"@vitest/utils@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.10.tgz#ffc71055f18bfccb1fd0586365ebc2824892e403" + integrity sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA== + dependencies: + "@vitest/pretty-format" "4.1.10" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -897,6 +975,11 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -1007,6 +1090,11 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== +chai@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -1241,6 +1329,11 @@ es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-module-lexer@^2.0.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz#5bf2df06999dbbe5f006a5f46a11fb9f5b7b391b" + integrity sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA== + es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" @@ -1395,6 +1488,13 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -1432,6 +1532,11 @@ executable@^4.1.1: dependencies: pify "^2.2.0" +expect-type@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -1896,6 +2001,13 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +magic-string@^0.30.21: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -1972,6 +2084,11 @@ object-inspect@^1.13.3: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== +obug@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.4.tgz#9090d8a548a522517915d2aa6aae907197ac6cf8" + integrity sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA== + once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -2034,6 +2151,11 @@ path-key@^3.0.0, path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -2261,6 +2383,11 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + signal-exit@^3.0.2: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -2307,6 +2434,16 @@ sshpk@^1.18.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^4.0.0-rc.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== + string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -2376,6 +2513,16 @@ throttleit@^1.0.0: resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.1.tgz#304ec51631c3b770c65c6c6f76938b384000f4d5" integrity sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^1.0.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.3.0.tgz#aacc1dbb1d4e93e6ad8dd64944e09f9ad147a474" + integrity sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ== + tinyglobby@^0.2.15: version "0.2.15" resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" @@ -2384,6 +2531,11 @@ tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.3" +tinyrainbow@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== + tldts-core@^6.1.86: version "6.1.86" resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.86.tgz#a93e6ed9d505cb54c542ce43feb14c73913265d8" @@ -2505,7 +2657,7 @@ vite-plugin-compression@^0.5.1: debug "^4.3.3" fs-extra "^10.0.0" -vite@^7.3.2: +vite@7.3.2, "vite@^6.0.0 || ^7.0.0 || ^8.0.0", vite@^7.3.2: version "7.3.2" resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== @@ -2519,6 +2671,32 @@ vite@^7.3.2: optionalDependencies: fsevents "~2.3.3" +vitest@^4.1.10: + version "4.1.10" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.10.tgz#7e9285efe264b1167050b7a3a7ff34788e1b7afc" + integrity sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw== + dependencies: + "@vitest/expect" "4.1.10" + "@vitest/mocker" "4.1.10" + "@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.0.0" + expect-type "^1.3.0" + magic-string "^0.30.21" + obug "^2.1.1" + pathe "^2.0.3" + picomatch "^4.0.3" + std-env "^4.0.0-rc.1" + tinybench "^2.9.0" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running "^2.3.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -2526,6 +2704,14 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" From 7164373420f209860e3db50882c347ed1a8aaa14 Mon Sep 17 00:00:00 2001 From: hyuraku <32809703+hyuraku@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:34:26 +0900 Subject: [PATCH 2/2] feat(search): reject invalid GitHub usernames before searching Co-Authored-By: Claude Opus 5 (1M context) --- cypress/e2e/validation_spec.cy.ts | 57 ++++++++++++++++++++++++++++++ src/components/SearchBar.tsx | 30 +++++++++++++--- src/css/SearchBar.css | 8 +++++ src/utils/validateUsername.test.ts | 38 ++++++++++++++++++++ src/utils/validateUsername.ts | 30 ++++++++++++++++ 5 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 cypress/e2e/validation_spec.cy.ts create mode 100644 src/utils/validateUsername.test.ts create mode 100644 src/utils/validateUsername.ts diff --git a/cypress/e2e/validation_spec.cy.ts b/cypress/e2e/validation_spec.cy.ts new file mode 100644 index 0000000..d631dd1 --- /dev/null +++ b/cypress/e2e/validation_spec.cy.ts @@ -0,0 +1,57 @@ +import { interceptStarredSinglePage } from '../support/intercepts' + +/** + * Which names are valid is settled by the unit tests in + * `src/utils/validateUsername.test.ts`. These tests only ask whether the + * validator is wired into the form at all: a name GitHub could never own + * must not reach the network, and the user must be told why. + */ +describe('Username validation', function () { + beforeEach(() => { + interceptStarredSinglePage() + cy.visit('/') + }) + + it('should not call the API when the username could not exist', () => { + cy.get('.search-input').type('oct--ocat') + cy.get('.search-button').click() + + cy.get('.search-error') + .should('have.attr', 'role', 'alert') + .and('contain', 'letters, numbers, and single hyphens') + + // The app never leaves the welcome screen, and no request is made. + // (`.card` is not usable here: the welcome screen renders sample cards.) + cy.get('.welcome').should('exist') + cy.get('@starred.all').should('have.length', 0) + }) + + it('should mark the input invalid for assistive technology', () => { + cy.get('.search-input').type('my_name') + cy.get('.search-button').click() + + cy.get('.search-input').should('have.attr', 'aria-invalid', 'true') + }) + + it('should search normally once the name is corrected', () => { + cy.get('.search-input').type('my_name') + cy.get('.search-button').click() + cy.get('.search-error').should('exist') + + cy.get('.search-input').clear().type('octocat') + cy.get('.search-button').click() + + cy.get('.search-error').should('not.exist') + cy.get('.search-input').should('not.have.attr', 'aria-invalid', 'true') + cy.get('.card').should('have.length', 3) + cy.get('@starred.all').should('have.length', 1) + }) + + it('should leave valid names untouched', () => { + cy.get('.search-input').type('octo-cat') + cy.get('.search-button').click() + + cy.get('.search-error').should('not.exist') + cy.get('.card').should('have.length', 3) + }) +}) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 2858857..4ef44fa 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react' +import { isValidUsername, USERNAME_ERROR_MESSAGE } from '../utils/validateUsername' import '../css/SearchBar.css' interface Props { @@ -8,11 +9,22 @@ interface Props { export const SearchBar: React.FC = (props) => { const [name, setName] = useState('') + // Only set on submit: warning while the user is still typing would flag + // every name as broken before it is finished. + const [invalid, setInvalid] = useState(false) + const onFormSubmit = (event: React.FormEvent) => { event.preventDefault() - if (name.trim()) { - props.onSubmit(name.trim()) + const trimmed = name.trim() + if (!trimmed) { + return + } + if (!isValidUsername(trimmed)) { + setInvalid(true) + return } + setInvalid(false) + props.onSubmit(trimmed) } return ( @@ -27,12 +39,17 @@ export const SearchBar: React.FC = (props) => { type="text" autoFocus value={name} - onChange={(e) => setName(e.target.value)} + onChange={(e) => { + setName(e.target.value) + // Drop a stale warning as soon as the name changes. + setInvalid(false) + }} readOnly={props.readOnly} placeholder="Enter GitHub username" className="search-input" aria-labelledby="search-heading" - aria-describedby="search-hint" + aria-describedby={invalid ? 'search-error search-hint' : 'search-hint'} + aria-invalid={invalid ? 'true' : undefined} aria-required="true" /> @@ -45,6 +62,11 @@ export const SearchBar: React.FC = (props) => { {props.readOnly ? 'Searching...' : 'Search'} + {invalid && ( + + )}

Press Enter or click Search to find starred repositories

diff --git a/src/css/SearchBar.css b/src/css/SearchBar.css index 6c61981..6f8c1d9 100644 --- a/src/css/SearchBar.css +++ b/src/css/SearchBar.css @@ -126,6 +126,14 @@ transform: none; } +.search-error { + margin-top: 0.75rem; + font-size: 0.875rem; + color: #d32f2f; + text-align: center; + line-height: 1.5; +} + .search-hint { margin-top: 0.75rem; font-size: 0.875rem; diff --git a/src/utils/validateUsername.test.ts b/src/utils/validateUsername.test.ts new file mode 100644 index 0000000..e64d75b --- /dev/null +++ b/src/utils/validateUsername.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { isValidUsername, USERNAME_MAX_LENGTH } from './validateUsername' + +describe('isValidUsername', () => { + describe('accepts names GitHub could own', () => { + it.each([ + ['a plain name', 'octocat'], + ['a single character', 'a'], + ['digits only', '123'], + ['mixed case', 'Hyuraku'], + ['letters and digits', 'user123'], + ['a single hyphen inside', 'my-name'], + ['hyphens spread apart', 'a-b-c-d'], + ['the maximum length', 'a'.repeat(USERNAME_MAX_LENGTH)], + ])('%s: %s', (_case, name) => { + expect(isValidUsername(name)).toBe(true) + }) + }) + + describe('rejects names GitHub could not own', () => { + it.each([ + ['empty input', ''], + ['one over the maximum', 'a'.repeat(USERNAME_MAX_LENGTH + 1)], + ['a leading hyphen', '-octocat'], + ['a trailing hyphen', 'octocat-'], + ['two hyphens in a row', 'oct--ocat'], + ['only a hyphen', '-'], + ['an underscore', 'my_name'], + ['a dot', 'my.name'], + ['an inner space', 'my name'], + ['an at sign', 'user@example'], + ['a slash', 'octocat/repo'], + ['non-ASCII letters', 'ユーザー'], + ])('%s: %s', (_case, name) => { + expect(isValidUsername(name)).toBe(false) + }) + }) +}) diff --git a/src/utils/validateUsername.ts b/src/utils/validateUsername.ts new file mode 100644 index 0000000..d112bba --- /dev/null +++ b/src/utils/validateUsername.ts @@ -0,0 +1,30 @@ +/** + * The longest username GitHub allows. + * Kept next to the validator so the test and the UI copy cannot drift apart. + */ +export const USERNAME_MAX_LENGTH = 39 + +/** + * Shown when the input does not describe a username GitHub could own. + * A single sentence covers every rule, so the user is not walked through + * the rules one rejection at a time. + */ +export const USERNAME_ERROR_MESSAGE = + 'Usernames may only contain letters, numbers, and single hyphens (max 39 characters).' + +/** + * Answers whether GitHub could own this username, so an input that can only + * ever 404 never reaches the API. + * + * GitHub's rules: letters, digits and hyphens only; no leading or trailing + * hyphen; no two hyphens in a row; 1 to 39 characters. + * + * @param name the raw input, already trimmed by the caller + */ +export const isValidUsername = (name: string): boolean => { + return ( + name.length > 0 && + name.length <= USERNAME_MAX_LENGTH && + /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(name) + ) +}