diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..280ee5d6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + +# Cancel superseded runs on the same branch / PR to save CI minutes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Get Playwright version + id: playwright-version + run: echo "version=$(npx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Type check + run: npm run check + + - name: Run tests + run: npm test diff --git a/CLAUDE.md b/CLAUDE.md index a5edd369..7486ca37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,14 @@ npm run check # Svelte + TypeScript type checking npm run prettify # Format all files with Prettier npm run migrate # Run database migrations via Knex npm run seed # Run database seeds (migrations run automatically first) +npm test # Run all tests (server unit + browser component projects) +npm run test:server # Server-side unit tests only (Node) +npm run test:client # Component tests only (headless Chromium via Playwright) +npm run test:watch # Watch mode ``` +Component tests need a one-time `npx playwright install chromium`. + ## Architecture ### Dual Process Model diff --git a/package-lock.json b/package-lock.json index 6fdbe0ea..c2d01ea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,7 @@ "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", "@types/nodemailer": "^7.0.4", + "@vitest/browser-playwright": "^4.1.10", "autoprefixer": "^10.4.22", "clsx": "^2.1.1", "concurrently": "^9.2.1", @@ -126,7 +127,9 @@ "vaul-svelte": "^1.0.0-next.7", "vite": "^7.2.2", "vite-node": "^5.3.0", - "vite-plugin-devtools-json": "^1.0.0" + "vite-plugin-devtools-json": "^1.0.0", + "vitest": "^4.1.10", + "vitest-browser-svelte": "^3.0.0" }, "engines": { "node": ">=20.0.0" @@ -157,6 +160,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -2645,6 +2655,19 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2666,6 +2689,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2716,6 +2750,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dns2": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/dns2/-/dns2-2.0.10.tgz", @@ -2900,6 +2941,186 @@ "@codemirror/view": ">=6.0.0" } }, + "node_modules/@vitest/browser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@zone-eu/mailsplit": { "version": "5.4.8", "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", @@ -3027,6 +3248,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3539,6 +3770,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3805,6 +4046,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -5018,6 +5266,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -8291,6 +8549,56 @@ "node": ">=22.0.0" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", @@ -9490,6 +9798,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -9614,6 +9929,13 @@ "node": ">= 0.6" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -9639,6 +9961,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10215,6 +10544,23 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10231,6 +10577,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tlds": { "version": "1.261.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", @@ -10729,6 +11085,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "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" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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 + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest-browser-svelte": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-3.0.0.tgz", + "integrity": "sha512-BKM9iBGNsEQzigWuSZFAiw6AJDqQqMttApoD8gM8LTI6vxfd423rDMN8GqUHJyGbhH3XcOo9N9u/4hu47Nxhsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/svelte-core": "^1.1.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vitest": "^4.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -10864,6 +11327,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -10887,6 +11367,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "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 + } + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", diff --git a/package.json b/package.json index d3eaaab7..a4366688 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,11 @@ "preview": "vite preview", "schedule": "vite-node src/lib/server/startup.ts", "seed": "npx knex seed:run", - "start": "node build/main.js" + "start": "node build/main.js", + "test": "cross-env TZ=UTC vitest run", + "test:client": "vitest run --project=client", + "test:server": "cross-env TZ=UTC vitest run --project=server", + "test:watch": "cross-env TZ=UTC vitest" }, "devDependencies": { "@internationalized/date": "^3.10.0", @@ -69,6 +73,7 @@ "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", "@types/nodemailer": "^7.0.4", + "@vitest/browser-playwright": "^4.1.10", "autoprefixer": "^10.4.22", "clsx": "^2.1.1", "concurrently": "^9.2.1", @@ -92,7 +97,9 @@ "vaul-svelte": "^1.0.0-next.7", "vite": "^7.2.2", "vite-node": "^5.3.0", - "vite-plugin-devtools-json": "^1.0.0" + "vite-plugin-devtools-json": "^1.0.0", + "vitest": "^4.1.10", + "vitest-browser-svelte": "^3.0.0" }, "engines": { "node": ">=20.0.0" diff --git a/src/lib/components/CopyButton.svelte.test.ts b/src/lib/components/CopyButton.svelte.test.ts new file mode 100644 index 00000000..fde21f68 --- /dev/null +++ b/src/lib/components/CopyButton.svelte.test.ts @@ -0,0 +1,57 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-svelte"; +import CopyButton from "./CopyButton.svelte"; + +// The clipboard is spied on rather than read back: headless Chromium does not +// grant clipboard-read permission, and the component's contract is "calls +// navigator.clipboard.writeText and flips to the copied state". +describe("CopyButton", () => { + beforeAll(() => { + // navigator.clipboard only exists in secure contexts; provide a minimal + // stand-in when absent so the spies below always have a target. + if (!("clipboard" in navigator)) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: async () => {} }, + configurable: true, + }); + } + }); + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("copies the text prop and shows the copied state on click", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + const screen = await render(CopyButton, { text: "copy-me" }); + const copiedTooltip = screen.getByText("Copied"); + + // Hidden before the click (class-based visibility) + await expect.element(copiedTooltip).toHaveClass(/opacity-0/); + + await screen.getByRole("button").click(); + + expect(writeText).toHaveBeenCalledExactlyOnceWith("copy-me"); + await expect.element(copiedTooltip).toHaveClass(/scale-100/); + }); + + it("invokes the onclick callback", async () => { + vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + const onclick = vi.fn(); + + const screen = await render(CopyButton, { text: "x", onclick }); + await screen.getByRole("button").click(); + + expect(onclick).toHaveBeenCalledOnce(); + }); + + it("does not touch the clipboard when no text is provided", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + const screen = await render(CopyButton, {}); + await screen.getByRole("button").click(); + + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/components/StatusBarCalendar.svelte.test.ts b/src/lib/components/StatusBarCalendar.svelte.test.ts new file mode 100644 index 00000000..47fc548f --- /dev/null +++ b/src/lib/components/StatusBarCalendar.svelte.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { render } from "vitest-browser-svelte"; +import StatusBarCalendar from "./StatusBarCalendar.svelte"; +import type { TimestampStatusCount } from "$lib/server/types/db"; + +// The browser context is pinned to UTC (see vite.config.ts), so the timezone store +// (which defaults to browser TZ) is deterministic; noon UTC keeps fixtures safe too. +const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z + +const day = (overrides: Partial): TimestampStatusCount => ({ + ts: NOON_UTC, + countOfUp: 0, + countOfDown: 0, + countOfDegraded: 0, + countOfMaintenance: 0, + avgLatency: 0, + maxLatency: 0, + minLatency: 0, + ...overrides, +}); + +describe("StatusBarCalendar", () => { + it("labels the canvas with the number of days", async () => { + const data = [day({ countOfUp: 10 }), day({ countOfUp: 10 }), day({ countOfUp: 10 })]; + const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true }); + + await expect.element(screen.getByLabelText("Status calendar showing 3-day uptime data")).toBeInTheDocument(); + }); + + it("shows status summary, date, and latency in the tooltip of the hovered day", async () => { + // Three bars: hovering the canvas center lands on the middle bar (index 1), + // which is 90% down => "Major System Outage" (>= 75% threshold). + const data = [ + day({ countOfUp: 100 }), + day({ countOfDown: 90, countOfUp: 10, avgLatency: 250 }), + day({ countOfUp: 90, countOfDegraded: 10 }), + ]; + const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true }); + + await screen.getByLabelText("Status calendar showing 3-day uptime data").hover(); + + await expect.element(screen.getByText("Major System Outage")).toBeInTheDocument(); + // page.data.dateAndTimeFormat.dateOnly is mocked as "yyyy-MM-dd" in vitest-setup-client.ts + await expect.element(screen.getByText("2026-01-15", { exact: false })).toBeInTheDocument(); + await expect.element(screen.getByText("250ms")).toBeInTheDocument(); + }); + + it("reports a partial outage when downtime is below the 75% threshold", async () => { + const data = [day({ countOfUp: 100 }), day({ countOfDown: 20, countOfUp: 80 }), day({ countOfUp: 100 })]; + const screen = await render(StatusBarCalendar, { data, monitorTag: "test-monitor", disableClick: true }); + + await screen.getByLabelText("Status calendar showing 3-day uptime data").hover(); + + await expect.element(screen.getByText("Partial System Outage")).toBeInTheDocument(); + }); +}); diff --git a/src/lib/server/tool.test.ts b/src/lib/server/tool.test.ts new file mode 100644 index 00000000..6540ea45 --- /dev/null +++ b/src/lib/server/tool.test.ts @@ -0,0 +1,378 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BeginningOfDay, + BeginningOfMinute, + checkIfDuplicateExists, + DurationInMinutes, + GetDayStartTimestampUTC, + GetDayStartWithOffset, + GetMinuteStartNowTimestampUTC, + GetMinuteStartTimestampUTC, + GetNowTimestampUTC, + GetNowTimestampUTCInMs, + GetRequiredSecrets, + GetWordsStartingWithDollar, + HashString, + IsStringURLSafe, + IsValidHost, + IsValidHTTPMethod, + IsValidNameServer, + IsValidURL, + IsValidUptimeFormula, + MaskString, + ParsePercentage, + ParseUptime, + ReplaceAllOccurrences, + UnparsePercentage, + UptimeCalculator, + ValidateEmail, + ValidateIpAddress, + ValidateMonitorAlerts, + ValidateURL, +} from "./tool"; +import type { TimestampStatusCount } from "./types/db"; + +// The npm scripts pin TZ=UTC, so local-date-based functions (GetDayStartTimestampUTC +// mixes local date parts with Date.UTC) are deterministic here. Fixtures still use +// noon UTC so the tests stay safe even if vitest is run directly without the pin. +const NOON_UTC = 1768478400; // 2026-01-15T12:00:00Z +const DAY_START = 1768435200; // 2026-01-15T00:00:00Z + +describe("timestamp helpers", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("GetNowTimestampUTC returns the current epoch in seconds", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00Z")); + expect(GetNowTimestampUTC()).toBe(NOON_UTC); + }); + + it("GetNowTimestampUTCInMs returns the current epoch in milliseconds", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00Z")); + expect(GetNowTimestampUTCInMs()).toBe(NOON_UTC * 1000); + }); + + it("GetMinuteStartTimestampUTC floors a timestamp to the start of its minute", () => { + expect(GetMinuteStartTimestampUTC(NOON_UTC + 45)).toBe(NOON_UTC); + expect(GetMinuteStartTimestampUTC(NOON_UTC)).toBe(NOON_UTC); + expect(GetMinuteStartTimestampUTC(NOON_UTC + 59)).toBe(NOON_UTC); + }); + + it("GetMinuteStartNowTimestampUTC floors the current time to the minute", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:45Z")); + expect(GetMinuteStartNowTimestampUTC()).toBe(NOON_UTC); + }); + + it("GetDayStartTimestampUTC returns midnight UTC of the timestamp's day", () => { + expect(GetDayStartTimestampUTC(NOON_UTC)).toBe(DAY_START); + expect(GetDayStartTimestampUTC(DAY_START + 12 * 3600 + 61)).toBe(DAY_START); + }); + + it("DurationInMinutes floors the difference to whole minutes", () => { + expect(DurationInMinutes(0, 600)).toBe(10); + expect(DurationInMinutes(0, 59)).toBe(0); + expect(DurationInMinutes(DAY_START, NOON_UTC)).toBe(720); + }); + + it("GetDayStartWithOffset shifts the UTC day start by positive offsets", () => { + // 330 minutes = UTC+5:30 (e.g. IST) + expect(GetDayStartWithOffset(NOON_UTC, 330)).toBe(DAY_START + 330 * 60); + expect(GetDayStartWithOffset(NOON_UTC, 0)).toBe(DAY_START); + }); + + it("GetDayStartWithOffset moves to the next day for negative offsets", () => { + expect(GetDayStartWithOffset(NOON_UTC, -330)).toBe(DAY_START + 86400 - 330 * 60); + }); + + it("BeginningOfDay returns midnight in the given timezone", () => { + const date = new Date("2026-01-15T12:00:00Z"); + expect(BeginningOfDay({ date, timeZone: "UTC" })).toBe(DAY_START); + // Midnight in New York (UTC-5 in January) is 05:00 UTC + expect(BeginningOfDay({ date, timeZone: "America/New_York" })).toBe(DAY_START + 5 * 3600); + }); + + it("BeginningOfMinute strips seconds", () => { + const date = new Date("2026-01-15T12:00:45Z"); + expect(BeginningOfMinute({ date, timeZone: "UTC" })).toBe(NOON_UTC); + }); +}); + +describe("ParseUptime", () => { + it("returns '-' when there is no data", () => { + expect(ParseUptime(0, 0)).toBe("-"); + }); + + it("returns '0' when nothing is up", () => { + expect(ParseUptime(0, 10)).toBe("0"); + }); + + it("returns whole numbers without decimals", () => { + expect(ParseUptime(10, 10)).toBe("100"); + expect(ParseUptime(5, 10)).toBe("50"); + }); + + it("keeps 4 decimal places and strips trailing zeros", () => { + expect(ParseUptime(1, 3)).toBe("33.3333"); + expect(ParseUptime(2, 3)).toBe("66.6667"); + expect(ParseUptime(1, 8)).toBe("12.5"); + expect(ParseUptime(999, 1000)).toBe("99.9"); + }); +}); + +describe("ParsePercentage / UnparsePercentage", () => { + it("formats numbers as percentage strings", () => { + expect(ParsePercentage(NaN)).toBe("-"); + expect(ParsePercentage(0)).toBe("0"); + expect(ParsePercentage(100)).toBe("100"); + expect(ParsePercentage(99.9)).toBe("99.9000"); + }); + + it("parses percentage strings back to numbers", () => { + expect(UnparsePercentage("99.9")).toBe(99.9); + expect(UnparsePercentage("0")).toBe(0); + expect(Number.isNaN(UnparsePercentage("-"))).toBe(true); + }); +}); + +describe("IsValidUptimeFormula", () => { + it("accepts single variables and operator chains", () => { + expect(IsValidUptimeFormula("up")).toBe(true); + expect(IsValidUptimeFormula("up + down")).toBe(true); + expect(IsValidUptimeFormula("up+down+degraded+maintenance")).toBe(true); + expect(IsValidUptimeFormula("up - down * maintenance / degraded")).toBe(true); + }); + + it("is case-insensitive", () => { + expect(IsValidUptimeFormula("UP + Maintenance")).toBe(true); + }); + + it("rejects malformed formulas", () => { + expect(IsValidUptimeFormula("")).toBe(false); + expect(IsValidUptimeFormula(" ")).toBe(false); + expect(IsValidUptimeFormula("up +")).toBe(false); + expect(IsValidUptimeFormula("+ up")).toBe(false); + expect(IsValidUptimeFormula("banana + up")).toBe(false); + expect(IsValidUptimeFormula("up ** down")).toBe(false); + expect(IsValidUptimeFormula("up down")).toBe(false); + expect(IsValidUptimeFormula("42")).toBe(false); + }); +}); + +describe("UptimeCalculator", () => { + const day = (overrides: Partial): TimestampStatusCount => ({ + ts: 0, + countOfUp: 0, + countOfDown: 0, + countOfDegraded: 0, + countOfMaintenance: 0, + avgLatency: 0, + maxLatency: 0, + minLatency: 0, + ...overrides, + }); + + it("computes uptime and latency aggregates with the default formulas", () => { + // Defaults (global-constants): numerator "up + maintenance + degraded", + // denominator "up + down + degraded + maintenance" + const data = [ + day({ countOfUp: 30, countOfDown: 10, avgLatency: 100, maxLatency: 150, minLatency: 50 }), + day({ countOfUp: 50, countOfDegraded: 10, avgLatency: 200, maxLatency: 300, minLatency: 80 }), + ]; + expect(UptimeCalculator(data)).toEqual({ + uptime: "90", // (80 up + 0 maintenance + 10 degraded) / 100 total + avgLatency: "150ms", // (100 + 200) / 2 + maxLatency: "300ms", + minLatency: "50ms", + }); + }); + + it("supports a custom numerator formula", () => { + const data = [day({ countOfUp: 30, countOfDown: 10 }), day({ countOfUp: 50, countOfDegraded: 10 })]; + expect(UptimeCalculator(data, "up").uptime).toBe("80"); + }); + + it("falls back to the default formula when the custom one is invalid", () => { + const data = [day({ countOfUp: 30, countOfDown: 10 }), day({ countOfUp: 50, countOfDegraded: 10 })]; + expect(UptimeCalculator(data, "banana + up").uptime).toBe("90"); + }); + + it("returns '-' uptime and empty latencies for no data", () => { + expect(UptimeCalculator([])).toEqual({ + uptime: "-", + avgLatency: "", + maxLatency: "", + minLatency: "", + }); + }); + + it("only counts entries with a positive avgLatency toward the average", () => { + const data = [ + day({ countOfUp: 10, avgLatency: 0 }), + day({ countOfUp: 10, avgLatency: 100, maxLatency: 100, minLatency: 100 }), + ]; + const result = UptimeCalculator(data); + expect(result.uptime).toBe("100"); + expect(result.avgLatency).toBe("100ms"); // (0 + 100) / 1 entry with latency > 0 + }); +}); + +describe("ValidateIpAddress", () => { + it("classifies IPv4, IPv6, domain names, and invalid input", () => { + expect(ValidateIpAddress("192.168.1.1")).toBe("IPv4"); + expect(ValidateIpAddress("8.8.8.8")).toBe("IPv4"); + expect(ValidateIpAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).toBe("IPv6"); + expect(ValidateIpAddress("example.com")).toBe("Domain Name"); + expect(ValidateIpAddress("kener.ing")).toBe("Domain Name"); + expect(ValidateIpAddress("not valid")).toBe("Invalid"); + }); + + it("documents quirks: permissive IPv4 octets, no shortened IPv6", () => { + // The regex does not range-check octets: + expect(ValidateIpAddress("999.999.999.999")).toBe("IPv4"); + // Only full-form IPv6 is recognized: + expect(ValidateIpAddress("::1")).toBe("Invalid"); + }); +}); + +describe("host / nameserver / URL / method validators", () => { + it("IsValidHost accepts dotted domains with a TLD", () => { + expect(IsValidHost("example.com")).toBe(true); + expect(IsValidHost("sub.domain.co.uk")).toBe(true); + expect(IsValidHost("my-site.example.org")).toBe(true); + expect(IsValidHost("localhost")).toBe(false); + expect(IsValidHost("-bad.com")).toBe(false); + expect(IsValidHost("")).toBe(false); + }); + + it("IsValidNameServer accepts only dotted-quad IPs", () => { + expect(IsValidNameServer("8.8.8.8")).toBe(true); + expect(IsValidNameServer("example.com")).toBe(false); + expect(IsValidNameServer("8.8.8")).toBe(false); + }); + + it("IsValidURL requires http(s) and no spaces", () => { + expect(IsValidURL("https://example.com")).toBe(true); + expect(IsValidURL("http://example.com/path?q=1")).toBe(true); + expect(IsValidURL("ftp://example.com")).toBe(false); + expect(IsValidURL("https://exa mple.com")).toBe(false); + }); + + it("ValidateURL parses with the URL constructor and allows only http(s)", () => { + expect(ValidateURL("https://example.com")).toBe(true); + expect(ValidateURL("http://localhost:3000")).toBe(true); + expect(ValidateURL("ftp://example.com")).toBe(false); + expect(ValidateURL("javascript:alert(1)")).toBe(false); + expect(ValidateURL("not a url")).toBe(false); + }); + + it("IsValidHTTPMethod is uppercase-only", () => { + expect(IsValidHTTPMethod("GET")).toBe(true); + expect(IsValidHTTPMethod("PATCH")).toBe(true); + expect(IsValidHTTPMethod("get")).toBe(false); + expect(IsValidHTTPMethod("FETCH")).toBe(false); + }); + + it("IsStringURLSafe allows unreserved URL characters only", () => { + expect(IsStringURLSafe("abc-123_~.")).toBe(true); + expect(IsStringURLSafe("a b")).toBe(false); + expect(IsStringURLSafe("a/b")).toBe(false); + }); +}); + +describe("ValidateEmail", () => { + it("accepts common address shapes", () => { + expect(ValidateEmail("user@example.com")).toBe(true); + expect(ValidateEmail("first.last@sub.example.co")).toBe(true); + expect(ValidateEmail("user+tag@example.com")).toBe(true); + }); + + it("rejects malformed addresses", () => { + expect(ValidateEmail("no-at.example.com")).toBe(false); + expect(ValidateEmail("user@")).toBe(false); + expect(ValidateEmail("user@nodot")).toBe(false); + expect(ValidateEmail("@example.com")).toBe(false); + }); +}); + +describe("string helpers", () => { + it("MaskString masks everything but the last 4 characters", () => { + expect(MaskString("secret123")).toBe("*****t123"); + expect(MaskString("abcd")).toBe("****"); + expect(MaskString("ab")).toBe("**"); + expect(MaskString("")).toBe(""); + }); + + it("HashString returns the sha256 hex digest", () => { + expect(HashString("hello")).toBe("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + }); + + it("GetWordsStartingWithDollar extracts $-prefixed words", () => { + expect(GetWordsStartingWithDollar("run $FOO with $BAR_2")).toEqual(["$FOO", "$BAR_2"]); + expect(GetWordsStartingWithDollar("nothing here")).toEqual([]); + }); + + it("ReplaceAllOccurrences replaces every occurrence of a $-token", () => { + expect(ReplaceAllOccurrences("a $X b $X", "$X", "y")).toBe("a y b y"); + }); + + it("checkIfDuplicateExists detects duplicates", () => { + expect(checkIfDuplicateExists([1, 2, 2])).toBe(true); + expect(checkIfDuplicateExists(["a", "b"])).toBe(false); + }); +}); + +describe("GetRequiredSecrets", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("resolves $-tokens that exist in the environment", () => { + vi.stubEnv("KENER_TEST_SECRET", "s3cret"); + expect(GetRequiredSecrets("token: $KENER_TEST_SECRET")).toEqual([ + { find: "$KENER_TEST_SECRET", replace: "s3cret" }, + ]); + }); + + it("ignores tokens with no matching environment variable", () => { + expect(GetRequiredSecrets("token: $KENER_TEST_DEFINITELY_UNSET_VAR")).toEqual([]); + }); +}); + +describe("ValidateMonitorAlerts", () => { + beforeEach(() => { + // The function console.logs every rejection reason; keep test output clean. + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const validAlert = { triggers: [1], failureThreshold: 1, successThreshold: 2 }; + + it("accepts DOWN and/or DEGRADED alerts with triggers and integer thresholds", () => { + expect(ValidateMonitorAlerts({ DOWN: validAlert })).toBe(true); + expect(ValidateMonitorAlerts({ DOWN: validAlert, DEGRADED: validAlert })).toBe(true); + }); + + it("rejects missing/empty/unknown-key alert objects", () => { + expect(ValidateMonitorAlerts(null)).toBe(false); + expect(ValidateMonitorAlerts({})).toBe(false); + expect(ValidateMonitorAlerts({ UP: validAlert } as never)).toBe(false); + }); + + it("rejects missing or empty triggers", () => { + expect(ValidateMonitorAlerts({ DOWN: { failureThreshold: 1, successThreshold: 1 } })).toBe(false); + expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, triggers: [] } })).toBe(false); + }); + + it("rejects missing, non-integer, or non-positive thresholds", () => { + expect(ValidateMonitorAlerts({ DOWN: { triggers: [1] } })).toBe(false); + expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, failureThreshold: 1.5 } })).toBe(false); + expect(ValidateMonitorAlerts({ DOWN: { ...validAlert, successThreshold: 0 } })).toBe(false); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 5b76075d..6f556618 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,11 @@ +/// import tailwindcss from "@tailwindcss/vite"; import { sveltekit } from "@sveltejs/kit/vite"; import version from "vite-plugin-package-version"; import { defineConfig } from "vite"; import devtoolsJson from "vite-plugin-devtools-json"; +import { playwright } from "@vitest/browser-playwright"; +import { configDefaults } from "vitest/config"; import * as dotenv from "dotenv"; @@ -56,5 +59,40 @@ export default defineConfig(({ mode }) => { __KENER_BUILD_ENV__: JSON.stringify(buildEnv), __KENER_IS_PROD__: JSON.stringify(isProduction), }, + test: { + projects: [ + { + extends: true, + test: { + name: "server", + environment: "node", + include: ["src/**/*.{test,spec}.{js,ts}"], + exclude: [...configDefaults.exclude, "src/**/*.svelte.{test,spec}.{js,ts}"], + }, + }, + { + extends: true, + // Pre-bundle the component-test dependency graph: on a cold Vite cache + // (every CI runner) a mid-run dep-optimization reload can flake the suite. + optimizeDeps: { + include: ["layerchart", "mode-watcher", "bits-ui", "d3-scale", "d3-shape"], + }, + test: { + name: "client", + browser: { + enabled: true, + headless: true, + provider: playwright({ contextOptions: { timezoneId: "UTC" } }), + instances: [{ browser: "chromium" }], + // Vitest's default browser API port (63315) can fall inside Windows + // Hyper-V excluded TCP port ranges; pin below the ephemeral range. + api: { port: 5180 }, + }, + include: ["src/**/*.svelte.{test,spec}.{js,ts}"], + setupFiles: ["./vitest-setup-client.ts"], + }, + }, + ], + }, }; }); diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts new file mode 100644 index 00000000..bafa1c88 --- /dev/null +++ b/vitest-setup-client.ts @@ -0,0 +1,27 @@ +import { vi } from "vitest"; + +// Shared stubs for SvelteKit runtime modules used by components under test. +// Values are minimal but realistic; individual tests can override with their +// own vi.mock(...) when they need different page data. +vi.mock("$app/state", () => ({ + page: { + data: { + siteStatusColors: { UP: "#22c55e", DOWN: "#ef4444", DEGRADED: "#eab308", MAINTENANCE: "#3b82f6" }, + dateAndTimeFormat: { dateOnly: "yyyy-MM-dd", timeOnly: "HH:mm" }, + nowAtTz: 1768478400, // 2026-01-15T12:00:00Z + }, + url: new URL("http://localhost/"), + params: {}, + route: { id: null }, + status: 200, + error: null, + form: null, + state: {}, + }, +})); + +vi.mock("$app/paths", () => ({ + base: "", + assets: "", + resolve: (path: string) => path, +}));