mirror of
https://github.com/rajnandan1/kener.git
synced 2026-08-07 07:14:56 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 880080cea1 | |||
| 733f355e4b | |||
| 01cfd119d4 | |||
| 4394ca3668 | |||
| ee95642adb | |||
| 66bc99bf78 | |||
| a0ca4be3e4 | |||
| 833b7a51a4 | |||
| b0a4279273 | |||
| 067d4526a5 | |||
| 8420b2cc85 | |||
| 48e1744c85 | |||
| 9580f0ee78 | |||
| 4bd3668a5d | |||
| a6ce620e4a | |||
| 9d99a5c11f | |||
| bbc85deea7 | |||
| a937727d62 | |||
| 946f5ed49d | |||
| ec98c71108 | |||
| 65e346be6d | |||
| 9b2989cbfc | |||
| 385fb63d55 | |||
| 01a428ced9 | |||
| 3fc1d750fd | |||
| c40b41a8b9 | |||
| a7b5c2c171 | |||
| ae1586cde7 | |||
| 2dc46ab007 | |||
| e8008671d9 | |||
| 048518f2a8 | |||
| 180c963b67 | |||
| fca7624252 | |||
| 060d8d9c70 | |||
| 272b37047d | |||
| ce9d9dcfd0 |
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -12,16 +12,16 @@ env:
|
||||
DOCKERHUB_IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }}
|
||||
GITHUB_IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
concurrency:
|
||||
group: main-docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push-main:
|
||||
name: Build and push main Docker images (with docs)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant: [debian, alpine]
|
||||
concurrency:
|
||||
group: main-docker-${{ github.ref }}-${{ matrix.variant }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -12,6 +12,7 @@ config/server.yaml
|
||||
/package
|
||||
.env
|
||||
.vscode
|
||||
.idea
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
@@ -39,4 +40,5 @@ translation-report.json
|
||||
# AI workflow docs (not version-controlled)
|
||||
CONTEXT.md
|
||||
docs/adr/
|
||||
docs/superpowers/
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
@@ -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
|
||||
|
||||
Generated
+503
-1
@@ -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",
|
||||
|
||||
+9
-2
@@ -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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TimestampStatusCount } from "$lib/server/types/db";
|
||||
import GC, { PAGE_STATUS_MESSAGES, type StatusType } from "$lib/global-constants";
|
||||
import GC, { PAGE_STATUS_MESSAGES } from "$lib/global-constants";
|
||||
import type { StatusType } from "$lib/types/status";
|
||||
|
||||
function ParseLatency(latencyMs: number): string {
|
||||
if (!!!latencyMs) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -52,14 +52,14 @@
|
||||
</div>
|
||||
|
||||
{#if incident.monitors && incident.monitors.length > 0 && !hideMonitors}
|
||||
<div class="my-1 overflow-x-auto p-1">
|
||||
<div class="flex gap-2">
|
||||
<div class="my-1 p-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each incident.monitors as monitor (`${incident.id}-${monitor.monitor_tag}`)}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger disabled={isEmbedded}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal"
|
||||
class="border-{monitor.monitor_impact.toLowerCase()} max-w-full cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal wrap-anywhere whitespace-normal"
|
||||
>
|
||||
{monitor.monitor_name}
|
||||
</Badge>
|
||||
|
||||
@@ -52,13 +52,13 @@
|
||||
{/if}
|
||||
|
||||
{#if maintenance.monitors && maintenance.monitors.length > 0 && !hideMonitors}
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each maintenance.monitors as monitor}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger disabled={isEmbedded}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="border-{monitor.monitor_impact.toLowerCase()} cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal"
|
||||
class="border-{monitor.monitor_impact.toLowerCase()} max-w-full cursor-pointer rounded-none border-0 border-b px-0 text-sm font-normal wrap-anywhere whitespace-normal"
|
||||
>
|
||||
{monitor.monitor_name}
|
||||
</Badge>
|
||||
|
||||
@@ -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>): 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMonitoringStatus } from "./global-constants.js";
|
||||
|
||||
describe("isMonitoringStatus", () => {
|
||||
it("accepts only statuses supported by the monitoring filter", () => {
|
||||
expect(isMonitoringStatus("UP")).toBe(true);
|
||||
expect(isMonitoringStatus("DOWN")).toBe(true);
|
||||
expect(isMonitoringStatus("DEGRADED")).toBe(true);
|
||||
expect(isMonitoringStatus("MAINTENANCE")).toBe(false);
|
||||
expect(isMonitoringStatus("")).toBe(false);
|
||||
expect(isMonitoringStatus("invalid")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { MonitoringStatus } from "$lib/types/status.js";
|
||||
|
||||
// Badge styles supported by badge-maker
|
||||
export const BADGE_STYLES = ["flat", "plastic", "flat-square", "for-the-badge", "social"] as const;
|
||||
export type BadgeStyle = (typeof BADGE_STYLES)[number];
|
||||
|
||||
// Status types
|
||||
export type StatusType = "UP" | "DOWN" | "DEGRADED" | "MAINTENANCE" | "NO_DATA";
|
||||
export const MONITORING_STATUSES = ["UP", "DOWN", "DEGRADED"] as const satisfies readonly MonitoringStatus[];
|
||||
|
||||
export function isMonitoringStatus(value: unknown): value is MonitoringStatus {
|
||||
return typeof value === "string" && MONITORING_STATUSES.includes(value as MonitoringStatus);
|
||||
}
|
||||
|
||||
// Incident states
|
||||
export type IncidentState = "INVESTIGATING" | "IDENTIFIED" | "MONITORING" | "RESOLVED";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"Badges": "Rozetler",
|
||||
"CANCELLED": "İPTAL EDİLMİŞ",
|
||||
"COMPLETED": "TAMAMLANMIŞ",
|
||||
"Continue": "Devam etmek",
|
||||
"Continue": "Devam et",
|
||||
"Copied": "Kopyalandı",
|
||||
"Current": "Güncel",
|
||||
"Dark": "Karanlık",
|
||||
@@ -39,14 +39,14 @@
|
||||
"Get notified about incidents and scheduled maintenance.": "Olaylar ve planlı bakım hakkında bildirim alın.",
|
||||
"Get notified about incidents updates": "Olay güncellemeleri hakkında bildirim alın",
|
||||
"Get notified about scheduled maintenance": "Planlanmış bakım hakkında bildirim alın",
|
||||
"IDENTIFIED": "IDENTIFIED",
|
||||
"IDENTIFIED": "BELİRLENDİ",
|
||||
"iFrame": "iFrame",
|
||||
"Impact": "Darbe",
|
||||
"incident": "Olay",
|
||||
"Incident Updates": "Olay Güncellemeleri",
|
||||
"Incidents": "Olaylar",
|
||||
"Included Monitors (%count)": "Included Monitors (%count)",
|
||||
"INVESTIGATING": "INVESTIGATING",
|
||||
"INVESTIGATING": "ARAŞTIRILIYOR",
|
||||
"Last Updated": "Son Güncelleme",
|
||||
"Latency": "Gecikme",
|
||||
"Latency Embed": "Gecikme Yerleştirme",
|
||||
@@ -69,7 +69,7 @@
|
||||
"Min Latency": "Minimum Gecikme",
|
||||
"Minimum Latency": "Minimum Gecikme",
|
||||
"Minute-by-minute status data for this day": "Bu günün dakika dakika durum verileri",
|
||||
"MONITORING": "MONITORING",
|
||||
"MONITORING": "GÖZLEMLENİYOR",
|
||||
"Network error. Please try again.": "Ağ hatası. ",
|
||||
"No Events in %currentMonth": "%currentMonth Bölgesinde Etkinlik Yok",
|
||||
"No events to show": "Gösterilecek olay yok",
|
||||
@@ -99,7 +99,7 @@
|
||||
"Read more": "Devamını oku",
|
||||
"READY": "HAZIR",
|
||||
"Recurring": "Tekrarlayan",
|
||||
"RESOLVED": "RESOLVED",
|
||||
"RESOLVED": "ÇÖZÜLDÜ",
|
||||
"RSS feed": "RSS feed",
|
||||
"SCHEDULED": "PLANLANMIŞ",
|
||||
"Scheduled Events (%count)": "Planlanan etkinlikler (%count)",
|
||||
@@ -114,7 +114,7 @@
|
||||
"Status Badge": "Durum Rozeti",
|
||||
"Status Embed": "Durum Yerleştirme",
|
||||
"Status history and latency trend": "Durum geçmişi ve gecikme eğilimi",
|
||||
"Subscribe": "Abone",
|
||||
"Subscribe": "Abone Ol",
|
||||
"Subscribe to Updates": "Güncellemelere abone olun",
|
||||
"There are no incidents or maintenances scheduled for this month.": "Bu ay için planlanmış herhangi bir olay veya bakım bulunmamaktadır.",
|
||||
"There are no ongoing incidents or maintenance events.": "Devam eden olay veya bakım etkinliği yok.",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json, error } from "@sveltejs/kit";
|
||||
import type { APIServerRequest } from "$lib/server/types/api-server";
|
||||
import db from "$lib/server/db/db";
|
||||
import { GetMinuteStartNowTimestampUTC } from "$lib/server/tool";
|
||||
import type { StatusType } from "$lib/global-constants";
|
||||
import type { StatusType } from "$lib/types/status";
|
||||
import type { TimestampStatusCount } from "$lib/server/types/db";
|
||||
import { buildMonitorBarResponse } from "./shared";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import db from "$lib/server/db/db";
|
||||
import GC, { type StatusType } from "$lib/global-constants";
|
||||
import GC from "$lib/global-constants";
|
||||
import type { StatusType } from "$lib/types/status";
|
||||
import type { MonitorRecord, TimestampStatusCount } from "$lib/server/types/db";
|
||||
import { UptimeCalculator } from "$lib/server/tool";
|
||||
import type { MonitorBarResponse } from "./get";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json, error } from "@sveltejs/kit";
|
||||
import type { APIServerRequest } from "$lib/server/types/api-server";
|
||||
import db from "$lib/server/db/db";
|
||||
import { GetMinuteStartNowTimestampUTC } from "$lib/server/tool";
|
||||
import type { StatusType } from "$lib/global-constants";
|
||||
import type { StatusType } from "$lib/types/status";
|
||||
import GC from "$lib/global-constants";
|
||||
import type { MonitorBarResponse } from "$lib/server/api-server/monitor-bar/get";
|
||||
import { buildMonitorBarResponseFromRawData } from "$lib/server/api-server/monitor-bar/shared";
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { MonitorFilter } from "../db/repositories/base.js";
|
||||
import db from "../db/db.js";
|
||||
import type { PaginationInput } from "../../types/common.js";
|
||||
import GC, { getBadgeStyle, type BadgeStyle } from "../../global-constants.js";
|
||||
import type { MonitoringStatus } from "../../types/status.js";
|
||||
import { makeBadge } from "badge-maker";
|
||||
import { ErrorSvg } from "../../anywhere.js";
|
||||
import { GetLastMonitoringValue, SetLastHeartbeat, DeleteMonitorCaches } from "../cache/setGet.js";
|
||||
@@ -490,7 +491,7 @@ export const GetStatusCountsByInterval = async (
|
||||
export const GetMonitoringDataPaginated = async (
|
||||
page: number,
|
||||
limit: number,
|
||||
filter?: { monitor_tag?: string; start_time?: number; end_time?: number },
|
||||
filter?: { monitor_tag?: string; status?: MonitoringStatus; start_time?: number; end_time?: number },
|
||||
): Promise<{ data: MonitoringData[]; total: number }> => {
|
||||
const data = await db.getMonitoringDataPaginated(page, limit, filter);
|
||||
const countResult = await db.getMonitoringDataCount(filter);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Knex as KnexType } from "knex";
|
||||
import { BaseRepository } from "./base.js";
|
||||
import GC from "../../../global-constants.js";
|
||||
import type { MonitoringStatus } from "../../../types/status.js";
|
||||
import { GetMinuteStartNowTimestampUTC } from "../../tool.js";
|
||||
import type {
|
||||
MonitoringData,
|
||||
@@ -82,7 +83,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
async getMonitoringDataPaginated(
|
||||
page: number,
|
||||
limit: number,
|
||||
filter?: { monitor_tag?: string; start_time?: number; end_time?: number },
|
||||
filter?: { monitor_tag?: string; status?: MonitoringStatus; start_time?: number; end_time?: number },
|
||||
): Promise<MonitoringData[]> {
|
||||
let query = this.knex("monitoring_data").select("*");
|
||||
|
||||
@@ -90,6 +91,10 @@ export class MonitoringRepository extends BaseRepository {
|
||||
query = query.where("monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
|
||||
if (filter?.status) {
|
||||
query = query.where("status", filter.status);
|
||||
}
|
||||
|
||||
if (filter?.start_time) {
|
||||
query = query.where("timestamp", ">=", filter.start_time);
|
||||
}
|
||||
@@ -106,6 +111,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
|
||||
async getMonitoringDataCount(filter?: {
|
||||
monitor_tag?: string;
|
||||
status?: MonitoringStatus;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
}): Promise<{ count: number }> {
|
||||
@@ -115,6 +121,10 @@ export class MonitoringRepository extends BaseRepository {
|
||||
query = query.where("monitor_tag", filter.monitor_tag);
|
||||
}
|
||||
|
||||
if (filter?.status) {
|
||||
query = query.where("status", filter.status);
|
||||
}
|
||||
|
||||
if (filter?.start_time) {
|
||||
query = query.where("timestamp", ">=", filter.start_time);
|
||||
}
|
||||
@@ -476,7 +486,7 @@ export class MonitoringRepository extends BaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMonitorDataByTag(tag?: string, start?: number, end?: number): Promise<number> {
|
||||
async deleteMonitorDataByTag(tag?: string, start?: number, end?: number, status?: MonitoringStatus): Promise<number> {
|
||||
const query = this.knex("monitoring_data");
|
||||
if (tag) {
|
||||
query.where("monitor_tag", tag);
|
||||
@@ -487,6 +497,9 @@ export class MonitoringRepository extends BaseRepository {
|
||||
if (end !== undefined) {
|
||||
query.where("timestamp", "<=", end);
|
||||
}
|
||||
if (status) {
|
||||
query.where("status", status);
|
||||
}
|
||||
return await query.del();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@ export * from "$lib/types/api";
|
||||
export * from "$lib/types/monitor";
|
||||
export * from "$lib/types/result";
|
||||
export * from "$lib/types/common";
|
||||
export * from "$lib/types/status";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export type MonitoringStatus = "UP" | "DOWN" | "DEGRADED";
|
||||
export type StatusType = MonitoringStatus | "MAINTENANCE" | "NO_DATA";
|
||||
@@ -28,10 +28,11 @@ FREQ=frequency[;INTERVAL=n][;BYDAY=days][;BYMONTHDAY=day][;COUNT=n][;UNTIL=date]
|
||||
- Default: 1
|
||||
- Integer value: 2 = every other, 3 = every third, etc.
|
||||
|
||||
**BYDAY** (Optional, for WEEKLY)
|
||||
**BYDAY** (Optional, for WEEKLY or MONTHLY with an ordinal prefix)
|
||||
|
||||
- Day codes: `MO`, `TU`, `WE`, `TH`, `FR`, `SA`, `SU`
|
||||
- Multiple days: `MO,WE,FR`
|
||||
- With MONTHLY, prefix a day code with an ordinal to mean "the Nth such weekday of the month": `1WE` (first Wednesday), `1SU` (first Sunday)
|
||||
|
||||
**BYMONTHDAY** (Optional, for MONTHLY)
|
||||
|
||||
@@ -240,6 +241,29 @@ Occurrences:
|
||||
- Continues monthly...
|
||||
```
|
||||
|
||||
#### Nth Day-of-Week of Month (Ordinal BYDAY) {#first-weekday-of-month}
|
||||
|
||||
**RRULE:** `FREQ=MONTHLY;BYDAY=1WE`
|
||||
|
||||
**Description:** Maintenance occurs on the first Wednesday of each month
|
||||
|
||||
**Use Case:** Monthly patch windows tied to a specific weekday
|
||||
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Title: Monthly Patch Window
|
||||
Start: June 3, 2026 at 2:30 PM
|
||||
RRULE: FREQ=MONTHLY;BYDAY=1WE
|
||||
Duration: 90 minutes
|
||||
|
||||
Occurrences:
|
||||
- Jun 3, 2026 at 2:30 PM
|
||||
- Jul 1, 2026 at 2:30 PM
|
||||
- Aug 5, 2026 at 2:30 PM
|
||||
- Continues monthly...
|
||||
```
|
||||
|
||||
#### Middle of Month {#middle-of-month}
|
||||
|
||||
**RRULE:** `FREQ=MONTHLY;BYMONTHDAY=15`
|
||||
@@ -362,15 +386,16 @@ Affected Monitors:
|
||||
Title: Monthly Security Updates
|
||||
Description: Critical security patches and system updates
|
||||
Start Date: Sunday, June 4, 2026 at 2:00 AM
|
||||
RRULE: FREQ=MONTHLY;BYMONTHDAY=1
|
||||
RRULE: FREQ=MONTHLY;BYDAY=1SU
|
||||
Duration: 3600 seconds (1 hour)
|
||||
Affected Monitors:
|
||||
- All Servers: MAINTENANCE
|
||||
```
|
||||
|
||||
**Note:** Since RRULE doesn't support "first Sunday", use BYMONTHDAY=1 and choose first occurrence that falls on Sunday, or use BYDAY with ordinal (not supported in Kener UI currently).
|
||||
**Note:** Use ordinal weekday syntax in `BYDAY` for "first X weekday" patterns:
|
||||
|
||||
**Workaround:** Create as weekly and manage manually or use BYMONTHDAY for specific date.
|
||||
- First Sunday: `BYDAY=1SU`
|
||||
- First Wednesday: `BYDAY=1WE`
|
||||
|
||||
### Example 4: Business Hours Backup {#example-backup}
|
||||
|
||||
@@ -469,6 +494,7 @@ Start: 2:00 AM UTC
|
||||
✅ `FREQ=WEEKLY;BYDAY=MO,WE,FR`
|
||||
✅ `FREQ=WEEKLY;INTERVAL=2;BYDAY=SU`
|
||||
✅ `FREQ=MONTHLY;BYMONTHDAY=1`
|
||||
✅ `FREQ=MONTHLY;BYDAY=1WE`
|
||||
✅ `FREQ=MINUTELY;COUNT=1`
|
||||
|
||||
### Invalid RRULEs {#invalid-rrules}
|
||||
@@ -602,6 +628,7 @@ Description: |
|
||||
| Every Sunday | `FREQ=WEEKLY;BYDAY=SU` |
|
||||
| Bi-weekly Mondays | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO` |
|
||||
| First of month | `FREQ=MONTHLY;BYMONTHDAY=1` |
|
||||
| First Wednesday | `FREQ=MONTHLY;BYDAY=1WE` |
|
||||
| 15th of month | `FREQ=MONTHLY;BYMONTHDAY=15` |
|
||||
| Quarterly | `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1` |
|
||||
|
||||
@@ -619,4 +646,4 @@ Description: |
|
||||
|
||||
- [Creating and Managing Maintenances](/docs/v4/maintenances/creating-managing) - Apply these patterns in the dashboard
|
||||
- [Maintenance Events](/docs/v4/maintenances/events) - Understand how RRULE generates events
|
||||
- [Maintenance Overview](/docs/v4/maintenances/overview) - Learn maintenance fundamentals
|
||||
- [Maintenance Overview](/docs/v4/maintenances/overview) - Learn maintenance fundamentals
|
||||
@@ -1,7 +1,8 @@
|
||||
import { GetMonitors, GetLatestMonitoringData, GetLatestStatusActiveAll } from "$lib/server/controllers/controller.js";
|
||||
import StatusColor, { type StatusColors } from "$lib/color.js";
|
||||
import { ErrorSvg } from "$lib/anywhere.js";
|
||||
import GC, { type StatusType } from "$lib/global-constants.js";
|
||||
import GC from "$lib/global-constants.js";
|
||||
import type { StatusType } from "$lib/types/status.js";
|
||||
import { GetLastMonitoringValue } from "$lib/server/cache/setGet.js";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { format } from "date-fns";
|
||||
import sharp from "sharp";
|
||||
import { nanoid } from "nanoid";
|
||||
import db from "$lib/server/db/db";
|
||||
import GC from "$lib/global-constants.js";
|
||||
import GC, { isMonitoringStatus } from "$lib/global-constants.js";
|
||||
import type { MonitoringStatus } from "$lib/types/status.js";
|
||||
import {
|
||||
CreateUpdateMonitor,
|
||||
UpdateMonitoringData,
|
||||
@@ -219,7 +220,14 @@ export async function POST({ request, cookies }) {
|
||||
} else if (action == "deleteMonitor") {
|
||||
resp = await DeleteMonitorCompletelyUsingTag(data.tag);
|
||||
} else if (action == "deleteMonitorData") {
|
||||
await db.deleteMonitorDataByTag(data.tag || undefined, data.start, data.end);
|
||||
let status: MonitoringStatus | undefined;
|
||||
if (data.status !== undefined && data.status !== null && data.status !== "ALL") {
|
||||
if (!isMonitoringStatus(data.status)) {
|
||||
return json({ error: "Invalid monitoring status" }, { status: 400 });
|
||||
}
|
||||
status = data.status;
|
||||
}
|
||||
await db.deleteMonitorDataByTag(data.tag || undefined, data.start, data.end, status);
|
||||
resp = { success: true };
|
||||
} else if (action == "cloneMonitor") {
|
||||
resp = await CloneMonitor({
|
||||
@@ -245,10 +253,16 @@ export async function POST({ request, cookies }) {
|
||||
} else if (action == "getMonitoringDataPaginated") {
|
||||
const page = parseInt(String(data.page)) || 1;
|
||||
const limit = parseInt(String(data.limit)) || 50;
|
||||
const filter: { monitor_tag?: string; start_time?: number; end_time?: number } = {};
|
||||
const filter: { monitor_tag?: string; status?: MonitoringStatus; start_time?: number; end_time?: number } = {};
|
||||
if (data.monitor_tag && data.monitor_tag !== "ALL") {
|
||||
filter.monitor_tag = data.monitor_tag;
|
||||
}
|
||||
if (data.status !== undefined && data.status !== null && data.status !== "ALL") {
|
||||
if (!isMonitoringStatus(data.status)) {
|
||||
return json({ error: "Invalid monitoring status" }, { status: 400 });
|
||||
}
|
||||
filter.status = data.status;
|
||||
}
|
||||
if (data.start_time) {
|
||||
filter.start_time = parseInt(String(data.start_time));
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { format, formatDistanceToNow, isPast, isFuture, isWithinInterval, addDays } from "date-fns";
|
||||
import { format, formatDistanceToNow, isPast, isFuture, isWithinInterval } from "date-fns";
|
||||
import { rrulestr } from "rrule";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
@@ -193,9 +193,16 @@
|
||||
const dtstart = new Date(startDateTimeLocal);
|
||||
const fullRrule = `DTSTART:${dtstart.toISOString().replace(/[-:]/g, "").split(".")[0]}Z\nRRULE:${customRrule}`;
|
||||
const rule = rrulestr(fullRrule);
|
||||
const now = new Date();
|
||||
const windowEnd = addDays(now, 30);
|
||||
const occurrences = rule.between(now, windowEnd, true).slice(0, 5);
|
||||
const occurrences: Date[] = [];
|
||||
let searchFrom = new Date();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const next = rule.after(searchFrom, i === 0);
|
||||
if (!next) break;
|
||||
occurrences.push(next);
|
||||
searchFrom = next;
|
||||
}
|
||||
|
||||
return occurrences.map((d) => format(d, "EEE, MMM d, yyyy 'at' h:mm a"));
|
||||
} catch (e) {
|
||||
return [];
|
||||
@@ -980,4 +987,4 @@
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
</Dialog.Root>
|
||||
@@ -19,6 +19,8 @@
|
||||
import { toast } from "svelte-sonner";
|
||||
import { resolve } from "$app/paths";
|
||||
import clientResolver from "$lib/client/resolver.js";
|
||||
import GC, { isMonitoringStatus } from "$lib/global-constants";
|
||||
import type { MonitoringStatus } from "$lib/types/status.js";
|
||||
|
||||
// Types
|
||||
interface MonitoringData {
|
||||
@@ -35,6 +37,8 @@
|
||||
name: string;
|
||||
}
|
||||
|
||||
type StatusFilter = "ALL" | MonitoringStatus;
|
||||
|
||||
// Helper to format datetime as YYYY-MM-DDTHH:mm for datetime-local input
|
||||
function formatDateTimeForInput(date: Date): string {
|
||||
return format(date, "yyyy-MM-dd'T'HH:mm");
|
||||
@@ -68,12 +72,14 @@
|
||||
let pageNo = $state(1);
|
||||
let showFilters = $state(false);
|
||||
let monitorTagFilter = $state("ALL");
|
||||
let statusFilter = $state<StatusFilter>("ALL");
|
||||
let startDateTime = $state(formatDateTimeForInput(yesterday));
|
||||
let endDateTime = $state(formatDateTimeForInput(now));
|
||||
const limit = 50;
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
monitorTagFilter !== "ALL" ||
|
||||
statusFilter !== "ALL" ||
|
||||
startDateTime !== formatDateTimeForInput(yesterday) ||
|
||||
endDateTime !== formatDateTimeForInput(now)
|
||||
);
|
||||
@@ -113,6 +119,7 @@
|
||||
|
||||
function clearFilters() {
|
||||
monitorTagFilter = "ALL";
|
||||
statusFilter = "ALL";
|
||||
startDateTime = formatDateTimeForInput(yesterday);
|
||||
endDateTime = formatDateTimeForInput(now);
|
||||
pageNo = 1;
|
||||
@@ -139,6 +146,7 @@
|
||||
action: "deleteMonitorData",
|
||||
data: {
|
||||
tag: monitorTagFilter === "ALL" ? "" : monitorTagFilter,
|
||||
status: statusFilter === "ALL" ? undefined : statusFilter,
|
||||
start: startTs,
|
||||
end: endTs
|
||||
}
|
||||
@@ -189,12 +197,14 @@
|
||||
page: number;
|
||||
limit: number;
|
||||
monitor_tag: string;
|
||||
status: StatusFilter;
|
||||
start_time?: number;
|
||||
end_time?: number;
|
||||
} = {
|
||||
page: pageNo,
|
||||
limit,
|
||||
monitor_tag: monitorTagFilter
|
||||
monitor_tag: monitorTagFilter,
|
||||
status: statusFilter
|
||||
};
|
||||
|
||||
if (startDateTime) {
|
||||
@@ -241,6 +251,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(value: string | undefined) {
|
||||
if (value === "ALL" || isMonitoringStatus(value)) {
|
||||
statusFilter = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
function goToPage(page: number) {
|
||||
pageNo = page;
|
||||
@@ -304,6 +320,20 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs font-medium">Status</span>
|
||||
<Select.Root type="single" value={statusFilter} onValueChange={handleStatusChange}>
|
||||
<Select.Trigger class="w-36" aria-label="Status">
|
||||
{statusFilter === "ALL" ? "All Statuses" : statusFilter}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="ALL">All Statuses</Select.Item>
|
||||
<Select.Item value={GC.UP}>UP</Select.Item>
|
||||
<Select.Item value={GC.DOWN}>DOWN</Select.Item>
|
||||
<Select.Item value={GC.DEGRADED}>DEGRADED</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Button size="sm" onclick={applyFilters}>
|
||||
<SearchIcon class="size-4" />
|
||||
Search
|
||||
@@ -437,11 +467,16 @@
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Monitoring Data</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will delete monitoring data for
|
||||
{#if monitorTagFilter === "ALL"}
|
||||
This will delete monitoring data for <strong>all monitors</strong> from {startDateTime} to {endDateTime}.
|
||||
<strong>all monitors</strong>
|
||||
{:else}
|
||||
This will delete monitoring data for <strong>{monitorTagFilter}</strong> from {startDateTime} to {endDateTime}.
|
||||
<strong>{monitorTagFilter}</strong>
|
||||
{/if}
|
||||
{#if statusFilter !== "ALL"}
|
||||
with status <strong>{statusFilter}</strong>
|
||||
{/if}
|
||||
from {startDateTime} to {endDateTime}.
|
||||
This action cannot be undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/// <reference types="vitest/config" />
|
||||
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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
Reference in New Issue
Block a user