mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-11-09 19:35:56 +00:00
Merge pull request #94 from jcreek/chore/test-suite-hardening
Chore/test suite hardening
This commit is contained in:
@@ -7,3 +7,16 @@ GOOGLE_OAUTH_CLIENT_ID="your-google-client-id"
|
|||||||
GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret"
|
GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret"
|
||||||
DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id"
|
DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id"
|
||||||
DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret"
|
DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret"
|
||||||
|
|
||||||
|
# Endpoint overrides for deterministic local provider tests. They are ignored unless
|
||||||
|
# ALLOW_PROVIDER_ENDPOINT_OVERRIDES is exactly "true", the local BDD stack variables are present,
|
||||||
|
# and every URL is loopback. These endpoints receive OAuth secrets, so never set this in a deployed
|
||||||
|
# environment. `npm run test:bdd` supplies the complete test context.
|
||||||
|
ALLOW_PROVIDER_ENDPOINT_OVERRIDES=""
|
||||||
|
GOOGLE_OAUTH_AUTHORIZE_URL=""
|
||||||
|
GOOGLE_OAUTH_TOKEN_URL=""
|
||||||
|
GOOGLE_DRIVE_API_URL=""
|
||||||
|
GOOGLE_DRIVE_UPLOAD_URL=""
|
||||||
|
DROPBOX_OAUTH_AUTHORIZE_URL=""
|
||||||
|
DROPBOX_OAUTH_TOKEN_URL=""
|
||||||
|
DROPBOX_UPLOAD_URL=""
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: tests-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# `$env/static/public` is resolved at build time, so every variable imported from it must be
|
||||||
|
# present for `vite build` and `svelte-check` to succeed on a clean checkout. These are the
|
||||||
|
# local-stack defaults already published in .env.local.example - never real credentials.
|
||||||
|
env:
|
||||||
|
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'false'
|
||||||
|
PUBLIC_SUPABASE_URL: http://127.0.0.1:54321
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- name: Check committed whitespace
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
git diff --check "${{ github.event.pull_request.base.sha }}...HEAD"
|
||||||
|
else
|
||||||
|
git diff --check "HEAD^...HEAD"
|
||||||
|
fi
|
||||||
|
- run: npm run check
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run test:fast
|
||||||
|
- name: Verify tests leave the checkout clean
|
||||||
|
run: test -z "$(git status --porcelain --untracked-files=all)"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: coverage
|
||||||
|
path: coverage/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
# `supabase start` applies migrations and seeds; no separate reset is needed.
|
||||||
|
- run: npx supabase start
|
||||||
|
- run: npm run test:integration
|
||||||
|
- if: always()
|
||||||
|
run: npx supabase stop
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:build
|
||||||
|
|
||||||
|
bdd:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
playwright-chromium-${{ runner.os }}-
|
||||||
|
- run: npx playwright install --with-deps chromium
|
||||||
|
- run: npx supabase start
|
||||||
|
- run: npm run test:bdd
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-failures
|
||||||
|
path: |
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
- if: always()
|
||||||
|
run: npx supabase stop
|
||||||
@@ -10,3 +10,7 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
/static/output.css
|
/static/output.css
|
||||||
.netlify
|
.netlify
|
||||||
|
.features-gen
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
|||||||
@@ -2,3 +2,13 @@
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Generated data exports. Nobody hand-edits these, and reformatting them buries real data
|
||||||
|
# changes under tens of thousands of lines of churn.
|
||||||
|
src/lib/helpers/pokeapi-pokemon.json
|
||||||
|
static/sprites/pokemon.json
|
||||||
|
src/lib/helpers/pokedex.json
|
||||||
|
src/lib/helpers/sprites.json
|
||||||
|
|
||||||
|
# Machine-local editor and tool settings.
|
||||||
|
**/*.local.json
|
||||||
|
|||||||
@@ -31,6 +31,76 @@ To create a production version:
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The test suite is split by responsibility so a failure points to the correct layer:
|
||||||
|
|
||||||
|
- `tests/unit` contains fast, isolated tests for utilities, repositories, and services.
|
||||||
|
- `tests/data` validates the tracked Pokémon, game, region, dex, and sprite reference files.
|
||||||
|
- `tests/integration` checks the migrated Supabase schema, views, constraints, RLS, and repositories.
|
||||||
|
- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step
|
||||||
|
definitions and browser fixtures live beside it under `tests/bdd`.
|
||||||
|
- `tests/build` verifies generated service-worker and manifest artifacts after each supported build.
|
||||||
|
|
||||||
|
Run the offline suites while developing. `test:fast` includes the coverage run, so there is no need
|
||||||
|
to run both:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:fast
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage is measured across all of `src/lib` (excluding type-only models and the browser-only
|
||||||
|
store/action modules) with global thresholds set to the current baseline, so new untested code lowers
|
||||||
|
the number instead of being invisible to the gate. Ratchet the thresholds in `vitest.config.mts` up as
|
||||||
|
coverage grows, never down.
|
||||||
|
|
||||||
|
Database and BDD tests require Docker and the local Supabase stack. The wrappers read the local keys
|
||||||
|
from `supabase status` at run time, so no keys are hard-coded in the test files; the local stack's
|
||||||
|
well-known demo keys do appear in `.env.local.example`, and no real credentials are committed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run supabase:start
|
||||||
|
npm run supabase:reset
|
||||||
|
npm run test:integration
|
||||||
|
npm run test:bdd
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` runs the complete CI-equivalent sequence and fails with setup instructions when Supabase is
|
||||||
|
not available. Individual layers are available as `test:unit`, `test:data`, `test:integration`,
|
||||||
|
`test:build`, and `test:bdd`.
|
||||||
|
|
||||||
|
Gherkin describes outcomes in domain language. Keep selectors, API calls, test-user provisioning, and
|
||||||
|
provider mocks in step definitions or support fixtures. `@product-review` marks a rule that should be
|
||||||
|
reviewed with product stakeholders, but does not skip it. Missing or ambiguous steps fail generation.
|
||||||
|
|
||||||
|
Google Drive and Dropbox scenarios use a local provider server (`scripts/mock-provider-server.mjs`)
|
||||||
|
and never contact real provider accounts. The endpoint overrides that point at it are refused unless
|
||||||
|
`ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true`, the local test-stack/service-role variables are present,
|
||||||
|
and the override is a loopback URL. These endpoints receive the OAuth client secret and refresh
|
||||||
|
token, so they must not be redirectable in a deployed environment. `npm run test:bdd` supplies the
|
||||||
|
complete test context.
|
||||||
|
|
||||||
|
The mock's recorded requests, refresh counter and fail-uploads switch are reset before every scenario
|
||||||
|
by an auto fixture in `tests/bdd/fixtures.ts`. That reset is also why the suite runs with a single
|
||||||
|
worker: the mock is one shared process, so parallel scenarios would reset each other's state. A global
|
||||||
|
teardown deletes the users each run creates, so repeated local runs do not need a database reset.
|
||||||
|
|
||||||
|
Chromium is the only configured browser project. Playwright traces and screenshots are retained on
|
||||||
|
failure under `test-results`.
|
||||||
|
|
||||||
|
Password-reset scenarios follow recovery links generated by the local Supabase stack and verify both
|
||||||
|
the rejected old password and accepted replacement password. The application waits for Supabase to
|
||||||
|
confirm the recovery session before enabling the replacement form.
|
||||||
|
|
||||||
|
After sign-in, the application automatically stores a versioned, per-user read-only copy of every
|
||||||
|
Pokédex and its referenced artwork. Offline navigation opens a static viewer; all mutation and
|
||||||
|
authentication controls remain unavailable until connectivity returns. A successful sign-out removes
|
||||||
|
the user-specific snapshot and artwork caches from the device.
|
||||||
|
|
||||||
|
The current National Dex maximum is deliberately asserted as 1025. When adding a new generation,
|
||||||
|
update that expectation together with Pokémon data, the corresponding game/dex files, database seed,
|
||||||
|
and sprites. Data tests print the exact conflicting identities or broken references.
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
## Sprites
|
## Sprites
|
||||||
|
|||||||
+17
-4
@@ -1,7 +1,20 @@
|
|||||||
import process from 'node:process'
|
import process from 'node:process';
|
||||||
import AdapterNode from '@sveltejs/adapter-node';
|
import AdapterNode from '@sveltejs/adapter-node';
|
||||||
import AdpaterStatic from '@sveltejs/adapter-static';
|
import AdapterNetlify from '@sveltejs/adapter-netlify';
|
||||||
|
|
||||||
export const nodeAdapter = process.env.NODE_ADAPTER === 'true'
|
export const nodeAdapter = process.env.NODE_ADAPTER === 'true';
|
||||||
|
|
||||||
export const adapter = nodeAdapter ? AdapterNode() : AdpaterStatic()
|
// Netlify is the deployment target; the node adapter exists so the service worker
|
||||||
|
// build tests can check the `build/client` layout a Node server produces.
|
||||||
|
export const adapter = nodeAdapter
|
||||||
|
? AdapterNode()
|
||||||
|
: AdapterNetlify({
|
||||||
|
// if true, will create a Netlify Edge Function rather
|
||||||
|
// than using standard Node-based functions
|
||||||
|
edge: false,
|
||||||
|
|
||||||
|
// if true, will split your app into multiple functions
|
||||||
|
// instead of creating a single one for the entire app.
|
||||||
|
// if `edge` is true, this option cannot be used
|
||||||
|
split: false
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
|
|
||||||
test('Test offline and trailing slashes', async ({ browser }) => {
|
|
||||||
// test offline + trailing slashes routes
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const offlinePage = await context.newPage();
|
|
||||||
await offlinePage.goto('/');
|
|
||||||
const offlineSwURL = await offlinePage.evaluate(async () => {
|
|
||||||
const registration = await Promise.race([
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
navigator.serviceWorker.ready,
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
// @ts-expect-error registration is of type unknown
|
|
||||||
return registration.active?.scriptURL;
|
|
||||||
});
|
|
||||||
const offlineSwName = 'sw.js';
|
|
||||||
expect(offlineSwURL).toBe(`http://localhost:4173/${offlineSwName}`);
|
|
||||||
await context.setOffline(true);
|
|
||||||
const aboutAnchor = offlinePage.getByRole('link', { name: 'About' });
|
|
||||||
expect(await aboutAnchor.getAttribute('href')).toBe('/about');
|
|
||||||
await aboutAnchor.click({ noWaitAfter: false });
|
|
||||||
const url = await offlinePage.evaluate(async () => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
return location.href;
|
|
||||||
});
|
|
||||||
expect(url).toBe('http://localhost:4173/about');
|
|
||||||
expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy();
|
|
||||||
await offlinePage.reload({ waitUntil: 'load' });
|
|
||||||
expect(offlinePage.url()).toBe('http://localhost:4173/about');
|
|
||||||
expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy();
|
|
||||||
// Dispose context once it's no longer needed.
|
|
||||||
await context.close();
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
|
|
||||||
test('The service worker is registered and cache storage is present', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
const swURL = await page.evaluate(async () => {
|
|
||||||
const registration = await Promise.race([
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
navigator.serviceWorker.ready,
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
// @ts-expect-error registration is of type unknown
|
|
||||||
return registration.active?.scriptURL;
|
|
||||||
});
|
|
||||||
const swName = 'sw.js';
|
|
||||||
expect(swURL).toBe(`http://localhost:4173/${swName}`);
|
|
||||||
|
|
||||||
const cacheContents = await page.evaluate(async () => {
|
|
||||||
const cacheState: Record<string, Array<string>> = {};
|
|
||||||
for (const cacheName of await caches.keys()) {
|
|
||||||
const cache = await caches.open(cacheName);
|
|
||||||
cacheState[cacheName] = (await cache.keys()).map((req) => req.url);
|
|
||||||
}
|
|
||||||
return cacheState;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Object.keys(cacheContents).length).toEqual(1);
|
|
||||||
|
|
||||||
const key = 'workbox-precache-v2-http://localhost:4173/';
|
|
||||||
|
|
||||||
expect(Object.keys(cacheContents)[0]).toEqual(key);
|
|
||||||
|
|
||||||
const urls = cacheContents[key].map((url) => url.slice('http://localhost:4173/'.length));
|
|
||||||
|
|
||||||
/*
|
|
||||||
'http://localhost:4173/about?__WB_REVISION__=38251751d310c9b683a1426c22c135a2',
|
|
||||||
'http://localhost:4173/?__WB_REVISION__=073370aa3804305a787b01180cd6b8aa',
|
|
||||||
'http://localhost:4173/manifest.webmanifest?__WB_REVISION__=27df2fa4f35d014b42361148a2207da3'
|
|
||||||
*/
|
|
||||||
expect(urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__='))).toEqual(true);
|
|
||||||
expect(urls.some((url) => url.startsWith('?__WB_REVISION__='))).toEqual(true);
|
|
||||||
expect(urls.some((url) => url.startsWith('about?__WB_REVISION__='))).toEqual(true);
|
|
||||||
// dontCacheBustURLsMatching: any asset in _app/immutable folder shouldn't have a revision (?__WB_REVISION__=)
|
|
||||||
expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.css'))).toEqual(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.js'))).toEqual(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(urls.some((url) => url.includes('_app/version.json?__WB_REVISION__='))).toEqual(true);
|
|
||||||
});
|
|
||||||
@@ -59,7 +59,7 @@ dexNumber,pokemon,form,notes
|
|||||||
58,Pyroar,,
|
58,Pyroar,,
|
||||||
59,Psyduck,,
|
59,Psyduck,,
|
||||||
60,Golduck,,
|
60,Golduck,,
|
||||||
61,Farfetch'd,,
|
61,Farfetch’d,,
|
||||||
62,Riolu,,
|
62,Riolu,,
|
||||||
63,Lucario,,
|
63,Lucario,,
|
||||||
64,Ralts,,
|
64,Ralts,,
|
||||||
|
|||||||
|
Generated
+543
-26
@@ -13,7 +13,7 @@
|
|||||||
"nanoid": "^5.0.4"
|
"nanoid": "^5.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.37.1",
|
"@playwright/test": "1.55.1",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-netlify": "^4.1.0",
|
"@sveltejs/adapter-netlify": "^4.1.0",
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
@@ -26,11 +26,13 @@
|
|||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"@vite-pwa/assets-generator": "^0.2.4",
|
"@vite-pwa/assets-generator": "^0.2.4",
|
||||||
"@vite-pwa/sveltekit": "^0.4.0",
|
"@vite-pwa/sveltekit": "^0.4.0",
|
||||||
|
"@vitest/coverage-v8": "^1.6.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"daisyui": "^4.10.1",
|
"daisyui": "^4.10.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.35.1",
|
"eslint-plugin-svelte": "^2.35.1",
|
||||||
|
"playwright-bdd": "^8.5.1",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
@@ -509,7 +511,6 @@
|
|||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -520,7 +521,6 @@
|
|||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -571,7 +571,6 @@
|
|||||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.28.5"
|
"@babel/types": "^7.28.5"
|
||||||
},
|
},
|
||||||
@@ -1938,7 +1937,6 @@
|
|||||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.27.1",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
@@ -1947,12 +1945,175 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "0.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
|
||||||
|
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@canvas/image-data": {
|
"node_modules/@canvas/image-data": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.0.0.tgz",
|
||||||
"integrity": "sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==",
|
"integrity": "sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@colors/colors": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.90"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/cucumber-expressions": {
|
||||||
|
"version": "18.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-18.0.1.tgz",
|
||||||
|
"integrity": "sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"regexp-match-indices": "1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/gherkin": {
|
||||||
|
"version": "32.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz",
|
||||||
|
"integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cucumber/messages": ">=19.1.4 <28"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/gherkin-utils": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cucumber/gherkin": "^31.0.0",
|
||||||
|
"@cucumber/messages": "^27.0.0",
|
||||||
|
"@teppeis/multimaps": "3.0.0",
|
||||||
|
"commander": "13.1.0",
|
||||||
|
"source-map-support": "^0.5.21"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gherkin-utils": "bin/gherkin-utils"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": {
|
||||||
|
"version": "31.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz",
|
||||||
|
"integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cucumber/messages": ">=19.1.4 <=26"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": {
|
||||||
|
"version": "26.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz",
|
||||||
|
"integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/uuid": "10.0.0",
|
||||||
|
"class-transformer": "0.5.1",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"uuid": "10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/gherkin-utils/node_modules/commander": {
|
||||||
|
"version": "13.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||||
|
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/gherkin-utils/node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/html-formatter": {
|
||||||
|
"version": "21.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.15.1.tgz",
|
||||||
|
"integrity": "sha512-tjxEpP161sQ7xc3VREc94v1ymwIckR3ySViy7lTvfi1jUpyqy2Hd/p4oE3YT1kQ9fFDvUflPwu5ugK5mA7BQLA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@cucumber/messages": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/junit-xml-formatter": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cucumber/query": "^13.0.2",
|
||||||
|
"@teppeis/multimaps": "^3.0.0",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
|
"xmlbuilder": "^15.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@cucumber/messages": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/messages": {
|
||||||
|
"version": "27.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz",
|
||||||
|
"integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/uuid": "10.0.0",
|
||||||
|
"class-transformer": "0.5.1",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"uuid": "11.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/query": {
|
||||||
|
"version": "13.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz",
|
||||||
|
"integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@teppeis/multimaps": "3.0.0",
|
||||||
|
"lodash.sortby": "^4.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@cucumber/messages": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cucumber/tag-expressions": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
@@ -2970,6 +3131,16 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@istanbuljs/schema": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jest/schemas": {
|
"node_modules/@jest/schemas": {
|
||||||
"version": "29.6.3",
|
"version": "29.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
|
||||||
@@ -3089,13 +3260,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.57.0",
|
"version": "1.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz",
|
||||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
"integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.57.0"
|
"playwright": "1.55.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3822,6 +3993,16 @@
|
|||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@teppeis/multimaps": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
@@ -3894,6 +4075,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -4171,6 +4359,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ampproject/remapping": "^2.2.1",
|
||||||
|
"@bcoe/v8-coverage": "^0.2.3",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-lib-source-maps": "^5.0.4",
|
||||||
|
"istanbul-reports": "^3.1.6",
|
||||||
|
"magic-string": "^0.30.5",
|
||||||
|
"magicast": "^0.3.3",
|
||||||
|
"picocolors": "^1.0.0",
|
||||||
|
"std-env": "^3.5.0",
|
||||||
|
"strip-literal": "^2.0.0",
|
||||||
|
"test-exclude": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vitest": "1.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
|
||||||
@@ -4855,8 +5071,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/builtin-modules": {
|
"node_modules/builtin-modules": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
@@ -5028,6 +5243,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/class-transformer": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cli-table3": {
|
||||||
|
"version": "0.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
|
||||||
|
"integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "10.* || >= 12.*"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@colors/colors": "1.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cli-table3/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/cli-table3/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cmd-shim": {
|
"node_modules/cmd-shim": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
|
||||||
@@ -6094,16 +6354,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
|
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodelib/fs.stat": "^2.0.2",
|
"@nodelib/fs.stat": "^2.0.2",
|
||||||
"@nodelib/fs.walk": "^1.2.3",
|
"@nodelib/fs.walk": "^1.2.3",
|
||||||
"glob-parent": "^5.1.2",
|
"glob-parent": "^5.1.2",
|
||||||
"merge2": "^1.3.0",
|
"merge2": "^1.3.0",
|
||||||
"micromatch": "^4.0.4"
|
"micromatch": "^4.0.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6.0"
|
"node": ">=8.6.0"
|
||||||
@@ -6704,6 +6965,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
@@ -7223,6 +7491,60 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-source-maps": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.23",
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"istanbul-lib-coverage": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jackspeak": {
|
"node_modules/jackspeak": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
@@ -7537,8 +7859,7 @@
|
|||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||||
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
|
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "2.3.7",
|
"version": "2.3.7",
|
||||||
@@ -7550,6 +7871,16 @@
|
|||||||
"get-func-name": "^2.0.1"
|
"get-func-name": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||||
@@ -7562,6 +7893,34 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.25.4",
|
||||||
|
"@babel/types": "^7.25.4",
|
||||||
|
"source-map-js": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mdn-data": {
|
"node_modules/mdn-data": {
|
||||||
"version": "2.0.30",
|
"version": "2.0.30",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||||
@@ -7597,6 +7956,33 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "^1.54.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mimic-fn": {
|
"node_modules/mimic-fn": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
|
||||||
@@ -8186,13 +8572,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.57.0",
|
"version": "1.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.57.0"
|
"playwright-core": "1.55.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -8204,10 +8590,53 @@
|
|||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright-bdd": {
|
||||||
|
"version": "8.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-8.5.1.tgz",
|
||||||
|
"integrity": "sha512-lDNaDzW8RvbvsKuR8cZaP9LBnRbG9juCOE3tgwm3pr1O0W1ooGPz7X8xH7zdUbqGgHbdOQ+5XpUTlOJrvpY6Tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cucumber/cucumber-expressions": "18.0.1",
|
||||||
|
"@cucumber/gherkin": "^32.1.2",
|
||||||
|
"@cucumber/gherkin-utils": "^9.2.0",
|
||||||
|
"@cucumber/html-formatter": "^21.11.0",
|
||||||
|
"@cucumber/junit-xml-formatter": "^0.7.1",
|
||||||
|
"@cucumber/messages": "^27.2.0",
|
||||||
|
"@cucumber/tag-expressions": "^6.2.0",
|
||||||
|
"cli-table3": "0.6.5",
|
||||||
|
"commander": "^13.1.0",
|
||||||
|
"fast-glob": "^3.3.3",
|
||||||
|
"mime-types": "^3.0.2",
|
||||||
|
"xmlbuilder": "15.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"bddgen": "dist/cli/index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/vitalets"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@playwright/test": ">=1.44"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-bdd/node_modules/commander": {
|
||||||
|
"version": "13.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||||
|
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.57.0",
|
"version": "1.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
||||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8715,6 +9144,13 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reflect-metadata": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/regenerate": {
|
"node_modules/regenerate": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
|
||||||
@@ -8745,6 +9181,26 @@
|
|||||||
"@babel/runtime": "^7.8.4"
|
"@babel/runtime": "^7.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regexp-match-indices": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"regexp-tree": "^0.1.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/regexp-tree": {
|
||||||
|
"version": "0.1.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
|
||||||
|
"integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"regexp-tree": "bin/regexp-tree"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.2",
|
"version": "1.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
|
||||||
@@ -9319,7 +9775,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-from": "^1.0.0",
|
"buffer-from": "^1.0.0",
|
||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
@@ -9330,7 +9785,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -10141,6 +10595,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/test-exclude": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@istanbuljs/schema": "^0.1.2",
|
||||||
|
"glob": "^7.1.4",
|
||||||
|
"minimatch": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||||
|
"version": "1.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0",
|
||||||
|
"concat-map": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/test-exclude/node_modules/minimatch": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^1.1.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/text-decoder": {
|
"node_modules/text-decoder": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
|
||||||
@@ -10569,6 +11062,20 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -11811,6 +12318,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "15.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "1.10.2",
|
"version": "1.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||||
|
|||||||
+24
-11
@@ -7,11 +7,12 @@
|
|||||||
"dev-generate": "GENERATE_SW=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
"dev-generate": "GENERATE_SW=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||||
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
"dev-generate-suppress-w": "GENERATE_SW=true SUPPRESS_WARNING=true npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
|
||||||
"sprites:build": "node scripts/optimize-sprites.mjs",
|
"sprites:build": "node scripts/optimize-sprites.mjs",
|
||||||
"build-generate-sw": "GENERATE_SW=true vite build",
|
"build-generate-sw": "npm run tailwind && GENERATE_SW=true vite build",
|
||||||
"build-generate-sw-node": "NODE_ADAPTER=true GENERATE_SW=true vite build",
|
"build-generate-sw-node": "npm run tailwind && NODE_ADAPTER=true GENERATE_SW=true vite build",
|
||||||
"build": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite build",
|
"build": "npm run tailwind && vite build",
|
||||||
"build-inject-manifest-node": "NODE_ADAPTER=true vite build",
|
"build-inject-manifest": "npm run tailwind && vite build",
|
||||||
"build-self-destroying": "SELF_DESTROYING_SW=true vite build",
|
"build-inject-manifest-node": "npm run tailwind && NODE_ADAPTER=true vite build",
|
||||||
|
"build-self-destroying": "npm run tailwind && SELF_DESTROYING_SW=true vite build",
|
||||||
"preview": "vite preview --port=4173",
|
"preview": "vite preview --port=4173",
|
||||||
"preview-node": "PORT=4173 node build",
|
"preview-node": "PORT=4173 node build",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
@@ -20,11 +21,21 @@
|
|||||||
"lint-fix": "npm run lint --fix",
|
"lint-fix": "npm run lint --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css",
|
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css",
|
||||||
"test-generate-sw": "npm run build-generate-sw && GENERATE_SW=true vitest run && GENERATE_SW=true playwright test",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test-generate-sw-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run && NODE_ADAPTER=true GENERATE_SW=true playwright test",
|
"test:data": "vitest run tests/data",
|
||||||
"test-inject-manifest": "npm run build-inject-manifest && vitest run && playwright test",
|
"test:coverage": "vitest run tests/unit --coverage",
|
||||||
"test-inject-manifest-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run && NODE_ADAPTER=true playwright test",
|
"test:fast": "npm run test:coverage && npm run test:data",
|
||||||
"test": "npm run test-generate-sw && npm run test-generate-sw-node && npm run test-inject-manifest && npm run test-inject-manifest-node",
|
"test:integration": "node scripts/run-with-local-supabase.mjs vitest run --config vitest.integration.config.mts",
|
||||||
|
"test:bdd:generate": "bddgen",
|
||||||
|
"test:bdd:inner": "npm run build-inject-manifest && npm run test:bdd:generate && playwright test --project=chromium",
|
||||||
|
"test:bdd": "ALLOW_PROVIDER_ENDPOINT_OVERRIDES=true GOOGLE_OAUTH_CLIENT_ID=mock-google GOOGLE_OAUTH_CLIENT_SECRET=mock-google-secret DROPBOX_OAUTH_CLIENT_ID=mock-dropbox DROPBOX_OAUTH_CLIENT_SECRET=mock-dropbox-secret GOOGLE_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/google/authorize GOOGLE_OAUTH_TOKEN_URL=http://127.0.0.1:4199/google/token GOOGLE_DRIVE_API_URL=http://127.0.0.1:4199/google/drive GOOGLE_DRIVE_UPLOAD_URL=http://127.0.0.1:4199/google/upload DROPBOX_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/dropbox/authorize DROPBOX_OAUTH_TOKEN_URL=http://127.0.0.1:4199/dropbox/token DROPBOX_UPLOAD_URL=http://127.0.0.1:4199/dropbox/files/upload node scripts/run-with-local-supabase.mjs npm run test:bdd:inner",
|
||||||
|
"test:build:generate-static": "npm run build-generate-sw && GENERATE_SW=true vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build:generate-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build:inject-static": "npm run build-inject-manifest && vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build:inject-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run --config vitest.build.config.mts",
|
||||||
|
"test:build": "npm run test:build:generate-static && npm run test:build:generate-node && npm run test:build:inject-static && npm run test:build:inject-node",
|
||||||
|
"test:ci": "npm run test:fast && npm run test:integration && npm run test:build && npm run test:bdd",
|
||||||
|
"test": "npm run test:ci",
|
||||||
"supabase:start": "supabase start",
|
"supabase:start": "supabase start",
|
||||||
"supabase:stop": "supabase stop",
|
"supabase:stop": "supabase stop",
|
||||||
"supabase:reset": "supabase db reset",
|
"supabase:reset": "supabase db reset",
|
||||||
@@ -34,7 +45,7 @@
|
|||||||
"dev:supabase": "supabase start && npm run dev"
|
"dev:supabase": "supabase start && npm run dev"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.37.1",
|
"@playwright/test": "1.55.1",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/adapter-netlify": "^4.1.0",
|
"@sveltejs/adapter-netlify": "^4.1.0",
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
@@ -47,11 +58,13 @@
|
|||||||
"@typescript-eslint/parser": "^7.0.0",
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"@vite-pwa/assets-generator": "^0.2.4",
|
"@vite-pwa/assets-generator": "^0.2.4",
|
||||||
"@vite-pwa/sveltekit": "^0.4.0",
|
"@vite-pwa/sveltekit": "^0.4.0",
|
||||||
|
"@vitest/coverage-v8": "^1.6.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"daisyui": "^4.10.1",
|
"daisyui": "^4.10.1",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.35.1",
|
"eslint-plugin-svelte": "^2.35.1",
|
||||||
|
"playwright-bdd": "^8.5.1",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
|
|||||||
+90
-73
@@ -1,10 +1,18 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { defineBddConfig } from 'playwright-bdd';
|
||||||
|
|
||||||
const url = 'http://localhost:4173'
|
const url = 'http://localhost:4173';
|
||||||
|
|
||||||
|
const testDir = defineBddConfig({
|
||||||
|
features: 'tests/bdd/features/**/*.feature',
|
||||||
|
steps: ['tests/bdd/steps/**/*.ts', 'tests/bdd/fixtures.ts'],
|
||||||
|
outputDir: '.features-gen',
|
||||||
|
missingSteps: 'fail-on-gen'
|
||||||
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { nodeAdapter } from './adapter.mjs'
|
import { nodeAdapter } from './adapter.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@@ -16,81 +24,90 @@ import { nodeAdapter } from './adapter.mjs'
|
|||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './client-test',
|
testDir,
|
||||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
globalTeardown: './tests/bdd/globalTeardown.ts',
|
||||||
outputDir: 'test-results/',
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
timeout: 5 * 1000,
|
outputDir: 'test-results/',
|
||||||
expect: {
|
timeout: 90 * 1000,
|
||||||
/**
|
expect: {
|
||||||
* Maximum time expect() should wait for the condition to be met.
|
/**
|
||||||
* For example in `await expect(locator).toHaveText();`
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
*/
|
* For example in `await expect(locator).toHaveText();`
|
||||||
timeout: 1000,
|
*/
|
||||||
},
|
timeout: 10 * 1000
|
||||||
/* Run tests in files in parallel */
|
},
|
||||||
fullyParallel: true,
|
/* Run tests in files in parallel */
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
/* Retry on CI only */
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 0,
|
/* Retry on CI only */
|
||||||
/* Opt out of parallel tests on CI. */
|
retries: 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
/* Opt out of parallel tests on CI. */
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
workers: 1,
|
||||||
reporter: 'line',
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
reporter: [['line'], ['./tests/support/no-skips-reporter.ts']],
|
||||||
use: {
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
use: {
|
||||||
actionTimeout: 0,
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
actionTimeout: 0,
|
||||||
baseURL: url,
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
//offline: true,
|
baseURL: url,
|
||||||
|
//offline: true,
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'retain-on-failure',
|
||||||
},
|
screenshot: 'only-on-failure'
|
||||||
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] }
|
||||||
},
|
}
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// name: 'firefox',
|
// name: 'firefox',
|
||||||
// use: { ...devices['Desktop Firefox'] },
|
// use: { ...devices['Desktop Firefox'] },
|
||||||
// },
|
// },
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// name: 'webkit',
|
// name: 'webkit',
|
||||||
// use: { ...devices['Desktop Safari'] },
|
// use: { ...devices['Desktop Safari'] },
|
||||||
// },
|
// },
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
/* Test against mobile viewports. */
|
||||||
// {
|
// {
|
||||||
// name: 'Mobile Chrome',
|
// name: 'Mobile Chrome',
|
||||||
// use: { ...devices['Pixel 5'] },
|
// use: { ...devices['Pixel 5'] },
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// name: 'Mobile Safari',
|
// name: 'Mobile Safari',
|
||||||
// use: { ...devices['iPhone 12'] },
|
// use: { ...devices['iPhone 12'] },
|
||||||
// },
|
// },
|
||||||
|
|
||||||
/* Test against branded browsers. */
|
/* Test against branded browsers. */
|
||||||
// {
|
// {
|
||||||
// name: 'Microsoft Edge',
|
// name: 'Microsoft Edge',
|
||||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// name: 'Google Chrome',
|
// name: 'Google Chrome',
|
||||||
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
|
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: [
|
||||||
command: nodeAdapter ? 'pnpm run preview-node' : 'pnpm run preview',
|
{
|
||||||
url,
|
command: 'node scripts/mock-provider-server.mjs',
|
||||||
reuseExistingServer: !process.env.CI,
|
url: 'http://127.0.0.1:4199/__mock/state',
|
||||||
},
|
reuseExistingServer: !process.env.CI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: nodeAdapter ? 'npm run preview-node' : 'npm run preview',
|
||||||
|
url,
|
||||||
|
reuseExistingServer: !process.env.CI
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
-7717
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {}
|
||||||
},
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import process from 'node:process'
|
import process from 'node:process';
|
||||||
|
|
||||||
export const generateSW = process.env.GENERATE_SW === 'true'
|
export const generateSW = process.env.GENERATE_SW === 'true';
|
||||||
|
|||||||
+133
-119
@@ -17,167 +17,181 @@ const __dirname = path.dirname(__filename);
|
|||||||
* Parse CSV file into array of objects
|
* Parse CSV file into array of objects
|
||||||
*/
|
*/
|
||||||
function parseCSV(filePath) {
|
function parseCSV(filePath) {
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
const lines = content.split('\n').filter(line => line.trim());
|
const lines = content.split('\n').filter((line) => line.trim());
|
||||||
const headers = lines[0].split(',');
|
const headers = lines[0].split(',');
|
||||||
|
|
||||||
return lines.slice(1).map(line => {
|
return lines.slice(1).map((line) => {
|
||||||
const values = parseCSVLine(line);
|
const values = parseCSVLine(line);
|
||||||
const obj = {};
|
const obj = {};
|
||||||
headers.forEach((header, i) => {
|
headers.forEach((header, i) => {
|
||||||
obj[header] = values[i] || null;
|
obj[header] = values[i] || null;
|
||||||
});
|
});
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a single CSV line, handling quoted values
|
* Parse a single CSV line, handling quoted values
|
||||||
*/
|
*/
|
||||||
function parseCSVLine(line) {
|
function parseCSVLine(line) {
|
||||||
const values = [];
|
const values = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
let inQuotes = false;
|
let inQuotes = false;
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
for (let i = 0; i < line.length; i++) {
|
||||||
const char = line[i];
|
const char = line[i];
|
||||||
|
|
||||||
if (char === '"') {
|
if (char === '"') {
|
||||||
inQuotes = !inQuotes;
|
inQuotes = !inQuotes;
|
||||||
} else if (char === ',' && !inQuotes) {
|
} else if (char === ',' && !inQuotes) {
|
||||||
values.push(current);
|
values.push(current);
|
||||||
current = '';
|
current = '';
|
||||||
} else {
|
} else {
|
||||||
current += char;
|
current += char;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
values.push(current);
|
values.push(current);
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate migration SQL from CSV data
|
* Generate migration SQL from CSV data
|
||||||
*/
|
*/
|
||||||
function generateMigration(region) {
|
function generateMigration(region) {
|
||||||
console.log(`\nGenerating migration for ${region}...`);
|
console.log(`\nGenerating migration for ${region}...`);
|
||||||
|
|
||||||
// Determine generation number from region
|
// Determine generation number from region
|
||||||
const regionToGen = {
|
const regionToGen = {
|
||||||
'Kanto': 1, 'Johto': 2, 'Hoenn': 3, 'Sinnoh': 4,
|
Kanto: 1,
|
||||||
'Unova': 5, 'Kalos': 6, 'Alola': 7, 'Galar': 8,
|
Johto: 2,
|
||||||
'Hisui': 8, 'Paldea': 9
|
Hoenn: 3,
|
||||||
};
|
Sinnoh: 4,
|
||||||
const gen = regionToGen[region] || 1;
|
Unova: 5,
|
||||||
|
Kalos: 6,
|
||||||
|
Alola: 7,
|
||||||
|
Galar: 8,
|
||||||
|
Hisui: 8,
|
||||||
|
Paldea: 9
|
||||||
|
};
|
||||||
|
const gen = regionToGen[region] || 1;
|
||||||
|
|
||||||
// 1. Load CSV files
|
// 1. Load CSV files
|
||||||
const pokemonPath = path.join(__dirname, '..', 'data', 'pokemon', `gen${gen}-${region.toLowerCase()}.csv`);
|
const pokemonPath = path.join(
|
||||||
const gamesPath = path.join(__dirname, '..', 'data', 'pokemon', 'games.csv');
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'data',
|
||||||
|
'pokemon',
|
||||||
|
`gen${gen}-${region.toLowerCase()}.csv`
|
||||||
|
);
|
||||||
|
const gamesPath = path.join(__dirname, '..', 'data', 'pokemon', 'games.csv');
|
||||||
|
|
||||||
if (!fs.existsSync(pokemonPath)) {
|
if (!fs.existsSync(pokemonPath)) {
|
||||||
console.error(`Error: Pokemon CSV not found at ${pokemonPath}`);
|
console.error(`Error: Pokemon CSV not found at ${pokemonPath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pokemon = parseCSV(pokemonPath);
|
const pokemon = parseCSV(pokemonPath);
|
||||||
const games = parseCSV(gamesPath);
|
const games = parseCSV(gamesPath);
|
||||||
|
|
||||||
// 2. Filter for this region
|
// 2. Filter for this region
|
||||||
const regionGames = games.filter(g => g.region === region);
|
const regionGames = games.filter((g) => g.region === region);
|
||||||
|
|
||||||
if (regionGames.length === 0) {
|
if (regionGames.length === 0) {
|
||||||
console.error(`Error: No games found for region ${region} in games.csv`);
|
console.error(`Error: No games found for region ${region} in games.csv`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate SQL
|
// 3. Generate SQL
|
||||||
let sql = `-- Seed ${region} region Pokémon (Gen ${gen})\n`;
|
let sql = `-- Seed ${region} region Pokémon (Gen ${gen})\n`;
|
||||||
sql += `-- Auto-generated from CSV files\n\n`;
|
sql += `-- Auto-generated from CSV files\n\n`;
|
||||||
|
|
||||||
// Region-game mappings
|
// Region-game mappings
|
||||||
sql += `-- Insert ${region} region-game mappings\n`;
|
sql += `-- Insert ${region} region-game mappings\n`;
|
||||||
sql += `INSERT INTO region_game_mappings (region, game) VALUES\n`;
|
sql += `INSERT INTO region_game_mappings (region, game) VALUES\n`;
|
||||||
sql += regionGames.map(g => ` ('${region}', '${g.displayName}')`).join(',\n');
|
sql += regionGames.map((g) => ` ('${region}', '${g.displayName}')`).join(',\n');
|
||||||
sql += `\nON CONFLICT (region, game) DO NOTHING;\n\n`;
|
sql += `\nON CONFLICT (region, game) DO NOTHING;\n\n`;
|
||||||
|
|
||||||
// Pokemon entries (without regional dex number)
|
// Pokemon entries (without regional dex number)
|
||||||
sql += `-- Insert ${region} Pokémon entries\n`;
|
sql += `-- Insert ${region} Pokémon entries\n`;
|
||||||
sql += `INSERT INTO pokedex_entries (\n`;
|
sql += `INSERT INTO pokedex_entries (\n`;
|
||||||
sql += ` "pokedexNumber",\n`;
|
sql += ` "pokedexNumber",\n`;
|
||||||
sql += ` pokemon,\n`;
|
sql += ` pokemon,\n`;
|
||||||
sql += ` form,\n`;
|
sql += ` form,\n`;
|
||||||
sql += ` "canGigantamax",\n`;
|
sql += ` "canGigantamax",\n`;
|
||||||
sql += ` "regionToCatchIn",\n`;
|
sql += ` "regionToCatchIn",\n`;
|
||||||
sql += ` "gamesToCatchIn"\n`;
|
sql += ` "gamesToCatchIn"\n`;
|
||||||
sql += `) VALUES\n`;
|
sql += `) VALUES\n`;
|
||||||
|
|
||||||
const rows = pokemon.map(p => {
|
const rows = pokemon.map((p) => {
|
||||||
const form = p.form ? `'${p.form}'` : 'NULL';
|
const form = p.form ? `'${p.form}'` : 'NULL';
|
||||||
// Use regionalDexGames for the database (regional dex availability)
|
// Use regionalDexGames for the database (regional dex availability)
|
||||||
// originGames column is for future origin dex feature
|
// originGames column is for future origin dex feature
|
||||||
const gamesField = p.regionalDexGames || p.games; // Fallback to old 'games' column for compatibility
|
const gamesField = p.regionalDexGames || p.games; // Fallback to old 'games' column for compatibility
|
||||||
const gamesList = gamesField.split('|');
|
const gamesList = gamesField.split('|');
|
||||||
const gamesArray = `ARRAY[${gamesList.map(g => `'${g}'`).join(', ')}]`;
|
const gamesArray = `ARRAY[${gamesList.map((g) => `'${g}'`).join(', ')}]`;
|
||||||
|
|
||||||
return `(${p.pokedexNumber}, '${p.name}', ${form}, false, '${region}', ${gamesArray})`;
|
return `(${p.pokedexNumber}, '${p.name}', ${form}, false, '${region}', ${gamesArray})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
sql += rows.join(',\n');
|
sql += rows.join(',\n');
|
||||||
sql += '\nON CONFLICT ON CONSTRAINT unique_pokemon_form DO NOTHING;\n\n';
|
sql += '\nON CONFLICT ON CONSTRAINT unique_pokemon_form DO NOTHING;\n\n';
|
||||||
|
|
||||||
// Regional dex numbers (separate table)
|
// Regional dex numbers (separate table)
|
||||||
sql += `-- Insert ${region} regional dex numbers\n`;
|
sql += `-- Insert ${region} regional dex numbers\n`;
|
||||||
|
|
||||||
const dexRows = pokemon
|
const dexRows = pokemon
|
||||||
.filter(p => p.regionalNumber) // Only entries with regional dex numbers
|
.filter((p) => p.regionalNumber) // Only entries with regional dex numbers
|
||||||
.map(p => {
|
.map((p) => {
|
||||||
const formCondition = p.form
|
const formCondition = p.form ? `form = '${p.form}'` : `form IS NULL`;
|
||||||
? `form = '${p.form}'`
|
|
||||||
: `form IS NULL`;
|
|
||||||
|
|
||||||
return ` ((SELECT id FROM pokedex_entries WHERE "pokedexNumber" = ${p.pokedexNumber} AND ${formCondition}), '${region}', ${p.regionalNumber})`;
|
return ` ((SELECT id FROM pokedex_entries WHERE "pokedexNumber" = ${p.pokedexNumber} AND ${formCondition}), '${region}', ${p.regionalNumber})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dexRows.length > 0) {
|
if (dexRows.length > 0) {
|
||||||
sql += `INSERT INTO regional_dex_numbers (\n`;
|
sql += `INSERT INTO regional_dex_numbers (\n`;
|
||||||
sql += ` pokedex_entry_id,\n`;
|
sql += ` pokedex_entry_id,\n`;
|
||||||
sql += ` region,\n`;
|
sql += ` region,\n`;
|
||||||
sql += ` dex_number\n`;
|
sql += ` dex_number\n`;
|
||||||
sql += `) VALUES\n`;
|
sql += `) VALUES\n`;
|
||||||
sql += dexRows.join(',\n');
|
sql += dexRows.join(',\n');
|
||||||
sql += ';\n\n';
|
sql += ';\n\n';
|
||||||
} else {
|
} else {
|
||||||
sql += '-- No regional dex numbers for this region\n\n';
|
sql += '-- No regional dex numbers for this region\n\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add metadata
|
// Add metadata
|
||||||
sql += `-- Add metadata\n`;
|
sql += `-- Add metadata\n`;
|
||||||
sql += `INSERT INTO metadata (key, value) VALUES\n`;
|
sql += `INSERT INTO metadata (key, value) VALUES\n`;
|
||||||
sql += ` ('${region.toLowerCase()}_seeded', 'true'),\n`;
|
sql += ` ('${region.toLowerCase()}_seeded', 'true'),\n`;
|
||||||
sql += ` ('${region.toLowerCase()}_seed_date', NOW()::TEXT),\n`;
|
sql += ` ('${region.toLowerCase()}_seed_date', NOW()::TEXT),\n`;
|
||||||
sql += ` ('${region.toLowerCase()}_pokemon_count', '${pokemon.filter(p => !p.form).length}');\n`;
|
sql += ` ('${region.toLowerCase()}_pokemon_count', '${pokemon.filter((p) => !p.form).length}');\n`;
|
||||||
|
|
||||||
// 4. Write file
|
// 4. Write file
|
||||||
const timestamp = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14);
|
const timestamp = new Date()
|
||||||
const filename = `${timestamp}_seed_${region.toLowerCase()}.sql`;
|
.toISOString()
|
||||||
const outputPath = path.join(__dirname, '..', 'supabase', 'migrations', filename);
|
.replace(/[-:T.]/g, '')
|
||||||
|
.slice(0, 14);
|
||||||
|
const filename = `${timestamp}_seed_${region.toLowerCase()}.sql`;
|
||||||
|
const outputPath = path.join(__dirname, '..', 'supabase', 'migrations', filename);
|
||||||
|
|
||||||
fs.writeFileSync(outputPath, sql);
|
fs.writeFileSync(outputPath, sql);
|
||||||
|
|
||||||
console.log(`✓ Generated ${filename}`);
|
console.log(`✓ Generated ${filename}`);
|
||||||
console.log(` - ${pokemon.length} Pokemon entries`);
|
console.log(` - ${pokemon.length} Pokemon entries`);
|
||||||
console.log(` - ${dexRows.length} regional dex numbers`);
|
console.log(` - ${dexRows.length} regional dex numbers`);
|
||||||
console.log(` - ${regionGames.length} games\n`);
|
console.log(` - ${regionGames.length} games\n`);
|
||||||
|
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run
|
// Run
|
||||||
const region = process.argv[2];
|
const region = process.argv[2];
|
||||||
if (!region) {
|
if (!region) {
|
||||||
console.error('Usage: node csv-to-migration.js <Region>');
|
console.error('Usage: node csv-to-migration.js <Region>');
|
||||||
console.error('Example: node csv-to-migration.js Kanto');
|
console.error('Example: node csv-to-migration.js Kanto');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateMigration(region);
|
generateMigration(region);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
|
const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199);
|
||||||
|
const state = { requests: [], failUploads: false, refreshes: 0 };
|
||||||
|
|
||||||
|
function send(response, status, body, headers = {}) {
|
||||||
|
response.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
||||||
|
response.end(typeof body === 'string' ? body : JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) body += chunk;
|
||||||
|
|
||||||
|
// Control-plane calls (including Playwright's webServer readiness polling of /__mock/state)
|
||||||
|
// must not show up as provider traffic the assertions then reason about.
|
||||||
|
if (!url.pathname.startsWith('/__mock/')) {
|
||||||
|
state.requests.push({ method: request.method, path: url.pathname, query: url.search, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/__mock/state') return send(response, 200, state);
|
||||||
|
if (url.pathname === '/__mock/reset') {
|
||||||
|
state.requests = [];
|
||||||
|
state.failUploads = false;
|
||||||
|
state.refreshes = 0;
|
||||||
|
return send(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if (url.pathname === '/__mock/fail-uploads') {
|
||||||
|
state.failUploads = true;
|
||||||
|
return send(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith('/authorize')) {
|
||||||
|
const redirectUri = url.searchParams.get('redirect_uri');
|
||||||
|
const oauthState = url.searchParams.get('state');
|
||||||
|
if (!redirectUri || !oauthState) return send(response, 400, { error: 'missing redirect data' });
|
||||||
|
const callback = new URL(redirectUri);
|
||||||
|
callback.searchParams.set('code', 'mock-authorization-code');
|
||||||
|
callback.searchParams.set('state', oauthState);
|
||||||
|
response.writeHead(302, { Location: callback.toString() });
|
||||||
|
return response.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith('/token')) {
|
||||||
|
if (body.includes('grant_type=refresh_token')) state.refreshes++;
|
||||||
|
return send(response, 200, {
|
||||||
|
access_token: 'mock-access-token',
|
||||||
|
refresh_token: 'mock-refresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'files.content.write drive.file'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.includes('/upload') || url.pathname.endsWith('/files/upload')) {
|
||||||
|
if (state.failUploads) return send(response, 503, { error: 'mock upload failure' });
|
||||||
|
return send(response, 200, { id: 'mock-file-id', name: 'pokedex.csv' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.endsWith('/drive/files')) {
|
||||||
|
if (request.method === 'GET') return send(response, 200, { files: [{ id: 'mock-folder-id' }] });
|
||||||
|
return send(response, 200, { id: 'mock-folder-id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return send(response, 404, { error: `No mock route for ${url.pathname}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
console.log(`Mock provider server listening on http://127.0.0.1:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||||
|
process.on(signal, () => server.close(() => process.exit(0)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const [, , command, ...args] = process.argv;
|
||||||
|
if (!command) {
|
||||||
|
console.error('Usage: node scripts/run-with-local-supabase.mjs <command> [...args]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETUP_HINT = 'Run "npm run supabase:start" followed by "npm run supabase:reset".';
|
||||||
|
// npx is a shell script on Windows, where spawn needs a shell to find it.
|
||||||
|
const useShell = process.platform === 'win32';
|
||||||
|
|
||||||
|
function fail(message, detail) {
|
||||||
|
console.error(message);
|
||||||
|
if (detail) console.error(String(detail).trim());
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = spawnSync('npx', ['supabase', 'status', '--output', 'json'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
shell: useShell
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status.error) {
|
||||||
|
fail(
|
||||||
|
'Unable to run "npx supabase status" - is the supabase CLI installed?',
|
||||||
|
status.error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status !== 0) {
|
||||||
|
const stderr = status.stderr ?? '';
|
||||||
|
// Distinguish a stopped stack from a genuinely broken CLI invocation, so the hint is only
|
||||||
|
// printed when it is actually the advice the reader needs.
|
||||||
|
if (/not running|supabase start/i.test(stderr)) {
|
||||||
|
fail(`Local Supabase is required but is not running.\n${SETUP_HINT}`, stderr);
|
||||||
|
}
|
||||||
|
fail(`"supabase status" failed with exit code ${status.status}.`, stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
let values;
|
||||||
|
try {
|
||||||
|
values = JSON.parse(status.stdout);
|
||||||
|
} catch (error) {
|
||||||
|
fail('Unable to parse "supabase status --output json".', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = values.API_URL ?? values.api_url ?? 'http://127.0.0.1:54321';
|
||||||
|
const anonKey = values.ANON_KEY ?? values.PUBLISHABLE_KEY ?? values.anon_key;
|
||||||
|
const serviceRoleKey = values.SERVICE_ROLE_KEY ?? values.SECRET_KEY ?? values.service_role_key;
|
||||||
|
|
||||||
|
if (!anonKey || !serviceRoleKey) {
|
||||||
|
fail(`Supabase status did not return an anonymous and service-role key.\n${SETUP_HINT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackUrl(value) {
|
||||||
|
try {
|
||||||
|
return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(value).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackUrl(apiUrl)) {
|
||||||
|
fail(`Refusing to run local-stack tests against non-loopback Supabase URL: ${apiUrl}`);
|
||||||
|
}
|
||||||
|
const providerUrlVariables = [
|
||||||
|
'MOCK_PROVIDER_URL',
|
||||||
|
'GOOGLE_OAUTH_AUTHORIZE_URL',
|
||||||
|
'GOOGLE_OAUTH_TOKEN_URL',
|
||||||
|
'GOOGLE_DRIVE_API_URL',
|
||||||
|
'GOOGLE_DRIVE_UPLOAD_URL',
|
||||||
|
'DROPBOX_OAUTH_AUTHORIZE_URL',
|
||||||
|
'DROPBOX_OAUTH_TOKEN_URL',
|
||||||
|
'DROPBOX_UPLOAD_URL'
|
||||||
|
];
|
||||||
|
for (const name of providerUrlVariables) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value && !isLoopbackUrl(value)) {
|
||||||
|
fail(`Refusing to run provider tests with non-loopback ${name}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A running-but-unseeded database is the most common broken state, and it surfaces downstream as
|
||||||
|
// a confusing assertion failure. Check it here instead.
|
||||||
|
const probe = await fetch(`${apiUrl}/rest/v1/pokedex_entries?select=id&limit=1`, {
|
||||||
|
headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` }
|
||||||
|
}).catch((error) => {
|
||||||
|
fail(`Unable to reach the local Supabase REST API at ${apiUrl}.\n${SETUP_HINT}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!probe.ok) {
|
||||||
|
fail(
|
||||||
|
`The local Supabase database has no readable pokedex_entries (HTTP ${probe.status}).\n${SETUP_HINT}`,
|
||||||
|
await probe.text()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (((await probe.json()) ?? []).length === 0) {
|
||||||
|
fail(`The local Supabase database is empty - migrations or seeds have not run.\n${SETUP_HINT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawnSync(command, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: useShell,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Never allow an exported production value to redirect a local integration run.
|
||||||
|
PUBLIC_SUPABASE_URL: apiUrl,
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY: anonKey,
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
||||||
|
TEST_SUPABASE_URL: apiUrl,
|
||||||
|
TEST_SUPABASE_ANON_KEY: anonKey,
|
||||||
|
E2E_SERVICE_ROLE_KEY: serviceRoleKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (child.error) fail(`Unable to run "${command}".`, child.error.message);
|
||||||
|
// A signalled child reports status === null; exiting 0 there would hide the failure.
|
||||||
|
process.exit(child.signal ? 1 : child.status ?? 1);
|
||||||
+4
-3
@@ -1,21 +1,22 @@
|
|||||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import type { CookieSerializeOptions } from 'cookie';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
cookies: {
|
cookies: {
|
||||||
get: (key) => event.cookies.get(key),
|
get: (key: string) => event.cookies.get(key),
|
||||||
/**
|
/**
|
||||||
* Note: You have to add the `path` variable to the
|
* Note: You have to add the `path` variable to the
|
||||||
* set and remove method due to sveltekit's cookie API
|
* set and remove method due to sveltekit's cookie API
|
||||||
* requiring this to be set, setting the path to an empty string
|
* requiring this to be set, setting the path to an empty string
|
||||||
* will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
|
* will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
|
||||||
*/
|
*/
|
||||||
set: (key, value, options) => {
|
set: (key: string, value: string, options: CookieSerializeOptions) => {
|
||||||
event.cookies.set(key, value, { ...options, path: '/' });
|
event.cookies.set(key, value, { ...options, path: '/' });
|
||||||
},
|
},
|
||||||
remove: (key, options) => {
|
remove: (key: string, options: CookieSerializeOptions) => {
|
||||||
event.cookies.delete(key, { ...options, path: '/' });
|
event.cookies.delete(key, { ...options, path: '/' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
currentPage = Math.max(currentPage - 1, 1);
|
currentPage = Math.max(currentPage - 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setItemsPerPage(event: any) {
|
function setItemsPerPage(event: Event) {
|
||||||
itemsPerPage = parseInt(event.target.value, 10);
|
itemsPerPage = parseInt((event.target as HTMLSelectElement).value, 10);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||||
import { inView } from '$lib/actions/inView';
|
import { inView } from '$lib/actions/inView';
|
||||||
|
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
export let pokemonName: string;
|
export let pokemonName: string;
|
||||||
export let pokedexNumber: string | number;
|
export let pokedexNumber: string | number;
|
||||||
@@ -12,46 +13,7 @@
|
|||||||
let imagePath = null as string | null;
|
let imagePath = null as string | null;
|
||||||
let isInView = false;
|
let isInView = false;
|
||||||
|
|
||||||
function isFemaleForm(value?: string) {
|
|
||||||
return /^female\b/i.test((value ?? '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFallbackKey() {
|
|
||||||
const strippedPokedexNumber = pokedexNumber.toString().replace(/^0+/, '') || '0';
|
|
||||||
if (!form) return strippedPokedexNumber;
|
|
||||||
|
|
||||||
let formValue = form.trim();
|
|
||||||
formValue = formValue.replace(/^female[-\s]*/i, '');
|
|
||||||
formValue = formValue
|
|
||||||
.replace(/\s*\(.*?\)/g, '')
|
|
||||||
.replace(/\s*\[.*?\]/g, '')
|
|
||||||
.trim();
|
|
||||||
if (!formValue || formValue.toLowerCase() === 'male') return strippedPokedexNumber;
|
|
||||||
|
|
||||||
formValue = formValue
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/%/g, '')
|
|
||||||
.replace(/\balolan\b/g, 'alola')
|
|
||||||
.replace(/\bgalarian\b/g, 'galar')
|
|
||||||
.replace(/\bhisuian\b/g, 'hisui')
|
|
||||||
.replace(/\bpaldean\b/g, 'paldea')
|
|
||||||
.replace(/\bform(e)?$/, '')
|
|
||||||
.replace(/\bability$/, '')
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/(^-|-$)/g, '')
|
|
||||||
.replace(/2/g, 'two')
|
|
||||||
.replace(/3/g, 'three')
|
|
||||||
.replace(/4/g, 'four');
|
|
||||||
|
|
||||||
return formValue ? `${strippedPokedexNumber}-${formValue}` : strippedPokedexNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
const rootFolderBase =
|
|
||||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
|
||||||
? '/sprites-small/home'
|
|
||||||
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
|
||||||
const resolvedSpriteKey = spriteKey?.trim() || buildFallbackKey();
|
|
||||||
if (!spriteKey?.trim()) {
|
if (!spriteKey?.trim()) {
|
||||||
console.warn('Missing sprite key for pokemon entry', {
|
console.warn('Missing sprite key for pokemon entry', {
|
||||||
pokemonName,
|
pokemonName,
|
||||||
@@ -60,14 +22,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder = rootFolderBase;
|
imagePath = resolveSpriteUrl(
|
||||||
if (shiny) {
|
{ pokedexNumber: Number(pokedexNumber), form, spriteKey },
|
||||||
rootFolder += '/shiny';
|
!!shiny,
|
||||||
}
|
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||||
if (isFemaleForm(form)) {
|
);
|
||||||
rootFolder += '/female';
|
|
||||||
}
|
|
||||||
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (loadingStrategy !== 'inView') {
|
$: if (loadingStrategy !== 'inView') {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signInWithEmail() {
|
async function signInWithEmail() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { clearOfflineData } from '$lib/stores/offlineSync';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
let errorMessage = '';
|
||||||
|
let isSigningOut = false;
|
||||||
|
|
||||||
function emitSignedOutEvent() {
|
function emitSignedOutEvent() {
|
||||||
dispatch('signedOut', {});
|
dispatch('signedOut', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
// TODO use the error from the response
|
errorMessage = '';
|
||||||
const { error } = await supabase.auth.signOut().then(() => {
|
isSigningOut = true;
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
if (error) {
|
||||||
|
errorMessage = `Sign out failed: ${error.message || 'Please try again.'}`;
|
||||||
|
dispatch('signOutFailed', { message: errorMessage });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await clearOfflineData();
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error('Signed out, but failed to clear offline data', cacheError);
|
||||||
|
}
|
||||||
emitSignedOutEvent();
|
emitSignedOutEvent();
|
||||||
});
|
await goto('/', { invalidateAll: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out failed', error);
|
||||||
|
errorMessage = 'Sign out failed. Please try again.';
|
||||||
|
dispatch('signOutFailed', { message: errorMessage });
|
||||||
|
} finally {
|
||||||
|
isSigningOut = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click={signOut}>Sign Out</button>
|
<button on:click={signOut} disabled={isSigningOut}>
|
||||||
|
{isSigningOut ? 'Signing Out…' : 'Sign Out'}
|
||||||
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
@@ -6,17 +7,16 @@
|
|||||||
let password = '';
|
let password = '';
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signUpNewUser() {
|
async function signUpNewUser() {
|
||||||
try {
|
try {
|
||||||
|
// `redirectTo` is not a signUp option - it was silently ignored, so the confirmation
|
||||||
|
// link has always used Supabase's configured site URL. Sending the user to /welcome
|
||||||
|
// would need `emailRedirectTo` with an absolute, allow-listed URL.
|
||||||
const { data, error } = await supabase.auth.signUp({
|
const { data, error } = await supabase.auth.signUp({
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password
|
||||||
options: {
|
|
||||||
// Redirect URL after successful sign-up
|
|
||||||
redirectTo: '/welcome'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
}
|
}
|
||||||
updateCatchRecord('toggle');
|
updateCatchRecord('toggle');
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -59,10 +59,8 @@
|
|||||||
// Validation
|
// Validation
|
||||||
$: hasAtLeastOneType =
|
$: hasAtLeastOneType =
|
||||||
pokedex.isLivingDex || pokedex.isShinyDex || pokedex.isOriginDex || pokedex.isFormDex;
|
pokedex.isLivingDex || pokedex.isShinyDex || pokedex.isOriginDex || pokedex.isFormDex;
|
||||||
$: hasDexScope =
|
$: hasDexScope = !pokedex.gameScope || (pokedex.dexScopes && pokedex.dexScopes.length > 0);
|
||||||
!pokedex.gameScope || (pokedex.dexScopes && pokedex.dexScopes.length > 0);
|
$: canSubmit = pokedex.name && pokedex.name.trim() !== '' && hasAtLeastOneType && hasDexScope;
|
||||||
$: canSubmit =
|
|
||||||
pokedex.name && pokedex.name.trim() !== '' && hasAtLeastOneType && hasDexScope;
|
|
||||||
|
|
||||||
$: if (pokedex.gameScope !== lastGameScope) {
|
$: if (pokedex.gameScope !== lastGameScope) {
|
||||||
const shouldResetDexes = mode === 'create' || hasSeenGameScope;
|
const shouldResetDexes = mode === 'create' || hasSeenGameScope;
|
||||||
@@ -162,16 +160,14 @@
|
|||||||
<option value={null}>All Games</option>
|
<option value={null}>All Games</option>
|
||||||
{#if loadingDexes}
|
{#if loadingDexes}
|
||||||
<option disabled>Loading games...</option>
|
<option disabled>Loading games...</option>
|
||||||
|
{:else if gameList.length > 0}
|
||||||
|
{#each gameList as game}
|
||||||
|
<option value={game.displayName}>{game.displayName}</option>
|
||||||
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#if gameList.length > 0}
|
{#each gameOrder.length > 0 ? gameOrder : Object.keys(gameDexes) as game}
|
||||||
{#each gameList as game}
|
<option value={game}>{game}</option>
|
||||||
<option value={game.displayName}>{game.displayName}</option>
|
{/each}
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each gameOrder.length > 0 ? gameOrder : Object.keys(gameDexes) as game}
|
|
||||||
<option value={game}>{game}</option>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +177,9 @@
|
|||||||
<fieldset class="w-full">
|
<fieldset class="w-full">
|
||||||
<legend class="label">
|
<legend class="label">
|
||||||
<span class="label-text">Dex Scope</span>
|
<span class="label-text">Dex Scope</span>
|
||||||
<span class="label-text-alt text-error">{!hasDexScope ? 'Select at least one dex' : ''}</span>
|
<span class="label-text-alt text-error"
|
||||||
|
>{!hasDexScope ? 'Select at least one dex' : ''}</span
|
||||||
|
>
|
||||||
</legend>
|
</legend>
|
||||||
{#if availableDexes.length === 0}
|
{#if availableDexes.length === 0}
|
||||||
<p class="text-sm text-error">No dexes found for this game.</p>
|
<p class="text-sm text-error">No dexes found for this game.</p>
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
export let creatingRecords = false;
|
export let creatingRecords = false;
|
||||||
export let totalRecordsCreated = 0;
|
export let totalRecordsCreated = 0;
|
||||||
export let failedToLoad = false;
|
export let failedToLoad = false;
|
||||||
export let markBoxAsNotCaught = (boxNumber: number) => {};
|
export let markBoxAsNotCaught: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsCaught = (boxNumber: number) => {};
|
export let markBoxAsCaught: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsNeedsToEvolve = (boxNumber: number) => {};
|
export let markBoxAsNeedsToEvolve: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsInHome = (boxNumber: number) => {};
|
export let markBoxAsInHome: (boxNumber: number) => void = () => {};
|
||||||
export let markBoxAsNotInHome = (boxNumber: number) => {};
|
export let markBoxAsNotInHome: (boxNumber: number) => void = () => {};
|
||||||
export let createCatchRecords = () => {};
|
export let createCatchRecords = () => {};
|
||||||
export let onPokemonClick: (pokemon: CombinedData) => void = () => {};
|
export let onPokemonClick: (pokemon: CombinedData) => void = () => {};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { CombinedData } from './CombinedData';
|
||||||
|
import type { Pokedex } from './Pokedex';
|
||||||
|
|
||||||
|
export const OFFLINE_SNAPSHOT_VERSION = 1;
|
||||||
|
|
||||||
|
export type OfflinePokedexSnapshot = {
|
||||||
|
pokedex: Pokedex;
|
||||||
|
entries: CombinedData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OfflineSnapshot = {
|
||||||
|
version: typeof OFFLINE_SNAPSHOT_VERSION;
|
||||||
|
generatedAt: string;
|
||||||
|
userId: string;
|
||||||
|
pokedexes: OfflinePokedexSnapshot[];
|
||||||
|
};
|
||||||
@@ -126,7 +126,7 @@ class CombinedDataRepository {
|
|||||||
let start = 0;
|
let start = 0;
|
||||||
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
||||||
|
|
||||||
while (true) {
|
for (;;) {
|
||||||
const end = start + maxRows - 1;
|
const end = start + maxRows - 1;
|
||||||
let query = this.supabase.from('pokedex_entries').select('*').not('form', 'is', null);
|
let query = this.supabase.from('pokedex_entries').select('*').not('form', 'is', null);
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ class CombinedDataRepository {
|
|||||||
let start = 0;
|
let start = 0;
|
||||||
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
||||||
|
|
||||||
while (true) {
|
for (;;) {
|
||||||
const end = start + maxRows - 1;
|
const end = start + maxRows - 1;
|
||||||
const { data, error } = await this.buildDexEntriesQuery(dexScopes, enableForms, region).range(
|
const { data, error } = await this.buildDexEntriesQuery(dexScopes, enableForms, region).range(
|
||||||
start,
|
start,
|
||||||
@@ -312,7 +312,7 @@ class CombinedDataRepository {
|
|||||||
let start = 0;
|
let start = 0;
|
||||||
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
const maxRows = CombinedDataRepository.MAX_ROWS_PER_REQUEST;
|
||||||
|
|
||||||
while (true) {
|
for (;;) {
|
||||||
const end = start + maxRows - 1;
|
const end = start + maxRows - 1;
|
||||||
const { data, error } = await this.buildEntriesQuery(enableForms, region, game).range(
|
const { data, error } = await this.buildEntriesQuery(enableForms, region, game).range(
|
||||||
start,
|
start,
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import type { SupabaseClient } from '@supabase/supabase-js';
|
|||||||
class PokedexEntryRepository {
|
class PokedexEntryRepository {
|
||||||
constructor(private supabase: SupabaseClient) {}
|
constructor(private supabase: SupabaseClient) {}
|
||||||
|
|
||||||
private parseCatchInformation(
|
private parseCatchInformation(values: string[] | null): Array<string | CatchInformationItem> {
|
||||||
values: string[] | null
|
|
||||||
): Array<string | CatchInformationItem> {
|
|
||||||
if (!values) return [];
|
if (!values) return [];
|
||||||
return values.map((value) => {
|
return values.map((value) => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type {
|
|||||||
PokedexExportIntegrationDB
|
PokedexExportIntegrationDB
|
||||||
} from '$lib/models/PokedexExportIntegration';
|
} from '$lib/models/PokedexExportIntegration';
|
||||||
|
|
||||||
|
// `from()` returns a table builder; only `select()` yields the filter builder that `eq`/`is`
|
||||||
|
// live on. Typing the scope helper with the table builder made `data` untyped downstream.
|
||||||
|
type IntegrationQuery = ReturnType<ReturnType<SupabaseClient['from']>['select']>;
|
||||||
|
|
||||||
class PokedexExportIntegrationRepository {
|
class PokedexExportIntegrationRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private supabase: SupabaseClient,
|
private supabase: SupabaseClient,
|
||||||
@@ -34,7 +38,7 @@ class PokedexExportIntegrationRepository {
|
|||||||
return this.supabase.from('pokedex_export_integrations').select('*').eq('userId', this.userId);
|
return this.supabase.from('pokedex_export_integrations').select('*').eq('userId', this.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private addPokedexScope(query: ReturnType<SupabaseClient['from']>) {
|
private addPokedexScope(query: IntegrationQuery): IntegrationQuery {
|
||||||
if (this.pokedexId) {
|
if (this.pokedexId) {
|
||||||
return query.eq('pokedexId', this.pokedexId);
|
return query.eq('pokedexId', this.pokedexId);
|
||||||
}
|
}
|
||||||
@@ -49,7 +53,8 @@ class PokedexExportIntegrationRepository {
|
|||||||
throw new Error(`Failed to load export integrations: ${error.message}`);
|
throw new Error(`Failed to load export integrations: ${error.message}`);
|
||||||
}
|
}
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
return data.map((row) => this.transform(row));
|
// PostgREST rows are untyped without generated database types.
|
||||||
|
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAll(): Promise<PokedexExportIntegration[]> {
|
async listAll(): Promise<PokedexExportIntegration[]> {
|
||||||
@@ -60,12 +65,12 @@ class PokedexExportIntegrationRepository {
|
|||||||
throw new Error(`Failed to load export integrations: ${error.message}`);
|
throw new Error(`Failed to load export integrations: ${error.message}`);
|
||||||
}
|
}
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
return data.map((row) => this.transform(row));
|
// PostgREST rows are untyped without generated database types.
|
||||||
|
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(
|
async upsert(
|
||||||
data: Partial<PokedexExportIntegrationDB> &
|
data: Partial<PokedexExportIntegrationDB> & Pick<PokedexExportIntegrationDB, 'provider'>
|
||||||
Pick<PokedexExportIntegrationDB, 'provider'>
|
|
||||||
): Promise<PokedexExportIntegration> {
|
): Promise<PokedexExportIntegration> {
|
||||||
const payload: Partial<PokedexExportIntegrationDB> = {
|
const payload: Partial<PokedexExportIntegrationDB> = {
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
@@ -105,7 +110,8 @@ class PokedexExportIntegrationRepository {
|
|||||||
throw new Error(`Failed to load export integrations: ${error.message}`);
|
throw new Error(`Failed to load export integrations: ${error.message}`);
|
||||||
}
|
}
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
return data.map((row) => this.transform(row));
|
// PostgREST rows are untyped without generated database types.
|
||||||
|
return (data as PokedexExportIntegrationDB[]).map((row) => this.transform(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTokens(
|
async updateTokens(
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ export async function setPokedexDexScopes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listGameDexes(
|
export async function listGameDexes(supabase: SupabaseClient): Promise<{
|
||||||
supabase: SupabaseClient
|
|
||||||
): Promise<{
|
|
||||||
gameDexes: Record<string, GameDexRow[]>;
|
gameDexes: Record<string, GameDexRow[]>;
|
||||||
gameOrder: string[];
|
gameOrder: string[];
|
||||||
games: { displayName: string; releaseYear: number }[];
|
games: { displayName: string; releaseYear: number }[];
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { CombinedData } from '$lib/models/CombinedData';
|
||||||
|
|
||||||
|
export function csvEscape(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
const str = String(value);
|
||||||
|
if (/[",\n\r]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFileName(name: string, fallback: string): string {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
||||||
|
if (!safe) return fallback;
|
||||||
|
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCsv(combinedData: CombinedData[]): string {
|
||||||
|
const headers = [
|
||||||
|
'pokemonId',
|
||||||
|
'pokedexNumber',
|
||||||
|
'pokemon',
|
||||||
|
'form',
|
||||||
|
'caught',
|
||||||
|
'haveToEvolve',
|
||||||
|
'inHome',
|
||||||
|
'personalNotes'
|
||||||
|
];
|
||||||
|
const lines = [headers.map(csvEscape).join(',')];
|
||||||
|
|
||||||
|
for (const row of combinedData) {
|
||||||
|
const entry = row.pokedexEntry;
|
||||||
|
const catchRecord = row.catchRecord ?? {
|
||||||
|
caught: false,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: false,
|
||||||
|
personalNotes: ''
|
||||||
|
};
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
entry._id,
|
||||||
|
entry.pokedexNumber,
|
||||||
|
entry.pokemon,
|
||||||
|
entry.form || '',
|
||||||
|
catchRecord.caught,
|
||||||
|
catchRecord.haveToEvolve,
|
||||||
|
catchRecord.inHome,
|
||||||
|
catchRecord.personalNotes || ''
|
||||||
|
]
|
||||||
|
.map(csvEscape)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshToken(expiresAt: string | null): boolean {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
const expiry = new Date(expiresAt).getTime();
|
||||||
|
if (!Number.isFinite(expiry)) return false;
|
||||||
|
return expiry - Date.now() < 60_000;
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
import type { CombinedData } from '$lib/models/CombinedData';
|
|
||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import type { ExportProvider, PokedexExportIntegration } from '$lib/models/PokedexExportIntegration';
|
import type {
|
||||||
|
ExportProvider,
|
||||||
|
PokedexExportIntegration
|
||||||
|
} from '$lib/models/PokedexExportIntegration';
|
||||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
|
||||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
import {
|
||||||
|
buildCsv,
|
||||||
|
sanitizeFileName,
|
||||||
|
shouldRefreshToken
|
||||||
|
} from '$lib/services/PokedexExportFormatting';
|
||||||
|
|
||||||
type ExportFailure = {
|
type ExportFailure = {
|
||||||
integrationId: string;
|
integrationId: string;
|
||||||
@@ -21,71 +29,6 @@ export type PokedexExportResult = {
|
|||||||
failed: ExportFailure[];
|
failed: ExportFailure[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function csvEscape(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return '';
|
|
||||||
const str = String(value);
|
|
||||||
if (/[",\n\r]/.test(str)) {
|
|
||||||
return `"${str.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFileName(name: string, fallback: string): string {
|
|
||||||
const trimmed = name.trim();
|
|
||||||
const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-');
|
|
||||||
if (!safe) return fallback;
|
|
||||||
return safe.endsWith('.csv') ? safe : `${safe}.csv`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string {
|
|
||||||
const headers = [
|
|
||||||
'pokemonId',
|
|
||||||
'pokedexNumber',
|
|
||||||
'pokemon',
|
|
||||||
'form',
|
|
||||||
'caught',
|
|
||||||
'haveToEvolve',
|
|
||||||
'inHome',
|
|
||||||
'personalNotes'
|
|
||||||
];
|
|
||||||
|
|
||||||
const lines = [headers.map(csvEscape).join(',')];
|
|
||||||
|
|
||||||
for (const row of combinedData) {
|
|
||||||
const entry = row.pokedexEntry;
|
|
||||||
const catchRecord = row.catchRecord ?? {
|
|
||||||
caught: false,
|
|
||||||
haveToEvolve: false,
|
|
||||||
inHome: false,
|
|
||||||
hasGigantamaxed: false,
|
|
||||||
personalNotes: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const values = [
|
|
||||||
entry._id,
|
|
||||||
entry.pokedexNumber,
|
|
||||||
entry.pokemon,
|
|
||||||
entry.form || '',
|
|
||||||
catchRecord.caught,
|
|
||||||
catchRecord.haveToEvolve,
|
|
||||||
catchRecord.inHome,
|
|
||||||
catchRecord.personalNotes || ''
|
|
||||||
];
|
|
||||||
|
|
||||||
lines.push(values.map(csvEscape).join(','));
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRefreshToken(expiresAt: string | null): boolean {
|
|
||||||
if (!expiresAt) return false;
|
|
||||||
const expiry = new Date(expiresAt).getTime();
|
|
||||||
if (!Number.isFinite(expiry)) return false;
|
|
||||||
// Refresh if within 60 seconds of expiry.
|
|
||||||
return expiry - Date.now() < 60_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshGoogleToken(
|
async function refreshGoogleToken(
|
||||||
integration: PokedexExportIntegration,
|
integration: PokedexExportIntegration,
|
||||||
repo: PokedexExportIntegrationRepository
|
repo: PokedexExportIntegrationRepository
|
||||||
@@ -108,7 +51,7 @@ async function refreshGoogleToken(
|
|||||||
grant_type: 'refresh_token'
|
grant_type: 'refresh_token'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
const response = await fetch(getProviderEndpoints().google.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params.toString()
|
body: params.toString()
|
||||||
@@ -162,7 +105,7 @@ async function refreshDropboxToken(
|
|||||||
grant_type: 'refresh_token'
|
grant_type: 'refresh_token'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch('https://api.dropbox.com/oauth2/token', {
|
const response = await fetch(getProviderEndpoints().dropbox.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params.toString()
|
body: params.toString()
|
||||||
@@ -217,7 +160,10 @@ type GoogleDriveMetadata = {
|
|||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getGoogleFileId(metadata: Record<string, unknown> | null, pokedexId: string): string | null {
|
function getGoogleFileId(
|
||||||
|
metadata: Record<string, unknown> | null,
|
||||||
|
pokedexId: string
|
||||||
|
): string | null {
|
||||||
const data = metadata as GoogleDriveMetadata | null;
|
const data = metadata as GoogleDriveMetadata | null;
|
||||||
const fileId = data?.files?.[pokedexId];
|
const fileId = data?.files?.[pokedexId];
|
||||||
return typeof fileId === 'string' && fileId ? fileId : null;
|
return typeof fileId === 'string' && fileId ? fileId : null;
|
||||||
@@ -262,7 +208,7 @@ async function uploadToGoogleDrive(
|
|||||||
if (!folderId) {
|
if (!folderId) {
|
||||||
try {
|
try {
|
||||||
const folderResponse = await fetch(
|
const folderResponse = await fetch(
|
||||||
'https://www.googleapis.com/drive/v3/files?' +
|
`${getProviderEndpoints().google.driveApi}/files?` +
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false",
|
||||||
fields: 'files(id,name)',
|
fields: 'files(id,name)',
|
||||||
@@ -286,7 +232,7 @@ async function uploadToGoogleDrive(
|
|||||||
|
|
||||||
if (!folderId) {
|
if (!folderId) {
|
||||||
try {
|
try {
|
||||||
const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', {
|
const createResponse = await fetch(`${getProviderEndpoints().google.driveApi}/files`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${refreshed.accessToken}`,
|
Authorization: `Bearer ${refreshed.accessToken}`,
|
||||||
@@ -332,12 +278,11 @@ async function uploadToGoogleDrive(
|
|||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const url = currentFileId
|
const url = currentFileId
|
||||||
? `https://www.googleapis.com/upload/drive/v3/files/${currentFileId}?uploadType=multipart`
|
? `${getProviderEndpoints().google.driveUpload}/files/${currentFileId}?uploadType=multipart`
|
||||||
: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';
|
: `${getProviderEndpoints().google.driveUpload}/files?uploadType=multipart`;
|
||||||
const method = currentFileId ? 'PATCH' : 'POST';
|
const method = currentFileId ? 'PATCH' : 'POST';
|
||||||
const uploadUrl = currentFileId && folderId
|
const uploadUrl =
|
||||||
? `${url}&addParents=${encodeURIComponent(folderId)}`
|
currentFileId && folderId ? `${url}&addParents=${encodeURIComponent(folderId)}` : url;
|
||||||
: url;
|
|
||||||
|
|
||||||
const response = await fetch(uploadUrl, {
|
const response = await fetch(uploadUrl, {
|
||||||
method,
|
method,
|
||||||
@@ -392,7 +337,7 @@ async function uploadToDropbox(
|
|||||||
targetPath = `${targetPath}/${fileName}`;
|
targetPath = `${targetPath}/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://content.dropboxapi.com/2/files/upload', {
|
const response = await fetch(getProviderEndpoints().dropbox.upload, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${refreshed.accessToken}`,
|
Authorization: `Bearer ${refreshed.accessToken}`,
|
||||||
@@ -453,7 +398,7 @@ export async function exportPokedexIfConfigured(
|
|||||||
pokedex.gameScope || '',
|
pokedex.gameScope || '',
|
||||||
dexScopes
|
dexScopes
|
||||||
);
|
);
|
||||||
const csv = buildCsv(pokedex, combinedData);
|
const csv = buildCsv(combinedData);
|
||||||
|
|
||||||
const failures: ExportFailure[] = [];
|
const failures: ExportFailure[] = [];
|
||||||
let successes = 0;
|
let successes = 0;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
|
||||||
|
export type ProviderEndpoints = {
|
||||||
|
google: { authorize: string; token: string; driveApi: string; driveUpload: string };
|
||||||
|
dropbox: { authorize: string; token: string; upload: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS: ProviderEndpoints = {
|
||||||
|
google: {
|
||||||
|
authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||||
|
token: 'https://oauth2.googleapis.com/token',
|
||||||
|
driveApi: 'https://www.googleapis.com/drive/v3',
|
||||||
|
driveUpload: 'https://www.googleapis.com/upload/drive/v3'
|
||||||
|
},
|
||||||
|
dropbox: {
|
||||||
|
authorize: 'https://www.dropbox.com/oauth2/authorize',
|
||||||
|
token: 'https://api.dropbox.com/oauth2/token',
|
||||||
|
upload: 'https://content.dropboxapi.com/2/files/upload'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These endpoints receive the OAuth client secret and the user's refresh token, so an override
|
||||||
|
* is only ever a local test seam - never a deployment knob. The guards require the explicit
|
||||||
|
* override flag, the BDD service-role context, and a loopback test stack. Each override must also
|
||||||
|
* be loopback, so a single injected variable cannot redirect credentials to an arbitrary host.
|
||||||
|
* Values are read at runtime from `$env/dynamic/private`.
|
||||||
|
*/
|
||||||
|
function isLocalOverride(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (
|
||||||
|
(url.protocol === 'http:' || url.protocol === 'https:') && LOOPBACK_HOSTS.has(url.hostname)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProviderEndpoints(
|
||||||
|
env: Record<string, string | undefined>
|
||||||
|
): ProviderEndpoints {
|
||||||
|
const overridesAllowed =
|
||||||
|
env.ALLOW_PROVIDER_ENDPOINT_OVERRIDES === 'true' &&
|
||||||
|
!!env.E2E_SERVICE_ROLE_KEY &&
|
||||||
|
!!env.TEST_SUPABASE_URL &&
|
||||||
|
isLocalOverride(env.TEST_SUPABASE_URL);
|
||||||
|
const pick = (override: string | undefined, fallback: string) =>
|
||||||
|
overridesAllowed && override && isLocalOverride(override) ? override : fallback;
|
||||||
|
|
||||||
|
return {
|
||||||
|
google: {
|
||||||
|
authorize: pick(env.GOOGLE_OAUTH_AUTHORIZE_URL, DEFAULTS.google.authorize),
|
||||||
|
token: pick(env.GOOGLE_OAUTH_TOKEN_URL, DEFAULTS.google.token),
|
||||||
|
driveApi: pick(env.GOOGLE_DRIVE_API_URL, DEFAULTS.google.driveApi),
|
||||||
|
driveUpload: pick(env.GOOGLE_DRIVE_UPLOAD_URL, DEFAULTS.google.driveUpload)
|
||||||
|
},
|
||||||
|
dropbox: {
|
||||||
|
authorize: pick(env.DROPBOX_OAUTH_AUTHORIZE_URL, DEFAULTS.dropbox.authorize),
|
||||||
|
token: pick(env.DROPBOX_OAUTH_TOKEN_URL, DEFAULTS.dropbox.token),
|
||||||
|
upload: pick(env.DROPBOX_UPLOAD_URL, DEFAULTS.dropbox.upload)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderEndpoints(): ProviderEndpoints {
|
||||||
|
return resolveProviderEndpoints(getEnv());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVIDER_ENDPOINT_DEFAULTS = DEFAULTS;
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||||
|
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||||
|
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
|
export type OfflineSyncStatus = {
|
||||||
|
state: 'idle' | 'syncing' | 'ready' | 'partial' | 'error';
|
||||||
|
generatedAt: string | null;
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const offlineSyncStatus = writable<OfflineSyncStatus>({
|
||||||
|
state: 'idle',
|
||||||
|
generatedAt: null,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const SYNC_EVENT = 'livingdex:offline-sync';
|
||||||
|
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||||
|
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||||
|
const OFFLINE_META_URL = '/__offline/current';
|
||||||
|
|
||||||
|
async function workerMessage(
|
||||||
|
message: unknown,
|
||||||
|
timeoutMs = 120_000,
|
||||||
|
waitForReady = true
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (!('serviceWorker' in navigator)) throw new Error('Service workers are unavailable');
|
||||||
|
const registration = waitForReady
|
||||||
|
? await navigator.serviceWorker.ready
|
||||||
|
: await navigator.serviceWorker.getRegistration();
|
||||||
|
if (!registration?.active) throw new Error('Offline worker is not active');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel();
|
||||||
|
const timeout = window.setTimeout(
|
||||||
|
() => reject(new Error('Offline worker timed out')),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
const result = event.data as Record<string, unknown>;
|
||||||
|
if (result?.ok) resolve(result);
|
||||||
|
else reject(new Error(String(result?.error ?? 'Offline worker failed')));
|
||||||
|
};
|
||||||
|
registration.active?.postMessage(message, [channel.port2]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestOfflineSync(): void {
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOfflineCaches(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined' || !('caches' in window)) return;
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimOfflineData(userId: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if ('caches' in window) {
|
||||||
|
const names = await caches.keys();
|
||||||
|
if (names.includes(OFFLINE_META_CACHE)) {
|
||||||
|
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||||
|
const meta = await response?.json().catch(() => null);
|
||||||
|
if (typeof meta?.userId !== 'string' || meta.userId !== userId) await deleteOfflineCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
|
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearOfflineData(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
await deleteOfflineCaches();
|
||||||
|
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||||
|
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
|
||||||
|
}
|
||||||
|
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||||
|
let timer: number | null = null;
|
||||||
|
let stopped = false;
|
||||||
|
let running = false;
|
||||||
|
let rerun = false;
|
||||||
|
let lastGeneratedAt: string | null = null;
|
||||||
|
|
||||||
|
const synchronize = async () => {
|
||||||
|
if (stopped || !navigator.onLine) return;
|
||||||
|
if (running) {
|
||||||
|
rerun = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = getUserId();
|
||||||
|
if (!userId || !('serviceWorker' in navigator)) return;
|
||||||
|
running = true;
|
||||||
|
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
|
||||||
|
try {
|
||||||
|
await claimOfflineData(userId);
|
||||||
|
const response = await fetch('/api/offline-snapshot', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Snapshot request failed (${response.status})`);
|
||||||
|
const snapshot = (await response.json()) as OfflineSnapshot;
|
||||||
|
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
|
||||||
|
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
|
||||||
|
const artworkUrls = Array.from(
|
||||||
|
new Set(
|
||||||
|
snapshot.pokedexes.flatMap(({ pokedex, entries }) =>
|
||||||
|
entries.map(({ pokedexEntry }) => {
|
||||||
|
const url = resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
||||||
|
(
|
||||||
|
pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }
|
||||||
|
).offlineSpriteUrl = url;
|
||||||
|
return url;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (getUserId() !== userId) throw new Error('Session changed during offline synchronization');
|
||||||
|
const result = await workerMessage({
|
||||||
|
type: 'SYNC_OFFLINE_SNAPSHOT',
|
||||||
|
snapshot,
|
||||||
|
artworkUrls
|
||||||
|
});
|
||||||
|
const failed = Number(result.failedArtwork ?? 0);
|
||||||
|
offlineSyncStatus.set({
|
||||||
|
state: failed > 0 ? 'partial' : 'ready',
|
||||||
|
generatedAt: snapshot.generatedAt,
|
||||||
|
message: failed > 0 ? `${failed} artwork files could not be cached` : null
|
||||||
|
});
|
||||||
|
lastGeneratedAt = snapshot.generatedAt;
|
||||||
|
} catch (error) {
|
||||||
|
offlineSyncStatus.set({
|
||||||
|
state: 'error',
|
||||||
|
generatedAt: lastGeneratedAt,
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
if (rerun && !stopped) {
|
||||||
|
rerun = false;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
void synchronize();
|
||||||
|
}, 1_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(SYNC_EVENT, schedule);
|
||||||
|
window.addEventListener('online', schedule);
|
||||||
|
schedule();
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
|
window.removeEventListener(SYNC_EVENT, schedule);
|
||||||
|
window.removeEventListener('online', schedule);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import { error } from '@sveltejs/kit';
|
|||||||
*/
|
*/
|
||||||
export async function requireAuth(event: RequestEvent): Promise<string> {
|
export async function requireAuth(event: RequestEvent): Promise<string> {
|
||||||
const { session, user } = await event.locals.safeGetSession();
|
const { session, user } = await event.locals.safeGetSession();
|
||||||
|
|
||||||
if (!session || !user) {
|
if (!session || !user) {
|
||||||
throw error(401, 'Authentication required');
|
throw error(401, 'Authentication required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return user.id;
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ export async function requireAuth(event: RequestEvent): Promise<string> {
|
|||||||
*/
|
*/
|
||||||
export async function getOptionalUserId(event: RequestEvent): Promise<string | null> {
|
export async function getOptionalUserId(event: RequestEvent): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { session, user } = await event.locals.safeGetSession();
|
const { user } = await event.locals.safeGetSession();
|
||||||
return user?.id || null;
|
return user?.id || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function calculateBoxPlacement(index: number): {
|
|||||||
} {
|
} {
|
||||||
const POKEMON_PER_BOX = 30;
|
const POKEMON_PER_BOX = 30;
|
||||||
const COLUMNS_PER_BOX = 6;
|
const COLUMNS_PER_BOX = 6;
|
||||||
const ROWS_PER_BOX = 5;
|
|
||||||
|
|
||||||
// Calculate which box this Pokémon belongs to (1-indexed)
|
// Calculate which box this Pokémon belongs to (1-indexed)
|
||||||
const box = Math.floor(index / POKEMON_PER_BOX) + 1;
|
const box = Math.floor(index / POKEMON_PER_BOX) + 1;
|
||||||
|
|||||||
@@ -24,62 +24,62 @@ type RegionalDexKey =
|
|||||||
|
|
||||||
const gameToRegionalDexMap: Record<string, RegionalDexKey> = {
|
const gameToRegionalDexMap: Record<string, RegionalDexKey> = {
|
||||||
// Kanto region
|
// Kanto region
|
||||||
'Red': 'kanto',
|
Red: 'kanto',
|
||||||
'Blue': 'kanto',
|
Blue: 'kanto',
|
||||||
'Yellow': 'kanto',
|
Yellow: 'kanto',
|
||||||
'FireRed': 'kanto',
|
FireRed: 'kanto',
|
||||||
'LeafGreen': 'kanto',
|
LeafGreen: 'kanto',
|
||||||
'LG: Pikachu': 'kanto',
|
'LG: Pikachu': 'kanto',
|
||||||
'LG: Eevee': 'kanto',
|
'LG: Eevee': 'kanto',
|
||||||
|
|
||||||
// Johto region
|
// Johto region
|
||||||
'Gold': 'johto',
|
Gold: 'johto',
|
||||||
'Silver': 'johto',
|
Silver: 'johto',
|
||||||
'Crystal': 'johto',
|
Crystal: 'johto',
|
||||||
'HeartGold': 'johto',
|
HeartGold: 'johto',
|
||||||
'SoulSilver': 'johto',
|
SoulSilver: 'johto',
|
||||||
|
|
||||||
// Hoenn region
|
// Hoenn region
|
||||||
'Ruby': 'hoenn',
|
Ruby: 'hoenn',
|
||||||
'Sapphire': 'hoenn',
|
Sapphire: 'hoenn',
|
||||||
'Emerald': 'hoenn',
|
Emerald: 'hoenn',
|
||||||
'OmegaRuby': 'hoenn',
|
OmegaRuby: 'hoenn',
|
||||||
'AlphaSapphire': 'hoenn',
|
AlphaSapphire: 'hoenn',
|
||||||
|
|
||||||
// Sinnoh region
|
// Sinnoh region
|
||||||
'Diamond': 'sinnoh',
|
Diamond: 'sinnoh',
|
||||||
'Pearl': 'sinnoh',
|
Pearl: 'sinnoh',
|
||||||
'Platinum': 'sinnoh',
|
Platinum: 'sinnoh',
|
||||||
'BrilliantDiamond': 'sinnoh',
|
BrilliantDiamond: 'sinnoh',
|
||||||
'ShiningPearl': 'sinnoh',
|
ShiningPearl: 'sinnoh',
|
||||||
|
|
||||||
// Unova region
|
// Unova region
|
||||||
'Black': 'unova_bw',
|
Black: 'unova_bw',
|
||||||
'White': 'unova_bw',
|
White: 'unova_bw',
|
||||||
'Black2': 'unova_b2w2',
|
Black2: 'unova_b2w2',
|
||||||
'White2': 'unova_b2w2',
|
White2: 'unova_b2w2',
|
||||||
|
|
||||||
// Kalos region - Note: All XY use all three sub-dexes
|
// Kalos region - Note: All XY use all three sub-dexes
|
||||||
// We default to Central for simplicity
|
// We default to Central for simplicity
|
||||||
'X': 'kalos_central',
|
X: 'kalos_central',
|
||||||
'Y': 'kalos_central',
|
Y: 'kalos_central',
|
||||||
|
|
||||||
// Alola region
|
// Alola region
|
||||||
'Sun': 'alola_sm',
|
Sun: 'alola_sm',
|
||||||
'Moon': 'alola_sm',
|
Moon: 'alola_sm',
|
||||||
'UltraSun': 'alola_usum',
|
UltraSun: 'alola_usum',
|
||||||
'UltraMoon': 'alola_usum',
|
UltraMoon: 'alola_usum',
|
||||||
|
|
||||||
// Galar region
|
// Galar region
|
||||||
'Sword': 'galar',
|
Sword: 'galar',
|
||||||
'Shield': 'galar',
|
Shield: 'galar',
|
||||||
|
|
||||||
// Hisui region
|
// Hisui region
|
||||||
'LegendsArceus': 'hisui',
|
LegendsArceus: 'hisui',
|
||||||
|
|
||||||
// Paldea region
|
// Paldea region
|
||||||
'Scarlet': 'paldea',
|
Scarlet: 'paldea',
|
||||||
'Violet': 'paldea'
|
Violet: 'paldea'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,22 +119,22 @@ export function getRegionalDexFieldName(gameName: string): string | undefined {
|
|||||||
|
|
||||||
// Map regional key to actual PokedexEntry field name (camelCase)
|
// Map regional key to actual PokedexEntry field name (camelCase)
|
||||||
const fieldMap: Record<string, string> = {
|
const fieldMap: Record<string, string> = {
|
||||||
'kanto': 'kantoDexNumber',
|
kanto: 'kantoDexNumber',
|
||||||
'johto': 'johtoDexNumber',
|
johto: 'johtoDexNumber',
|
||||||
'hoenn': 'hoennDexNumber',
|
hoenn: 'hoennDexNumber',
|
||||||
'sinnoh': 'sinnohDexNumber',
|
sinnoh: 'sinnohDexNumber',
|
||||||
'unova_bw': 'unovaBwDexNumber',
|
unova_bw: 'unovaBwDexNumber',
|
||||||
'unova_b2w2': 'unovaB2w2DexNumber',
|
unova_b2w2: 'unovaB2w2DexNumber',
|
||||||
'kalos_central': 'kalosCentralDexNumber',
|
kalos_central: 'kalosCentralDexNumber',
|
||||||
'kalos_coastal': 'kalosCoastalDexNumber',
|
kalos_coastal: 'kalosCoastalDexNumber',
|
||||||
'kalos_mountain': 'kalosMountainDexNumber',
|
kalos_mountain: 'kalosMountainDexNumber',
|
||||||
'alola_sm': 'alolaSmDexNumber',
|
alola_sm: 'alolaSmDexNumber',
|
||||||
'alola_usum': 'alolaUsumDexNumber',
|
alola_usum: 'alolaUsumDexNumber',
|
||||||
'galar': 'galarDexNumber',
|
galar: 'galarDexNumber',
|
||||||
'galar_isle_of_armor': 'galarIsleOfArmorDexNumber',
|
galar_isle_of_armor: 'galarIsleOfArmorDexNumber',
|
||||||
'galar_crown_tundra': 'galarCrownTundraDexNumber',
|
galar_crown_tundra: 'galarCrownTundraDexNumber',
|
||||||
'hisui': 'hisuiDexNumber',
|
hisui: 'hisuiDexNumber',
|
||||||
'paldea': 'paldeaDexNumber'
|
paldea: 'paldeaDexNumber'
|
||||||
};
|
};
|
||||||
|
|
||||||
return fieldMap[regionalKey];
|
return fieldMap[regionalKey];
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export function resolveSpriteUrl(
|
||||||
|
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
|
||||||
|
shiny: boolean,
|
||||||
|
useLocalSprites: boolean
|
||||||
|
): string {
|
||||||
|
const form = entry.form?.trim() ?? '';
|
||||||
|
const strippedNumber = String(entry.pokedexNumber).replace(/^0+/, '') || '0';
|
||||||
|
let key = entry.spriteKey?.trim();
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
let formKey = form
|
||||||
|
.replace(/^female[-\s]*/i, '')
|
||||||
|
.replace(/\s*\(.*?\)/g, '')
|
||||||
|
.replace(/\s*\[.*?\]/g, '')
|
||||||
|
.trim();
|
||||||
|
if (!formKey || formKey.toLowerCase() === 'male') {
|
||||||
|
key = strippedNumber;
|
||||||
|
} else {
|
||||||
|
formKey = formKey
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/%/g, '')
|
||||||
|
.replace(/\balolan\b/g, 'alola')
|
||||||
|
.replace(/\bgalarian\b/g, 'galar')
|
||||||
|
.replace(/\bhisuian\b/g, 'hisui')
|
||||||
|
.replace(/\bpaldean\b/g, 'paldea')
|
||||||
|
.replace(/\bform(e)?$/, '')
|
||||||
|
.replace(/\bability$/, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.replace(/2/g, 'two')
|
||||||
|
.replace(/3/g, 'three')
|
||||||
|
.replace(/4/g, 'four');
|
||||||
|
key = formKey ? `${strippedNumber}-${formKey}` : strippedNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = useLocalSprites
|
||||||
|
? '/sprites-small/home'
|
||||||
|
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
||||||
|
if (shiny) root += '/shiny';
|
||||||
|
if (/^female\b/i.test(form)) root += '/female';
|
||||||
|
return `${root}/${key}.webp`;
|
||||||
|
}
|
||||||
+5
-35
@@ -2,26 +2,18 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference no-default-lib="true"/>
|
/// <reference no-default-lib="true"/>
|
||||||
/// <reference lib="esnext" />
|
/// <reference lib="esnext" />
|
||||||
import {
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
||||||
cleanupOutdatedCaches,
|
|
||||||
// createHandlerBoundToURL,
|
|
||||||
precacheAndRoute,
|
|
||||||
precache
|
|
||||||
} from 'workbox-precaching';
|
|
||||||
import { registerRoute } from 'workbox-routing';
|
|
||||||
import { CacheFirst } from 'workbox-strategies';
|
|
||||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
|
||||||
import { ExpirationPlugin } from 'workbox-expiration';
|
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope;
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
// Kept as a static script so the same snapshot, artwork and navigation behavior can be imported
|
||||||
|
// by Workbox's generateSW output too.
|
||||||
|
self.importScripts('/offline-worker.js');
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add root route to precache manifest
|
|
||||||
precache([{ url: '/', revision: null }]);
|
|
||||||
|
|
||||||
// self.__WB_MANIFEST is default injection point
|
// self.__WB_MANIFEST is default injection point
|
||||||
// Handle the case where __WB_MANIFEST might be undefined in development
|
// Handle the case where __WB_MANIFEST might be undefined in development
|
||||||
const manifest = self.__WB_MANIFEST || [];
|
const manifest = self.__WB_MANIFEST || [];
|
||||||
@@ -29,26 +21,4 @@ if (Array.isArray(manifest)) {
|
|||||||
precacheAndRoute(manifest);
|
precacheAndRoute(manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean old assets
|
|
||||||
cleanupOutdatedCaches();
|
cleanupOutdatedCaches();
|
||||||
|
|
||||||
registerRoute(
|
|
||||||
({ request }) => request.destination === 'image',
|
|
||||||
new CacheFirst({
|
|
||||||
cacheName: 'image-cache',
|
|
||||||
plugins: [
|
|
||||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
|
||||||
new ExpirationPlugin({
|
|
||||||
maxEntries: 3000,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
||||||
purgeOnQuotaError: true
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// let allowlist: undefined | RegExp[];
|
|
||||||
// if (import.meta.env.DEV) allowlist = [/^\/$/];
|
|
||||||
|
|
||||||
// // to allow work offline
|
|
||||||
// registerRoute(new NavigationRoute(createHandlerBoundToURL('/'), { allowlist }));
|
|
||||||
|
|||||||
+92
-22
@@ -6,6 +6,13 @@
|
|||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
import SignOut from '$lib/components/SignOut.svelte';
|
import SignOut from '$lib/components/SignOut.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
import {
|
||||||
|
claimOfflineData,
|
||||||
|
clearOfflineData,
|
||||||
|
offlineSyncStatus,
|
||||||
|
requestOfflineSync,
|
||||||
|
startOfflineSync
|
||||||
|
} from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
import { pwaInfo } from 'virtual:pwa-info';
|
import { pwaInfo } from 'virtual:pwa-info';
|
||||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||||
@@ -24,9 +31,35 @@
|
|||||||
onDestroy(unsubscribe);
|
onDestroy(unsubscribe);
|
||||||
|
|
||||||
let authSubscription: { unsubscribe: () => void } | null = null;
|
let authSubscription: { unsubscribe: () => void } | null = null;
|
||||||
|
let stopOfflineSync: (() => void) | null = null;
|
||||||
|
let isOnline = true;
|
||||||
|
let signOutError = '';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void getUser();
|
isOnline = navigator.onLine;
|
||||||
|
const updateOnlineState = () => {
|
||||||
|
isOnline = navigator.onLine;
|
||||||
|
document.documentElement.classList.toggle('offline-readonly', !isOnline);
|
||||||
|
};
|
||||||
|
const blockOfflineMutation = (event: Event) => {
|
||||||
|
if (navigator.onLine) return;
|
||||||
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (!target?.closest('button, input, textarea, select, form')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
window.addEventListener('online', updateOnlineState);
|
||||||
|
window.addEventListener('offline', updateOnlineState);
|
||||||
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
||||||
|
window.addEventListener(name, blockOfflineMutation, true);
|
||||||
|
}
|
||||||
|
updateOnlineState();
|
||||||
|
void getUser()
|
||||||
|
.then(async () => {
|
||||||
|
if (localUser) await claimOfflineData(localUser.id);
|
||||||
|
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
||||||
|
})
|
||||||
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
|
|
||||||
// Listen for auth state changes to keep the user store in sync
|
// Listen for auth state changes to keep the user store in sync
|
||||||
const {
|
const {
|
||||||
@@ -34,35 +67,26 @@
|
|||||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
if (session) {
|
if (session) {
|
||||||
localUser = session.user;
|
localUser = session.user;
|
||||||
|
void claimOfflineData(session.user.id)
|
||||||
|
.then(requestOfflineSync)
|
||||||
|
.catch((error) => console.error('Unable to claim offline data', error));
|
||||||
} else {
|
} else {
|
||||||
localUser = null;
|
localUser = null;
|
||||||
|
if (event === 'SIGNED_OUT') void clearOfflineData();
|
||||||
}
|
}
|
||||||
user.set(localUser);
|
user.set(localUser);
|
||||||
});
|
});
|
||||||
authSubscription = subscription;
|
authSubscription = subscription;
|
||||||
|
|
||||||
if (pwaInfo) {
|
|
||||||
void (async () => {
|
|
||||||
const { registerSW } = await import('virtual:pwa-register');
|
|
||||||
registerSW({
|
|
||||||
immediate: true,
|
|
||||||
onRegistered(r) {
|
|
||||||
// uncomment following code if you want check for updates
|
|
||||||
// r && setInterval(() => {
|
|
||||||
// console.log('Checking for sw update')
|
|
||||||
// r.update()
|
|
||||||
// }, 20000 /* 20s for testing purposes */)
|
|
||||||
console.log(`SW Registered: ${r}`);
|
|
||||||
},
|
|
||||||
onRegisterError(error) {
|
|
||||||
console.log('SW registration error', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
authSubscription?.unsubscribe();
|
authSubscription?.unsubscribe();
|
||||||
|
stopOfflineSync?.();
|
||||||
|
window.removeEventListener('online', updateOnlineState);
|
||||||
|
window.removeEventListener('offline', updateOnlineState);
|
||||||
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
||||||
|
window.removeEventListener(name, blockOfflineMutation, true);
|
||||||
|
}
|
||||||
|
document.documentElement.classList.remove('offline-readonly');
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,7 +186,16 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="/backup-settings"> Backup Settings </a>
|
<a href="/backup-settings"> Backup Settings </a>
|
||||||
</li>
|
</li>
|
||||||
<li><SignOut {supabase} on:signedOut={getUser} /></li>
|
<li>
|
||||||
|
<SignOut
|
||||||
|
{supabase}
|
||||||
|
on:signedOut={() => {
|
||||||
|
signOutError = '';
|
||||||
|
void getUser();
|
||||||
|
}}
|
||||||
|
on:signOutFailed={(event) => (signOutError = event.detail.message)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -174,6 +207,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{#if signOutError}
|
||||||
|
<div class="alert alert-error rounded-none" role="alert">
|
||||||
|
<span>{signOutError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !isOnline}
|
||||||
|
<div class="alert rounded-none" role="status">
|
||||||
|
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
||||||
|
</div>
|
||||||
|
{:else if localUser && $offlineSyncStatus.state === 'error'}
|
||||||
|
<div class="alert alert-warning rounded-none" role="status">
|
||||||
|
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||||
|
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if localUser && $offlineSyncStatus.state === 'partial'}
|
||||||
|
<div class="alert alert-warning rounded-none" role="status">
|
||||||
|
<span>Offline data is saved, but {$offlineSyncStatus.message}.</span>
|
||||||
|
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
||||||
|
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
|
||||||
|
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
||||||
|
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
||||||
|
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<main class="flex-grow">
|
<main class="flex-grow">
|
||||||
<slot />
|
<slot />
|
||||||
@@ -223,3 +283,13 @@
|
|||||||
<ReloadPrompt />
|
<ReloadPrompt />
|
||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.offline-readonly button),
|
||||||
|
:global(.offline-readonly input),
|
||||||
|
:global(.offline-readonly textarea),
|
||||||
|
:global(.offline-readonly select) {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+42
-4
@@ -1,22 +1,56 @@
|
|||||||
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
|
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';
|
import { createBrowserClient, isBrowser, parse, serialize } from '@supabase/ssr';
|
||||||
|
import type { CookieSerializeOptions } from 'cookie';
|
||||||
|
|
||||||
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||||
depends('supabase:auth');
|
depends('supabase:auth');
|
||||||
|
let recoveryExchangeSucceeded = false;
|
||||||
|
let hashRecoveryCallback = false;
|
||||||
|
let codeRecoveryCallback = false;
|
||||||
|
if (isBrowser() && window.location.pathname === '/reset-password') {
|
||||||
|
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||||
|
hashRecoveryCallback =
|
||||||
|
hash.get('type') === 'recovery' && hash.has('access_token') && hash.has('refresh_token');
|
||||||
|
codeRecoveryCallback = new URL(window.location.href).searchParams.has('code');
|
||||||
|
}
|
||||||
|
const authFetch: typeof fetch = async (input, init) => {
|
||||||
|
const response = await fetch(input, init);
|
||||||
|
if (codeRecoveryCallback && response.ok) {
|
||||||
|
const requestUrl = new URL(
|
||||||
|
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||||
|
window.location.origin
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
requestUrl.pathname.endsWith('/auth/v1/token') &&
|
||||||
|
requestUrl.searchParams.get('grant_type') === 'pkce'
|
||||||
|
) {
|
||||||
|
recoveryExchangeSucceeded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
global: {
|
global: {
|
||||||
fetch
|
fetch: authFetch
|
||||||
},
|
},
|
||||||
cookies: {
|
cookies: {
|
||||||
get(key) {
|
get(key: string) {
|
||||||
if (!isBrowser()) {
|
if (!isBrowser()) {
|
||||||
return JSON.stringify(data.session);
|
return JSON.stringify(data.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie = parse(document.cookie);
|
const cookie = parse(document.cookie);
|
||||||
return cookie[key];
|
return cookie[key];
|
||||||
|
},
|
||||||
|
set(key: string, value: string, options: CookieSerializeOptions) {
|
||||||
|
if (isBrowser()) document.cookie = serialize(key, value, { ...options, path: '/' });
|
||||||
|
},
|
||||||
|
remove(key: string, options: CookieSerializeOptions) {
|
||||||
|
if (isBrowser()) {
|
||||||
|
document.cookie = serialize(key, '', { ...options, path: '/', maxAge: 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -30,5 +64,9 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
|||||||
data: { session }
|
data: { session }
|
||||||
} = await supabase.auth.getSession();
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
return { supabase, session };
|
return {
|
||||||
|
supabase,
|
||||||
|
session,
|
||||||
|
recoveryIntent: !!session && (hashRecoveryCallback || recoveryExchangeSucceeded)
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
|
||||||
import { type User } from '@supabase/auth-js';
|
|
||||||
import SignUp from '$lib/components/SignUp.svelte';
|
import SignUp from '$lib/components/SignUp.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
@@ -9,12 +7,7 @@
|
|||||||
let { supabase, stats } = data;
|
let { supabase, stats } = data;
|
||||||
$: ({ supabase, stats } = data);
|
$: ({ supabase, stats } = data);
|
||||||
|
|
||||||
let localUser: User | null;
|
// Redirection is decided from the live session below, so the user store is not needed here.
|
||||||
const unsubscribe = user.subscribe((value) => {
|
|
||||||
localUser = value;
|
|
||||||
});
|
|
||||||
onDestroy(unsubscribe);
|
|
||||||
|
|
||||||
let isCheckingSession = true;
|
let isCheckingSession = true;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export const PUT = async (event: RequestEvent) => {
|
|||||||
.eq('userId', userId)
|
.eq('userId', userId)
|
||||||
.is('pokedexId', null)
|
.is('pokedexId', null)
|
||||||
.eq('provider', provider)
|
.eq('provider', provider)
|
||||||
.select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError')
|
.select(
|
||||||
|
'id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError'
|
||||||
|
)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +57,7 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
grant_type: 'authorization_code'
|
grant_type: 'authorization_code'
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', {
|
const tokenResponse = await fetch(getProviderEndpoints().dropbox.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: tokenParams.toString()
|
body: tokenParams.toString()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
const userId = await requireAuth(event);
|
const userId = await requireAuth(event);
|
||||||
@@ -60,5 +61,5 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
scope: 'files.content.write'
|
scope: 'files.content.write'
|
||||||
});
|
});
|
||||||
|
|
||||||
throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`);
|
throw redirect(302, `${getProviderEndpoints().dropbox.authorize}?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +58,7 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
grant_type: 'authorization_code'
|
grant_type: 'authorization_code'
|
||||||
});
|
});
|
||||||
|
|
||||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
const tokenResponse = await fetch(getProviderEndpoints().google.token, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: tokenParams.toString()
|
body: tokenParams.toString()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository';
|
|||||||
import { requireAuth } from '$lib/utils/auth';
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
|
||||||
import { getEnv } from '$lib/utils/env';
|
import { getEnv } from '$lib/utils/env';
|
||||||
|
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
export const GET = async (event: RequestEvent) => {
|
export const GET = async (event: RequestEvent) => {
|
||||||
const userId = await requireAuth(event);
|
const userId = await requireAuth(event);
|
||||||
@@ -65,5 +66,5 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
state
|
state
|
||||||
});
|
});
|
||||||
|
|
||||||
throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
|
throw redirect(302, `${getProviderEndpoints().google.authorize}?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||||
|
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||||
|
import { OFFLINE_SNAPSHOT_VERSION, type OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||||
|
import { requireAuth } from '$lib/utils/auth';
|
||||||
|
|
||||||
|
export const GET = async (event: RequestEvent) => {
|
||||||
|
try {
|
||||||
|
const userId = await requireAuth(event);
|
||||||
|
const pokedexes = await new PokedexRepository(event.locals.supabase, userId).findAll();
|
||||||
|
const snapshots = await Promise.all(
|
||||||
|
pokedexes.map(async (pokedex) => {
|
||||||
|
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
|
||||||
|
const entries = await new CombinedDataRepository(
|
||||||
|
event.locals.supabase,
|
||||||
|
userId,
|
||||||
|
pokedex._id
|
||||||
|
).findAllCombinedData(userId, pokedex.isFormDex, '', pokedex.gameScope ?? '', dexScopes);
|
||||||
|
return { pokedex: { ...pokedex, dexScopes }, entries };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot: OfflineSnapshot = {
|
||||||
|
version: OFFLINE_SNAPSHOT_VERSION,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
userId,
|
||||||
|
pokedexes: snapshots
|
||||||
|
};
|
||||||
|
|
||||||
|
return json(snapshot, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'private, no-store',
|
||||||
|
Vary: 'Cookie'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unable to build offline snapshot:', error);
|
||||||
|
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||||
|
return json({ error: 'Unable to build offline snapshot' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -31,9 +31,7 @@ export const GET = async (event: RequestEvent) => {
|
|||||||
|
|
||||||
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
|
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
|
||||||
const catchData = await repo.findAll();
|
const catchData = await repo.findAll();
|
||||||
const sortedData = catchData.sort(
|
const sortedData = catchData.sort((a, b) => Number(a.pokemonId) - Number(b.pokemonId));
|
||||||
(a, b) => Number(a.pokemonId) - Number(b.pokemonId)
|
|
||||||
);
|
|
||||||
return json(sortedData);
|
return json(sortedData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -159,10 +157,7 @@ export const POST = async (event: RequestEvent) => {
|
|||||||
try {
|
try {
|
||||||
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
|
||||||
} catch (exportError) {
|
} catch (exportError) {
|
||||||
console.error(
|
console.error('Failed to export pokedex after per-record catch updates:', exportError);
|
||||||
'Failed to export pokedex after per-record catch updates:',
|
|
||||||
exportError
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return json(insertedRecords);
|
return json(insertedRecords);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
||||||
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
||||||
|
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { pokedexes } = data;
|
let { pokedexes } = data;
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Create new pokédex
|
// Create new pokédex
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
|
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
|
||||||
closeModal();
|
closeModal();
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
|
|
||||||
// If the user's total pokédex count is now 1, this newly created one is their first.
|
// If the user's total pokédex count is now 1, this newly created one is their first.
|
||||||
if (pokedexes.length === 1) {
|
if (pokedexes.length === 1) {
|
||||||
@@ -149,6 +152,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadPokedexes();
|
await loadPokedexes();
|
||||||
|
requestOfflineSync();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting pokédex:', error);
|
console.error('Error deleting pokédex:', error);
|
||||||
alert('An error occurred');
|
alert('An error occurred');
|
||||||
@@ -204,11 +208,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<PokedexForm bind:pokedex={formData} {mode} onSubmit={handleSubmit} onCancel={closeModal} />
|
<PokedexForm bind:pokedex={formData} {mode} onSubmit={handleSubmit} onCancel={closeModal} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" class="modal-backdrop" aria-label="Close modal" on:click={closeModal}
|
||||||
type="button"
|
|
||||||
class="modal-backdrop"
|
|
||||||
aria-label="Close modal"
|
|
||||||
on:click={closeModal}
|
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
||||||
import type { Pokedex } from '$lib/models/Pokedex';
|
import type { Pokedex } from '$lib/models/Pokedex';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
@@ -38,7 +38,10 @@
|
|||||||
// Box view requires the full dataset for correct box numbering/placement.
|
// Box view requires the full dataset for correct box numbering/placement.
|
||||||
// If/when a paginated list view is introduced, this can be lowered and paired with UI controls.
|
// If/when a paginated list view is introduced, this can be lowered and paired with UI controls.
|
||||||
let itemsPerPage = 9999 as number;
|
let itemsPerPage = 9999 as number;
|
||||||
let totalPages = 0 as number;
|
type CatchUpdateEvent = CustomEvent<{
|
||||||
|
catchRecord: CatchRecord;
|
||||||
|
source: 'toggle' | 'notes' | 'notes-blur';
|
||||||
|
}>;
|
||||||
let creatingRecords = false;
|
let creatingRecords = false;
|
||||||
let totalRecordsCreated = 0;
|
let totalRecordsCreated = 0;
|
||||||
let failedToLoad = false;
|
let failedToLoad = false;
|
||||||
@@ -57,6 +60,7 @@
|
|||||||
lastFlushAttemptAt: null,
|
lastFlushAttemptAt: null,
|
||||||
lastSuccessfulFlushAt: null
|
lastSuccessfulFlushAt: null
|
||||||
};
|
};
|
||||||
|
let lastOfflineSyncFlush: number | null = null;
|
||||||
let exportAfterFlush = false;
|
let exportAfterFlush = false;
|
||||||
let exportInFlight = false;
|
let exportInFlight = false;
|
||||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -128,7 +132,6 @@
|
|||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Derive from pokedex config
|
// Derive from pokedex config
|
||||||
$: showOrigins = !!pokedex?.isOriginDex;
|
$: showOrigins = !!pokedex?.isOriginDex;
|
||||||
$: showShiny = !!pokedex?.isShinyDex;
|
$: showShiny = !!pokedex?.isShinyDex;
|
||||||
@@ -178,6 +181,15 @@
|
|||||||
|
|
||||||
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
||||||
catchWriteStatus = s;
|
catchWriteStatus = s;
|
||||||
|
if (
|
||||||
|
s.lastSuccessfulFlushAt &&
|
||||||
|
s.lastSuccessfulFlushAt !== lastOfflineSyncFlush &&
|
||||||
|
s.pending === 0 &&
|
||||||
|
s.inFlight === 0
|
||||||
|
) {
|
||||||
|
lastOfflineSyncFlush = s.lastSuccessfulFlushAt;
|
||||||
|
requestOfflineSync();
|
||||||
|
}
|
||||||
if (s.pending > 0 || s.inFlight > 0) {
|
if (s.pending > 0 || s.inFlight > 0) {
|
||||||
if (exportTimer) {
|
if (exportTimer) {
|
||||||
clearTimeout(exportTimer);
|
clearTimeout(exportTimer);
|
||||||
@@ -190,8 +202,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
|
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
|
||||||
if (!combinedData) return;
|
if (!combinedData) return;
|
||||||
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
|
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
|
||||||
@@ -212,7 +222,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleModalCatchUpdate(event: any) {
|
async function handleModalCatchUpdate(event: CatchUpdateEvent) {
|
||||||
await updateACatch(event);
|
await updateACatch(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,20 +253,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
combinedData = fetchedData.combinedData;
|
combinedData = fetchedData.combinedData;
|
||||||
totalPages = fetchedData.totalPages || 0;
|
|
||||||
// Always extract box numbers for box view
|
// Always extract box numbers for box view
|
||||||
if (combinedData) {
|
if (combinedData) {
|
||||||
boxNumbers = calculateBoxNumbers(combinedData.length);
|
boxNumbers = calculateBoxNumbers(combinedData.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateACatch(event: any) {
|
async function updateACatch(event: CatchUpdateEvent) {
|
||||||
if (!pokedexId) return;
|
if (!pokedexId) return;
|
||||||
ensureCatchWriteQueue();
|
ensureCatchWriteQueue();
|
||||||
const { catchRecord, source } = event.detail as {
|
const { catchRecord, source } = event.detail;
|
||||||
catchRecord: CatchRecord;
|
|
||||||
source: 'toggle' | 'notes' | 'notes-blur';
|
|
||||||
};
|
|
||||||
// Enforce mutual exclusivity (should be impossible to have both true).
|
// Enforce mutual exclusivity (should be impossible to have both true).
|
||||||
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
const sanitizedCatchRecord: CatchRecord = { ...catchRecord };
|
||||||
if (sanitizedCatchRecord.caught) {
|
if (sanitizedCatchRecord.caught) {
|
||||||
@@ -464,7 +470,6 @@
|
|||||||
window.clearInterval(reconcileInterval);
|
window.clearInterval(reconcileInterval);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -599,7 +604,6 @@
|
|||||||
{#if pokedex.description}
|
{#if pokedex.description}
|
||||||
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
|
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side: Actions -->
|
<!-- Right side: Actions -->
|
||||||
@@ -637,6 +641,7 @@
|
|||||||
bind:combinedData
|
bind:combinedData
|
||||||
bind:boxNumbers
|
bind:boxNumbers
|
||||||
bind:creatingRecords
|
bind:creatingRecords
|
||||||
|
{totalRecordsCreated}
|
||||||
bind:failedToLoad
|
bind:failedToLoad
|
||||||
{markBoxAsNotCaught}
|
{markBoxAsNotCaught}
|
||||||
{markBoxAsCaught}
|
{markBoxAsCaught}
|
||||||
|
|||||||
@@ -1,42 +1,74 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { user } from '$lib/stores/user.js';
|
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { User } from '@supabase/auth-js';
|
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase } = data;
|
let { supabase } = data;
|
||||||
$: ({ supabase } = data);
|
$: ({ supabase } = data);
|
||||||
|
|
||||||
let localUser: User | null = null;
|
|
||||||
const unsubscribe = user.subscribe((value) => {
|
|
||||||
localUser = value;
|
|
||||||
});
|
|
||||||
onDestroy(unsubscribe);
|
|
||||||
|
|
||||||
let showAnimation = false;
|
let showAnimation = false;
|
||||||
let hasCheckedAuth = false;
|
let authReady = false;
|
||||||
|
let authChecking = true;
|
||||||
|
let authError = '';
|
||||||
|
const recoveryMarkerKey = 'livingdex:password-recovery';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Start animation
|
const animationTimer = setTimeout(() => {
|
||||||
setTimeout(() => {
|
|
||||||
showAnimation = true;
|
showAnimation = true;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
let settled = false;
|
||||||
// Check authentication after a short delay to allow Supabase to process the token
|
const settle = (hasSession: boolean) => {
|
||||||
setTimeout(() => {
|
if (settled && !hasSession) return;
|
||||||
hasCheckedAuth = true;
|
settled = hasSession;
|
||||||
if (!localUser) {
|
authReady = hasSession;
|
||||||
goto('/signin');
|
authChecking = false;
|
||||||
|
authError = hasSession
|
||||||
|
? ''
|
||||||
|
: 'This password-reset link is invalid or expired. Request a new link and try again.';
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data: { subscription }
|
||||||
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
if (event === 'PASSWORD_RECOVERY' && session) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
recoveryMarkerKey,
|
||||||
|
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||||
|
);
|
||||||
|
settle(true);
|
||||||
}
|
}
|
||||||
}, 500);
|
});
|
||||||
|
void supabase.auth.getSession().then(({ data: { session }, error }) => {
|
||||||
|
if (error) {
|
||||||
|
authChecking = false;
|
||||||
|
authError = error.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let marker: { userId?: string; expiresAt?: number } | null = null;
|
||||||
|
try {
|
||||||
|
marker = JSON.parse(sessionStorage.getItem(recoveryMarkerKey) ?? 'null');
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
|
}
|
||||||
|
const suppliedRecoveryIntent = data.recoveryIntent === true;
|
||||||
|
const isRecoverySession =
|
||||||
|
!!session &&
|
||||||
|
(suppliedRecoveryIntent ||
|
||||||
|
(marker?.userId === session.user.id && Number(marker.expiresAt) > Date.now()));
|
||||||
|
if (session && suppliedRecoveryIntent) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
recoveryMarkerKey,
|
||||||
|
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isRecoverySession) sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
|
settle(isRecoverySession);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
clearTimeout(animationTimer);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reactive: redirect if user becomes null after initial check
|
|
||||||
$: if (hasCheckedAuth && !localUser) {
|
|
||||||
goto('/signin');
|
|
||||||
}
|
|
||||||
|
|
||||||
let password = '';
|
let password = '';
|
||||||
let confirmPassword = '';
|
let confirmPassword = '';
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
@@ -44,6 +76,7 @@
|
|||||||
let successMessage = '';
|
let successMessage = '';
|
||||||
|
|
||||||
async function updatePassword() {
|
async function updatePassword() {
|
||||||
|
if (!authReady) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
@@ -74,11 +107,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
successMessage = 'Password updated successfully! Redirecting to sign in...';
|
successMessage = 'Password updated successfully! Redirecting to sign in...';
|
||||||
|
sessionStorage.removeItem(recoveryMarkerKey);
|
||||||
// Redirect to sign in after a short delay
|
setTimeout(() => void finishPasswordReset(), 1_000);
|
||||||
setTimeout(() => {
|
|
||||||
goto('/signin');
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update password error:', err);
|
console.error('Update password error:', err);
|
||||||
errorMessage = 'An unexpected error occurred. Please try again.';
|
errorMessage = 'An unexpected error occurred. Please try again.';
|
||||||
@@ -87,6 +117,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finishPasswordReset() {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
if (error) {
|
||||||
|
errorMessage = `Password updated, but sign out failed: ${error.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await goto('/signin');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out after password reset failed:', error);
|
||||||
|
errorMessage = 'Password updated, but sign out failed. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeyPress(event: KeyboardEvent) {
|
function handleKeyPress(event: KeyboardEvent) {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
updatePassword();
|
updatePassword();
|
||||||
@@ -132,6 +176,11 @@
|
|||||||
<!-- Reset Password Card -->
|
<!-- Reset Password Card -->
|
||||||
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
|
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
|
||||||
<div class="card-body p-6 md:p-8">
|
<div class="card-body p-6 md:p-8">
|
||||||
|
{#if authChecking}
|
||||||
|
<div class="alert"><span>Validating your password-reset link…</span></div>
|
||||||
|
{:else if authError}
|
||||||
|
<div class="alert alert-error" role="alert"><span>{authError}</span></div>
|
||||||
|
{/if}
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<div class="alert alert-error text-sm">
|
<div class="alert alert-error text-sm">
|
||||||
@@ -185,7 +234,7 @@
|
|||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
on:keypress={handleKeyPress}
|
on:keypress={handleKeyPress}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !authReady}
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -215,7 +264,7 @@
|
|||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
on:keypress={handleKeyPress}
|
on:keypress={handleKeyPress}
|
||||||
disabled={isLoading}
|
disabled={isLoading || !authReady}
|
||||||
/>
|
/>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -237,7 +286,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary w-full"
|
class="btn btn-primary w-full"
|
||||||
on:click={updatePassword}
|
on:click={updatePassword}
|
||||||
disabled={isLoading || !password || !confirmPassword}
|
disabled={isLoading || !authReady || !password || !confirmPassword}
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const META_CACHE = 'livingdex-offline-meta-v1';
|
||||||
|
const META_URL = '/__offline/current';
|
||||||
|
|
||||||
|
function element(tag, className, text) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(record) {
|
||||||
|
if (!record) return 'Not caught';
|
||||||
|
const values = [];
|
||||||
|
if (record.caught) values.push('Caught');
|
||||||
|
if (record.haveToEvolve) values.push('Needs evolution');
|
||||||
|
if (record.inHome) values.push('In HOME');
|
||||||
|
return values.join(' · ') || 'Not caught';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSnapshot(snapshot) {
|
||||||
|
const content = document.querySelector('#offline-content');
|
||||||
|
for (const { pokedex, entries } of snapshot.pokedexes) {
|
||||||
|
const section = element('section', 'card bg-base-100 mb-6 shadow');
|
||||||
|
const body = element('div', 'card-body');
|
||||||
|
body.append(element('h2', 'card-title text-2xl', pokedex.name));
|
||||||
|
body.append(element('p', 'text-sm opacity-70', `${entries.length} entries`));
|
||||||
|
const grid = element(
|
||||||
|
'div',
|
||||||
|
'grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-6'
|
||||||
|
);
|
||||||
|
for (const { pokedexEntry: entry, catchRecord } of entries) {
|
||||||
|
const card = element('article', 'rounded border border-base-300 p-2');
|
||||||
|
const image = element('img', 'mx-auto h-20 w-20 object-contain');
|
||||||
|
image.alt = `${entry.pokemon}${entry.form ? ` — ${entry.form}` : ''}`;
|
||||||
|
image.src = entry.offlineSpriteUrl ?? '/placeholder-bulb.png';
|
||||||
|
image.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
image.src = '/placeholder-bulb.png';
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
card.append(image);
|
||||||
|
card.append(element('h3', 'font-semibold', `#${entry.pokedexNumber} ${entry.pokemon}`));
|
||||||
|
if (entry.form) card.append(element('p', 'text-xs opacity-70', entry.form));
|
||||||
|
card.append(element('p', 'text-sm', statusText(catchRecord)));
|
||||||
|
if (catchRecord?.personalNotes)
|
||||||
|
card.append(element('p', 'mt-1 whitespace-pre-wrap text-xs', catchRecord.personalNotes));
|
||||||
|
grid.append(card);
|
||||||
|
}
|
||||||
|
body.append(grid);
|
||||||
|
section.append(body);
|
||||||
|
content.append(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const status = document.querySelector('#offline-status');
|
||||||
|
try {
|
||||||
|
const meta = await (await (await caches.open(META_CACHE)).match(META_URL))?.json();
|
||||||
|
if (!meta?.userId) throw new Error('No collection has been synchronized on this device.');
|
||||||
|
if (!meta.dataCache) throw new Error('The saved collection metadata is incomplete.');
|
||||||
|
const dataCache = await caches.open(meta.dataCache);
|
||||||
|
const response = await dataCache.match(
|
||||||
|
`/__offline/snapshot/${encodeURIComponent(meta.userId)}`
|
||||||
|
);
|
||||||
|
if (!response)
|
||||||
|
throw new Error('The saved collection is incomplete. Reconnect and synchronize again.');
|
||||||
|
const snapshot = await response.json();
|
||||||
|
if (snapshot.version !== 1 || snapshot.userId !== meta.userId)
|
||||||
|
throw new Error('The saved collection is incompatible with this app version.');
|
||||||
|
status.textContent = `Saved ${new Date(snapshot.generatedAt).toLocaleString()}.`;
|
||||||
|
renderSnapshot(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
status.className = 'alert alert-warning';
|
||||||
|
status.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
navigator.serviceWorker?.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type !== 'OFFLINE_DATA_CLEARED') return;
|
||||||
|
const content = document.querySelector('#offline-content');
|
||||||
|
if (content) content.replaceChildren();
|
||||||
|
const status = document.querySelector('#offline-status');
|
||||||
|
if (status) {
|
||||||
|
status.className = 'alert alert-warning';
|
||||||
|
status.textContent =
|
||||||
|
'The saved collection was removed because the account changed or signed out.';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||||
|
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||||
|
const OFFLINE_META_URL = '/__offline/current';
|
||||||
|
let offlineEpoch = 0;
|
||||||
|
let offlineOperation = Promise.resolve();
|
||||||
|
let claimedUserId = null;
|
||||||
|
|
||||||
|
function queueOfflineOperation(operation) {
|
||||||
|
const result = offlineOperation.then(operation, operation);
|
||||||
|
offlineOperation = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataCacheName(userId, generation) {
|
||||||
|
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artworkCacheName(userId, generation) {
|
||||||
|
return `${OFFLINE_CACHE_PREFIX}art-v1-${userId}-${generation}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentOfflineMeta() {
|
||||||
|
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||||
|
if (!response) return null;
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
return typeof data?.userId === 'string' ? data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearOfflineData() {
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyOfflineDataCleared() {
|
||||||
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||||
|
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheArtwork(cache, urls) {
|
||||||
|
let next = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const workers = Array.from({ length: Math.min(6, urls.length) }, async () => {
|
||||||
|
for (;;) {
|
||||||
|
const index = next++;
|
||||||
|
if (index >= urls.length) return;
|
||||||
|
const url = urls[index];
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
mode: url.startsWith(self.location.origin) ? 'same-origin' : 'no-cors'
|
||||||
|
});
|
||||||
|
if (!response.ok && response.type !== 'opaque') throw new Error(`HTTP ${response.status}`);
|
||||||
|
await cache.put(url, response);
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
const reply = (value) => event.ports[0]?.postMessage(value);
|
||||||
|
if (event.data?.type === 'CLEAR_OFFLINE_DATA') {
|
||||||
|
claimedUserId = null;
|
||||||
|
offlineEpoch++;
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
await clearOfflineData();
|
||||||
|
await notifyOfflineDataCleared();
|
||||||
|
})
|
||||||
|
.then(() => reply({ ok: true }))
|
||||||
|
.catch((error) => reply({ ok: false, error: String(error) }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data?.type === 'CLAIM_OFFLINE_USER') {
|
||||||
|
if (typeof event.data.userId !== 'string') {
|
||||||
|
reply({ ok: false, error: 'Invalid offline cache owner' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
claimedUserId = event.data.userId;
|
||||||
|
offlineEpoch++;
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
const meta = await currentOfflineMeta();
|
||||||
|
if (meta?.userId && meta.userId !== event.data.userId) {
|
||||||
|
await clearOfflineData();
|
||||||
|
await notifyOfflineDataCleared();
|
||||||
|
}
|
||||||
|
reply({ ok: true });
|
||||||
|
}).catch((error) => reply({ ok: false, error: String(error) }))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
|
||||||
|
const syncEpoch = offlineEpoch;
|
||||||
|
const syncUserId = claimedUserId;
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
queueOfflineOperation(async () => {
|
||||||
|
let nextData;
|
||||||
|
let nextArtwork;
|
||||||
|
try {
|
||||||
|
const snapshot = event.data.snapshot;
|
||||||
|
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
|
||||||
|
throw new Error('Unsupported offline snapshot');
|
||||||
|
}
|
||||||
|
if (!syncUserId || snapshot.userId !== syncUserId) {
|
||||||
|
throw new Error('Offline snapshot owner did not match the claimed account');
|
||||||
|
}
|
||||||
|
const previousMeta = await currentOfflineMeta();
|
||||||
|
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
|
||||||
|
await clearOfflineData();
|
||||||
|
|
||||||
|
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
|
||||||
|
const generation = `${timestamp}-${crypto.randomUUID()}`;
|
||||||
|
nextData = dataCacheName(snapshot.userId, generation);
|
||||||
|
const dataCache = await caches.open(nextData);
|
||||||
|
await dataCache.put(
|
||||||
|
`/__offline/snapshot/${encodeURIComponent(snapshot.userId)}`,
|
||||||
|
new Response(JSON.stringify(snapshot), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
nextArtwork = artworkCacheName(snapshot.userId, generation);
|
||||||
|
const artworkCache = await caches.open(nextArtwork);
|
||||||
|
const failedArtwork = await cacheArtwork(
|
||||||
|
artworkCache,
|
||||||
|
Array.from(new Set(event.data.artworkUrls ?? []))
|
||||||
|
);
|
||||||
|
if (syncEpoch !== offlineEpoch) {
|
||||||
|
await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
|
||||||
|
throw new Error('Offline synchronization was superseded by an account change');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaCache = await caches.open(OFFLINE_META_CACHE);
|
||||||
|
await metaCache.put(
|
||||||
|
OFFLINE_META_URL,
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
userId: snapshot.userId,
|
||||||
|
generatedAt: snapshot.generatedAt,
|
||||||
|
dataCache: nextData,
|
||||||
|
artworkCache: nextArtwork
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (syncEpoch !== offlineEpoch) {
|
||||||
|
const current = await currentOfflineMeta();
|
||||||
|
if (current?.dataCache === nextData) await metaCache.delete(OFFLINE_META_URL);
|
||||||
|
await Promise.all([caches.delete(nextData), caches.delete(nextArtwork)]);
|
||||||
|
throw new Error('Offline synchronization was superseded by an account change');
|
||||||
|
}
|
||||||
|
if (previousMeta?.artworkCache && previousMeta.artworkCache !== nextArtwork) {
|
||||||
|
await caches.delete(previousMeta.artworkCache);
|
||||||
|
}
|
||||||
|
if (previousMeta?.dataCache && previousMeta.dataCache !== nextData) {
|
||||||
|
await caches.delete(previousMeta.dataCache);
|
||||||
|
}
|
||||||
|
const currentCaches = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
currentCaches
|
||||||
|
.filter(
|
||||||
|
(name) =>
|
||||||
|
name.startsWith(OFFLINE_CACHE_PREFIX) &&
|
||||||
|
![OFFLINE_META_CACHE, nextData, nextArtwork].includes(name)
|
||||||
|
)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
reply({ ok: true, failedArtwork });
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[nextData, nextArtwork].filter(Boolean).map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.mode === 'navigate' && !['/offline', '/offline.html'].includes(url.pathname)) {
|
||||||
|
const fallback = async () =>
|
||||||
|
(await caches.match('/offline', { ignoreSearch: true })) ??
|
||||||
|
(await caches.match('/offline.html', { ignoreSearch: true })) ??
|
||||||
|
Response.error();
|
||||||
|
event.respondWith(
|
||||||
|
self.navigator.onLine
|
||||||
|
? fetch(new Request(event.request, { cache: 'no-store' })).catch(fallback)
|
||||||
|
: fallback()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.request.destination !== 'image') return;
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const meta = await currentOfflineMeta();
|
||||||
|
if (meta?.artworkCache) {
|
||||||
|
const cached = await (
|
||||||
|
await caches.open(meta.artworkCache)
|
||||||
|
).match(event.request, {
|
||||||
|
ignoreSearch: true
|
||||||
|
});
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
return fetch(event.request);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#f00000" />
|
||||||
|
<title>Living Dex Tracker — Offline</title>
|
||||||
|
<link rel="stylesheet" href="/output.css" />
|
||||||
|
<script type="module" src="/offline-viewer.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-base-200 text-base-content">
|
||||||
|
<header class="navbar bg-primary text-primary-content">
|
||||||
|
<div class="mx-auto w-full max-w-screen-2xl px-4 text-xl font-semibold">
|
||||||
|
Living Dex Tracker
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-screen-2xl p-4">
|
||||||
|
<div class="alert mb-4" role="status">
|
||||||
|
<span
|
||||||
|
>You are offline. This is a read-only copy; changes, exports, and account actions are
|
||||||
|
disabled.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p id="offline-status" class="mb-4">Loading the saved collection…</p>
|
||||||
|
<div id="offline-content"></div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -119,7 +119,8 @@ enabled = true
|
|||||||
# in emails.
|
# in emails.
|
||||||
site_url = "http://localhost:5173"
|
site_url = "http://localhost:5173"
|
||||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||||
additional_redirect_urls = ["http://localhost:5173"]
|
# 4173 is the preview server the BDD suite runs against; recovery links redirect there.
|
||||||
|
additional_redirect_urls = ["http://localhost:5173", "http://localhost:4173"]
|
||||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||||
jwt_expiry = 3600
|
jwt_expiry = 3600
|
||||||
# If disabled, the refresh token will never expire.
|
# If disabled, the refresh token will never expire.
|
||||||
@@ -149,7 +150,8 @@ anonymous_users = 30
|
|||||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||||
token_refresh = 150
|
token_refresh = 150
|
||||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||||
sign_in_sign_ups = 30
|
# Raised for the BDD suite: every scenario provisions and signs in its own user from one IP.
|
||||||
|
sign_in_sign_ups = 300
|
||||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||||
token_verifications = 30
|
token_verifications = 30
|
||||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||||
|
|||||||
+6
-12
@@ -1,7 +1,7 @@
|
|||||||
import adapter from '@sveltejs/adapter-netlify';
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
// you don't need to do this if you're using generateSW strategy in your app
|
// you don't need to do this if you're using generateSW strategy in your app
|
||||||
import { generateSW } from './pwa.mjs';
|
import { generateSW } from './pwa.mjs';
|
||||||
|
import { adapter } from './adapter.mjs';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
@@ -10,18 +10,12 @@ const config = {
|
|||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter({
|
// Netlify by default, or the node adapter when NODE_ADAPTER=true. See adapter.mjs.
|
||||||
// if true, will create a Netlify Edge Function rather
|
adapter,
|
||||||
// than using standard Node-based functions
|
|
||||||
edge: false,
|
|
||||||
|
|
||||||
// if true, will split your app into multiple functions
|
|
||||||
// instead of creating a single one for the entire app.
|
|
||||||
// if `edge` is true, this option cannot be used
|
|
||||||
split: false
|
|
||||||
}),
|
|
||||||
serviceWorker: {
|
serviceWorker: {
|
||||||
register: true
|
// VitePWA owns registration. Registering here as well requests SvelteKit's default
|
||||||
|
// /service-worker.js even though the inject-manifest output is /prompt-sw.js.
|
||||||
|
register: false
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
// you don't need to do this if you're using generateSW strategy in your app
|
// you don't need to do this if you're using generateSW strategy in your app
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
import { generateSW } from '../pwa.mjs'
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
import { nodeAdapter } from '../adapter.mjs'
|
|
||||||
|
|
||||||
describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
|
|
||||||
it(`service worker is generated: ${generateSW ? 'sw.js' : 'prompt-sw.js'}`, () => {
|
|
||||||
const swName = `./build/${nodeAdapter ? 'client/': ''}${generateSW ? 'sw.js' : 'prompt-sw.js'}`
|
|
||||||
expect(existsSync(swName), `${swName} doesn't exist`).toBeTruthy()
|
|
||||||
const webManifest = `./build/${nodeAdapter ? 'client/': ''}manifest.webmanifest`
|
|
||||||
expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy()
|
|
||||||
const swContent = readFileSync(swName, 'utf-8')
|
|
||||||
let match: RegExpMatchArray | null
|
|
||||||
if (generateSW) {
|
|
||||||
match = swContent.match(/define\(\['\.\/(workbox-\w+)'/)
|
|
||||||
expect(match && match.length === 2, `workbox-***.js entry not found in ${swName}`).toBeTruthy()
|
|
||||||
const workboxName = `./build/${nodeAdapter ? 'client/': ''}${match?.[1]}.js`
|
|
||||||
expect(existsSync(workboxName),`${workboxName} doesn't exist`).toBeTruthy()
|
|
||||||
}
|
|
||||||
match = swContent.match(/"url":\s*"manifest\.webmanifest"/)
|
|
||||||
expect(match && match.length === 1, 'missing manifest.webmanifest in sw precache manifest').toBeTruthy()
|
|
||||||
match = swContent.match(/"url":\s*"\/"/)
|
|
||||||
expect(match && match.length === 1, 'missing entry point route (/) in sw precache manifest').toBeTruthy()
|
|
||||||
match = swContent.match(/"url":\s*"about"/)
|
|
||||||
expect(match && match.length === 1,'missing about route (/about) in sw precache manifest').toBeTruthy()
|
|
||||||
if (nodeAdapter) {
|
|
||||||
match = swContent.match(/"url":\s*"server\//)
|
|
||||||
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { createCatchRecordWriteQueue } from '../src/lib/utils/catchRecordWriteQueue';
|
|
||||||
import type { CatchRecord } from '../src/lib/models/CatchRecord';
|
|
||||||
|
|
||||||
function mkRecord(overrides: Partial<CatchRecord> = {}): CatchRecord {
|
|
||||||
return {
|
|
||||||
_id: '',
|
|
||||||
userId: 'u1',
|
|
||||||
pokedexId: 'p1',
|
|
||||||
pokemonId: '25',
|
|
||||||
haveToEvolve: false,
|
|
||||||
caught: false,
|
|
||||||
inHome: false,
|
|
||||||
hasGigantamaxed: false,
|
|
||||||
personalNotes: '',
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createCatchRecordWriteQueue()', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coalesces multiple updates for the same key and flushes only the latest state', async () => {
|
|
||||||
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
return new Response(init?.body as string, { status: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const queue = createCatchRecordWriteQueue({
|
|
||||||
endpointUrl: '/api/pokedexes/p1/catch-records',
|
|
||||||
fetchFn,
|
|
||||||
batchSize: 50,
|
|
||||||
concurrency: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.enqueue(mkRecord({ caught: true }));
|
|
||||||
queue.enqueue(mkRecord({ caught: false, haveToEvolve: true }));
|
|
||||||
|
|
||||||
await queue.flushNow();
|
|
||||||
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
||||||
const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body));
|
|
||||||
expect(body).toHaveLength(1);
|
|
||||||
expect(body[0].haveToEvolve).toBe(true);
|
|
||||||
expect(body[0].caught).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('debounces notes updates before flushing', async () => {
|
|
||||||
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
return new Response(init?.body as string, { status: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const queue = createCatchRecordWriteQueue({
|
|
||||||
endpointUrl: '/api/pokedexes/p1/catch-records',
|
|
||||||
fetchFn,
|
|
||||||
batchSize: 50,
|
|
||||||
concurrency: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 });
|
|
||||||
await queue.flushNow();
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(0);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(499);
|
|
||||||
await queue.flushNow();
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(0);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(1);
|
|
||||||
await queue.flushNow();
|
|
||||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
Feature: Account access
|
||||||
|
As a trainer
|
||||||
|
I want secure access to my account
|
||||||
|
So that only I can update my collection
|
||||||
|
|
||||||
|
Scenario: Register a new account
|
||||||
|
Given I am a new visitor
|
||||||
|
When I register with valid account details
|
||||||
|
Then I am told to confirm my email
|
||||||
|
And a confirmation email is captured locally
|
||||||
|
|
||||||
|
Scenario: Sign in with valid credentials
|
||||||
|
Given I have a confirmed account
|
||||||
|
When I sign in with my credentials
|
||||||
|
Then I arrive at my Pokédex list
|
||||||
|
|
||||||
|
Scenario: Reject invalid credentials
|
||||||
|
Given I have a confirmed account
|
||||||
|
When I sign in with an incorrect password
|
||||||
|
Then I see a sign-in error
|
||||||
|
|
||||||
|
Scenario: Redirect an authenticated visitor
|
||||||
|
Given I am signed in
|
||||||
|
When I visit the public home page
|
||||||
|
Then I arrive at my Pokédex list
|
||||||
|
|
||||||
|
Scenario: Sign out
|
||||||
|
Given I am signed in
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When I sign out
|
||||||
|
Then I return to the public home page
|
||||||
|
And my offline copy is removed
|
||||||
|
|
||||||
|
Scenario: Keep the session when sign out fails
|
||||||
|
Given I am signed in
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When the sign-out request fails
|
||||||
|
Then I remain signed in with an error
|
||||||
|
And my offline copy remains
|
||||||
|
|
||||||
|
Scenario: Request a password reset
|
||||||
|
Given I have a confirmed account
|
||||||
|
When I request a password reset
|
||||||
|
Then a password reset email is captured locally
|
||||||
|
|
||||||
|
Scenario: Reject a normal session on the recovery page
|
||||||
|
Given I am signed in
|
||||||
|
When I visit the password recovery page directly
|
||||||
|
Then the replacement password form is unavailable
|
||||||
|
|
||||||
|
@product-review
|
||||||
|
Scenario: Reject mismatched replacement passwords
|
||||||
|
Given I follow a valid password reset link
|
||||||
|
When I enter two different replacement passwords
|
||||||
|
Then I am told that the passwords do not match
|
||||||
|
|
||||||
|
Scenario: Complete a password reset
|
||||||
|
Given I follow a valid password reset link
|
||||||
|
When I enter a valid replacement password
|
||||||
|
Then I am told that my password was updated
|
||||||
|
And only the replacement password signs me in
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
Feature: Backup and export
|
||||||
|
As a trainer
|
||||||
|
I want changes exported to my connected storage
|
||||||
|
So that I retain a portable backup
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
|
||||||
|
Scenario: Show disconnected backup providers
|
||||||
|
When I visit backup settings
|
||||||
|
Then Google Drive and Dropbox are shown as not connected
|
||||||
|
|
||||||
|
Scenario Outline: Connect a backup provider
|
||||||
|
When I connect the mocked "<provider>" provider
|
||||||
|
Then "<provider>" is shown as connected
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| provider |
|
||||||
|
| Google Drive |
|
||||||
|
| Dropbox |
|
||||||
|
|
||||||
|
Scenario: Reject an invalid OAuth state
|
||||||
|
When a mocked OAuth callback has an invalid state
|
||||||
|
Then the backup connection is rejected
|
||||||
|
|
||||||
|
Scenario: Export escaped catch data without losing the update
|
||||||
|
Given Google Drive is connected to the mocked provider
|
||||||
|
And I have a Living Dex named "Quoted, Dex"
|
||||||
|
When I save a catch note containing a comma and quote
|
||||||
|
Then the mocked provider receives a valid escaped CSV
|
||||||
|
And the note survives a reload
|
||||||
|
|
||||||
|
Scenario: Refresh an expired provider token
|
||||||
|
Given Dropbox is connected with an expired token
|
||||||
|
When an export is requested
|
||||||
|
Then the token is refreshed before the mocked upload
|
||||||
|
|
||||||
|
Scenario: Record provider failure without losing progress
|
||||||
|
Given Google Drive is connected to a failing mocked provider
|
||||||
|
When I update collection progress
|
||||||
|
Then the catch remains marked caught
|
||||||
|
And the provider failure is shown in backup settings
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
Feature: Pokédex composition
|
||||||
|
As a trainer
|
||||||
|
I want the correct Pokémon in each configured dex
|
||||||
|
So that completion totals are trustworthy
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
|
||||||
|
Scenario: Build a national Living Dex from canonical forms
|
||||||
|
Given I have a Living Dex named "National"
|
||||||
|
When I inspect its entries without forms
|
||||||
|
Then it contains 1025 unique species
|
||||||
|
And named default forms are represented once
|
||||||
|
|
||||||
|
Scenario: Include all supported forms
|
||||||
|
Given I have a Form Dex named "Forms"
|
||||||
|
When I inspect its entries with forms
|
||||||
|
Then every entry identity is unique
|
||||||
|
And Basculin has 3 forms
|
||||||
|
And Alcremie has 63 forms
|
||||||
|
And Unown has 28 forms
|
||||||
|
|
||||||
|
Scenario: Render shiny artwork
|
||||||
|
Given I have a Shiny Dex named "Shinies"
|
||||||
|
When I view the Pokédex
|
||||||
|
Then its Pokémon use shiny sprites
|
||||||
|
|
||||||
|
Scenario: Respect game and dex scope
|
||||||
|
Given I have a Form Dex named "Black Forms" scoped to game "Black" and dex "Unova"
|
||||||
|
When I inspect its entries with forms
|
||||||
|
Then Rotom includes its named default form without duplicate forms
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
Feature: Pokédex lifecycle
|
||||||
|
As a trainer
|
||||||
|
I want to configure and manage Pokédexes
|
||||||
|
So that each collection matches my goal
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
|
||||||
|
Scenario: See the empty state
|
||||||
|
Given I have no Pokédexes
|
||||||
|
When I visit my Pokédex list
|
||||||
|
Then I see the empty Pokédex message
|
||||||
|
|
||||||
|
Scenario: Validate a new Pokédex
|
||||||
|
When I open the new Pokédex form
|
||||||
|
Then I cannot create a Pokédex without a name and type
|
||||||
|
|
||||||
|
Scenario Outline: Create each supported Pokédex type
|
||||||
|
When I create a Pokédex named "<name>" of type "<type>"
|
||||||
|
Then the Pokédex "<name>" is available to view
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| name | type |
|
||||||
|
| Living | Living Dex |
|
||||||
|
| Shiny | Shiny Dex |
|
||||||
|
| Origin | Origin Dex |
|
||||||
|
| Every Form | Form Dex |
|
||||||
|
|
||||||
|
Scenario: Create a game and dex scoped Pokédex
|
||||||
|
When I create a Living Dex named "Black Regional" scoped to game "Black" and dex "Unova"
|
||||||
|
Then the Pokédex "Black Regional" is available to view
|
||||||
|
|
||||||
|
Scenario: Reject a duplicate name
|
||||||
|
Given I have a Living Dex named "My Collection"
|
||||||
|
When I try to create another Living Dex named "My Collection"
|
||||||
|
Then I am told that the Pokédex name is already used
|
||||||
|
|
||||||
|
Scenario: Edit a Pokédex
|
||||||
|
Given I have a Living Dex named "Before Editing"
|
||||||
|
When I rename it to "After Editing" and enable forms
|
||||||
|
Then the Pokédex "After Editing" is available to view
|
||||||
|
|
||||||
|
Scenario: Cancel deleting a Pokédex
|
||||||
|
Given I have a Living Dex named "Keep Me"
|
||||||
|
When I cancel deleting "Keep Me"
|
||||||
|
Then the Pokédex "Keep Me" is available to view
|
||||||
|
|
||||||
|
Scenario: Delete a Pokédex
|
||||||
|
Given I have a Living Dex named "Delete Me"
|
||||||
|
When I confirm deleting "Delete Me"
|
||||||
|
Then the Pokédex "Delete Me" is no longer listed
|
||||||
|
|
||||||
|
Scenario: Keep another user's Pokédex private
|
||||||
|
Given another trainer has a Pokédex
|
||||||
|
When I request the other trainer's Pokédex
|
||||||
|
Then the Pokédex is not disclosed
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
Feature: Progress tracking
|
||||||
|
As a trainer
|
||||||
|
I want to record collection state
|
||||||
|
So that my Pokédex shows what remains
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given I am signed in
|
||||||
|
And I have a Living Dex named "Progress"
|
||||||
|
And I view the Pokédex
|
||||||
|
|
||||||
|
Scenario: Mark a Pokémon caught
|
||||||
|
When I mark the first Pokémon as caught
|
||||||
|
Then the first Pokémon is shown as caught after reloading
|
||||||
|
|
||||||
|
Scenario: Keep caught and needs-to-evolve mutually exclusive
|
||||||
|
When I mark the first Pokémon as caught
|
||||||
|
And I mark the first Pokémon as needing evolution
|
||||||
|
Then the first Pokémon needs evolution and is not marked caught
|
||||||
|
|
||||||
|
Scenario: Record HOME state and notes
|
||||||
|
When I mark the first Pokémon as in HOME
|
||||||
|
And I add the note "Caught, traded, and checked" to the first Pokémon
|
||||||
|
Then its HOME state and note persist after reloading
|
||||||
|
|
||||||
|
Scenario: Update an entire box
|
||||||
|
When I mark box 1 as caught
|
||||||
|
Then box 1 contains 30 caught Pokémon
|
||||||
|
|
||||||
|
Scenario: Filter collection progress
|
||||||
|
When I mark the first Pokémon as caught
|
||||||
|
And I filter to Pokémon that are not caught
|
||||||
|
Then the caught Pokémon is filtered out
|
||||||
|
|
||||||
|
Scenario: Remember box layout density
|
||||||
|
When I select the "Compact" box layout
|
||||||
|
And I reload the Pokédex
|
||||||
|
Then the "Compact" box layout remains selected
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
Feature: Offline-friendly application
|
||||||
|
As a trainer
|
||||||
|
I want the installed site to survive network loss
|
||||||
|
So that I can consult my collection anywhere
|
||||||
|
|
||||||
|
Scenario: Register the service worker
|
||||||
|
When I open the built application
|
||||||
|
Then a service worker controls the page
|
||||||
|
And the application shell is precached
|
||||||
|
And no legacy service worker is requested
|
||||||
|
|
||||||
|
Scenario: Reload the home page while offline
|
||||||
|
Given I have opened the built application online
|
||||||
|
When I go offline and reload the home page
|
||||||
|
Then the read-only offline viewer is available
|
||||||
|
|
||||||
|
Scenario: Reload a nested route while offline
|
||||||
|
Given I have opened the built application online
|
||||||
|
When I go offline and reload the sign-in page
|
||||||
|
Then the read-only offline viewer is available
|
||||||
|
|
||||||
|
Scenario: Read a synchronized collection offline
|
||||||
|
Given I am signed in
|
||||||
|
And I have a Living Dex named "Offline Collection"
|
||||||
|
And my offline copy is synchronized
|
||||||
|
When I go offline and reload the current Pokédex
|
||||||
|
Then the offline copy contains "Offline Collection"
|
||||||
|
|
||||||
|
Scenario: Restore network access
|
||||||
|
Given I have opened the built application online
|
||||||
|
When I go offline and then return online
|
||||||
|
Then the application remains available
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test as base } from 'playwright-bdd';
|
||||||
|
|
||||||
|
export type ScenarioState = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
replacementPassword: string;
|
||||||
|
userId: string | null;
|
||||||
|
pokedexId: string | null;
|
||||||
|
pokedexName: string | null;
|
||||||
|
entries: Array<{ pokemon: string; form: string | null; num: number; id: string }>;
|
||||||
|
lastResponseStatus: number | null;
|
||||||
|
lastMessage: string | null;
|
||||||
|
caughtEntryLabel: string | null;
|
||||||
|
legacyServiceWorkerRequested: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fixtures = { state: ScenarioState; providerMock: void };
|
||||||
|
|
||||||
|
const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199';
|
||||||
|
|
||||||
|
export const test = base.extend<Fixtures>({
|
||||||
|
/**
|
||||||
|
* The mock provider keeps recorded requests, the refresh counter and the fail-uploads switch
|
||||||
|
* in one process-wide object. Without a reset per scenario, assertions are satisfied by
|
||||||
|
* whatever ran before them - and the failing-upload scenario would poison every later one.
|
||||||
|
*/
|
||||||
|
providerMock: [
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
async ({}, use) => {
|
||||||
|
const response = await fetch(`${MOCK_URL}/__mock/reset`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Unable to reset the mock provider at ${MOCK_URL}: ${response.status}`);
|
||||||
|
}
|
||||||
|
await use();
|
||||||
|
},
|
||||||
|
{ auto: true }
|
||||||
|
],
|
||||||
|
// Playwright fixture callbacks require the dependency object even when this fixture has none.
|
||||||
|
// eslint-disable-next-line no-empty-pattern
|
||||||
|
state: async ({}, use, testInfo) => {
|
||||||
|
const slug = testInfo.testId
|
||||||
|
.replace(/[^a-z0-9]/gi, '')
|
||||||
|
.slice(-18)
|
||||||
|
.toLowerCase();
|
||||||
|
await use({
|
||||||
|
email: `bdd-${slug}-${Date.now()}@example.test`,
|
||||||
|
password: 'BddPassword123!',
|
||||||
|
replacementPassword: 'BddReplacement456!',
|
||||||
|
userId: null,
|
||||||
|
pokedexId: null,
|
||||||
|
pokedexName: null,
|
||||||
|
entries: [],
|
||||||
|
lastResponseStatus: null,
|
||||||
|
lastMessage: null,
|
||||||
|
caughtEntryLabel: null,
|
||||||
|
legacyServiceWorkerRequested: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { requireLoopbackUrl } from '../support/loopback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the users each BDD run provisions, so repeated local runs do not need a full
|
||||||
|
* `supabase db reset` to stay clean. Pokédexes, catch records and integrations follow via the
|
||||||
|
* schema's cascades. Only the suite's own synthetic addresses are touched.
|
||||||
|
*/
|
||||||
|
const SUPABASE_URL = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const OWNED_EMAIL = /^(bdd|other|integration)-.*@example\.test$/;
|
||||||
|
|
||||||
|
type AdminUser = { id: string; email?: string };
|
||||||
|
|
||||||
|
async function adminRequest(path: string, init: RequestInit = {}) {
|
||||||
|
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
if (!key) return null;
|
||||||
|
return fetch(`${SUPABASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init.headers ?? {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalTeardown() {
|
||||||
|
const listed = await adminRequest('/auth/v1/admin/users?per_page=1000');
|
||||||
|
if (!listed) {
|
||||||
|
console.warn('Skipping BDD teardown: E2E_SERVICE_ROLE_KEY is not set.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!listed.ok) {
|
||||||
|
console.warn(`Skipping BDD teardown: unable to list users (${listed.status}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { users = [] } = (await listed.json()) as { users?: AdminUser[] };
|
||||||
|
const disposable = users.filter((user) => user.email && OWNED_EMAIL.test(user.email));
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
for (const user of disposable) {
|
||||||
|
const deleted = await adminRequest(`/auth/v1/admin/users/${user.id}`, { method: 'DELETE' });
|
||||||
|
if (!deleted?.ok) failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`BDD teardown removed ${disposable.length - failures} of ${disposable.length} test users.`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { createConfirmedUser, signIn } from '../support/app';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
const MAILPIT_URL = process.env.TEST_MAILPIT_URL ?? 'http://127.0.0.1:54324';
|
||||||
|
|
||||||
|
async function mailCountFor(email: string, subject: string): Promise<number> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email} subject:${subject}`)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`MailPit is unavailable: ${response.status}`);
|
||||||
|
const body = (await response.json()) as { total?: number; messages?: unknown[] };
|
||||||
|
return body.total ?? body.messages?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoveryLinkFromMail(email: string): Promise<string> {
|
||||||
|
for (let attempt = 0; attempt < 50; attempt++) {
|
||||||
|
const search = await fetch(
|
||||||
|
`${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email} subject:Reset`)}`
|
||||||
|
);
|
||||||
|
if (search.ok) {
|
||||||
|
const result = (await search.json()) as { messages?: Array<{ ID?: string; Id?: string }> };
|
||||||
|
const id = result.messages?.[0]?.ID ?? result.messages?.[0]?.Id;
|
||||||
|
if (id) {
|
||||||
|
const response = await fetch(`${MAILPIT_URL}/api/v1/message/${id}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const message = JSON.stringify(await response.json());
|
||||||
|
const match = message.match(/https?:\/\/[^"'<>\s]+\/auth\/v1\/verify[^"'<>\s]+/);
|
||||||
|
if (match) return match[0].replaceAll('&', '&').replaceAll('\\u0026', '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
throw new Error('No password recovery link arrived in MailPit');
|
||||||
|
}
|
||||||
|
|
||||||
|
Given('I am a new visitor', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have a confirmed account', async ({ state }) => {
|
||||||
|
await createConfirmedUser(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I am signed in', async ({ page, state }) => {
|
||||||
|
await createConfirmedUser(state);
|
||||||
|
await signIn(page, state);
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I follow a valid password reset link', async ({ page, state }) => {
|
||||||
|
await createConfirmedUser(state);
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByRole('button', { name: 'Send Reset Link' }).click();
|
||||||
|
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
|
||||||
|
const actionLink = await recoveryLinkFromMail(state.email);
|
||||||
|
await page.goto(actionLink);
|
||||||
|
await expect(page).toHaveURL(/\/reset-password/);
|
||||||
|
await expect(page.getByLabel('New Password')).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I register with valid account details', async ({ page, state }) => {
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByLabel('Password').fill(state.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign Up', exact: true }).first().click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I sign in with my credentials', async ({ page, state }) => {
|
||||||
|
await signIn(page, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I sign in with an incorrect password', async ({ page, state }) => {
|
||||||
|
await signIn(page, state, `${state.password}-incorrect`);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit the public home page', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I sign out', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'usericon' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('the sign-out request fails', async ({ page }) => {
|
||||||
|
await page.route('**/auth/v1/logout*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 503,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: '{"message":"unavailable"}'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'usericon' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign Out', exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I request a password reset', async ({ page, state }) => {
|
||||||
|
await page.goto('/forgot-password');
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByRole('button', { name: 'Send Reset Link' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit the password recovery page directly', async ({ page }) => {
|
||||||
|
await page.goto('/reset-password?code=arbitrary-code');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I enter two different replacement passwords', async ({ page, state }) => {
|
||||||
|
await page.getByLabel('New Password').fill(state.replacementPassword);
|
||||||
|
await page.getByLabel('Confirm Password').fill(`${state.replacementPassword}-different`);
|
||||||
|
await page.getByRole('button', { name: 'Update Password' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I enter a valid replacement password', async ({ page, state }) => {
|
||||||
|
await page.getByLabel('New Password').fill(state.replacementPassword);
|
||||||
|
await page.getByLabel('Confirm Password').fill(state.replacementPassword);
|
||||||
|
await page.getByRole('button', { name: 'Update Password' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told to confirm my email', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/welcome$/);
|
||||||
|
await expect(page.getByText('Check Your Email', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('a confirmation email is captured locally', async ({ state }) => {
|
||||||
|
await expect.poll(() => mailCountFor(state.email, 'Confirm')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I arrive at my Pokédex list', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I see a sign-in error', async ({ page }) => {
|
||||||
|
await expect(page.locator('.alert-error')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I return to the public home page', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I remain signed in with an error', async ({ page }) => {
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
await expect(page.locator('.alert-error.rounded-none')).toContainText('Sign out failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('a password reset email is captured locally', async ({ page, state }) => {
|
||||||
|
await expect(page.getByText('Check your email for the password reset link')).toBeVisible();
|
||||||
|
await expect.poll(() => mailCountFor(state.email, 'Reset')).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the replacement password form is unavailable', async ({ page }) => {
|
||||||
|
await expect(page.getByLabel('New Password')).toBeDisabled();
|
||||||
|
await expect(page.getByText(/invalid or expired/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told that the passwords do not match', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Passwords do not match')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told that my password was updated', async ({ page }) => {
|
||||||
|
await expect(page.getByText(/Password updated successfully/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('only the replacement password signs me in', async ({ page, state }) => {
|
||||||
|
await expect(page).toHaveURL(/\/signin$/, { timeout: 10_000 });
|
||||||
|
await signIn(page, state, state.password);
|
||||||
|
await expect(page.locator('.alert-error')).toBeVisible();
|
||||||
|
await page.getByLabel('Password').fill(state.replacementPassword);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/my-pokedexes$/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { createDexThroughUi, firstPokemon, openFirstPokemon } from '../support/app';
|
||||||
|
import { requireLoopbackUrl } from '../../support/loopback';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199';
|
||||||
|
const SUPABASE_URL = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
|
||||||
|
type Provider = 'google_drive' | 'dropbox';
|
||||||
|
|
||||||
|
async function seedIntegration(
|
||||||
|
state: import('../fixtures').ScenarioState,
|
||||||
|
provider: Provider,
|
||||||
|
overrides: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
|
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
if (!key || !state.userId)
|
||||||
|
throw new Error('A confirmed user and E2E_SERVICE_ROLE_KEY are required');
|
||||||
|
const response = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_export_integrations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Prefer: 'resolution=merge-duplicates,return=representation'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: state.userId,
|
||||||
|
pokedexId: null,
|
||||||
|
provider,
|
||||||
|
enabled: true,
|
||||||
|
accessToken: 'mock-access-token',
|
||||||
|
refreshToken: 'mock-refresh-token',
|
||||||
|
accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureExportDex(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
state: import('../fixtures').ScenarioState
|
||||||
|
) {
|
||||||
|
if (!state.pokedexId) {
|
||||||
|
await createDexThroughUi(page, state, {
|
||||||
|
name: state.pokedexName ?? `Export ${Date.now()}`,
|
||||||
|
type: 'Living Dex'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Given('Google Drive is connected to the mocked provider', async ({ state }) => {
|
||||||
|
await seedIntegration(state, 'google_drive');
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('Dropbox is connected with an expired token', async ({ page, state }) => {
|
||||||
|
await seedIntegration(state, 'dropbox', {
|
||||||
|
accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
await ensureExportDex(page, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => {
|
||||||
|
await seedIntegration(state, 'google_drive');
|
||||||
|
await fetch(`${MOCK_URL}/__mock/fail-uploads`);
|
||||||
|
await ensureExportDex(page, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit backup settings', async ({ page }) => {
|
||||||
|
await page.goto('/backup-settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I connect the mocked {string} provider', async ({ page }, provider: string) => {
|
||||||
|
await page.goto('/backup-settings');
|
||||||
|
const card = page.locator('.card, .border').filter({ hasText: provider }).last();
|
||||||
|
await card.getByRole('button', { name: 'Connect' }).click();
|
||||||
|
await page.waitForURL(/\/backup-settings\?export=.*-connected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('a mocked OAuth callback has an invalid state', async ({ page, state }) => {
|
||||||
|
const response = await page.request.get(
|
||||||
|
'/api/integrations/google-drive/callback?code=mock&state=invalid-state'
|
||||||
|
);
|
||||||
|
state.lastResponseStatus = response.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I save a catch note containing a comma and quote', async ({ page, state }) => {
|
||||||
|
await ensureExportDex(page, state);
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').fill('A comma, and a "quote"');
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').blur();
|
||||||
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
When('an export is requested', async ({ page, state }) => {
|
||||||
|
const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`);
|
||||||
|
state.lastResponseStatus = response.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I update collection progress', async ({ page, state }) => {
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Caught:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox')
|
||||||
|
.check();
|
||||||
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('Google Drive and Dropbox are shown as not connected', async ({ page }) => {
|
||||||
|
await expect(page.getByText('Not Connected', { exact: true })).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('{string} is shown as connected', async ({ page }, provider: string) => {
|
||||||
|
const card = page.locator('.border').filter({ hasText: provider });
|
||||||
|
await expect(card.getByText('Connected', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the backup connection is rejected', async ({ state }) => {
|
||||||
|
expect(state.lastResponseStatus).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mockState() {
|
||||||
|
const response = await fetch(`${MOCK_URL}/__mock/state`);
|
||||||
|
if (!response.ok) throw new Error(`Mock provider is unavailable: ${response.status}`);
|
||||||
|
return (await response.json()) as {
|
||||||
|
refreshes: number;
|
||||||
|
requests: Array<{ method: string; path: string; body: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Then('the mocked provider receives a valid escaped CSV', async () => {
|
||||||
|
const { requests } = await mockState();
|
||||||
|
const upload = requests.find((request) => request.path.includes('upload'));
|
||||||
|
expect(upload, 'no upload reached the mock provider').toBeDefined();
|
||||||
|
expect(upload!.body).toContain('"A comma, and a ""quote"""');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the note survives a reload', async ({ page, state }) => {
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await expect(firstPokemon(page)).toBeVisible();
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await expect(page.getByRole('dialog').getByLabel('Notes:')).toHaveValue('A comma, and a "quote"');
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the catch remains marked caught', async ({ page, state }) => {
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await expect(firstPokemon(page)).toBeVisible();
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
const caught = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Caught:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await expect(caught).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the token is refreshed before the mocked upload', async ({ state }) => {
|
||||||
|
expect(state.lastResponseStatus).toBe(200);
|
||||||
|
const { refreshes, requests } = await mockState();
|
||||||
|
// Exactly one refresh, and it has to come before the upload it was needed for - a global
|
||||||
|
// ">= 1" would be satisfied by any earlier scenario's traffic.
|
||||||
|
expect(refreshes).toBe(1);
|
||||||
|
const refreshIndex = requests.findIndex(
|
||||||
|
(request) =>
|
||||||
|
request.path.endsWith('/token') && request.body.includes('grant_type=refresh_token')
|
||||||
|
);
|
||||||
|
const uploadIndex = requests.findIndex((request) => request.path.includes('upload'));
|
||||||
|
expect(refreshIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(uploadIndex).toBeGreaterThan(refreshIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the provider failure is shown in backup settings', async ({ page }) => {
|
||||||
|
await page.goto('/backup-settings');
|
||||||
|
await expect(page.getByText(/mock upload failure/)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { loadEntries } from '../support/app';
|
||||||
|
|
||||||
|
const { When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
When('I inspect its entries without forms', async ({ page, state }) => {
|
||||||
|
await loadEntries(page, state, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I inspect its entries with forms', async ({ page, state }) => {
|
||||||
|
await loadEntries(page, state, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I view the Pokédex', async ({ page, state }) => {
|
||||||
|
if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be viewed');
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}`);
|
||||||
|
await expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('it contains {int} unique species', async ({ state }, count: number) => {
|
||||||
|
expect(state.entries).toHaveLength(count);
|
||||||
|
expect(new Set(state.entries.map((entry) => entry.pokemon)).size).toBe(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('named default forms are represented once', async ({ state }) => {
|
||||||
|
for (const [pokemon, form] of Object.entries({
|
||||||
|
Basculin: 'Red-striped',
|
||||||
|
Tornadus: 'Incarnate Form',
|
||||||
|
Oricorio: 'Baile (Red)',
|
||||||
|
Zygarde: '50%',
|
||||||
|
Gimmighoul: 'Box Form',
|
||||||
|
Rotom: 'Lightbulb',
|
||||||
|
Unown: 'A'
|
||||||
|
})) {
|
||||||
|
expect(state.entries.filter((entry) => entry.pokemon === pokemon)).toEqual([
|
||||||
|
expect.objectContaining({ form })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('every entry identity is unique', async ({ state }) => {
|
||||||
|
const identities = state.entries.map((entry) => `${entry.pokemon}|${entry.form ?? ''}`);
|
||||||
|
expect(new Set(identities).size).toBe(identities.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('{word} has {int} forms', async ({ state }, pokemon: string, count: number) => {
|
||||||
|
expect(state.entries.filter((entry) => entry.pokemon === pokemon)).toHaveLength(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('its Pokémon use shiny sprites', async ({ page }) => {
|
||||||
|
const first = page.locator('img[src*="/shiny/"]').first();
|
||||||
|
for (let attempts = 0; attempts < 10 && (await first.count()) === 0; attempts++) {
|
||||||
|
await page.mouse.wheel(0, 2500);
|
||||||
|
}
|
||||||
|
await expect(first).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('Rotom includes its named default form without duplicate forms', async ({ state }) => {
|
||||||
|
const forms = state.entries
|
||||||
|
.filter((entry) => entry.pokemon === 'Rotom')
|
||||||
|
.map((entry) => entry.form ?? '');
|
||||||
|
expect(forms).toContain('Lightbulb');
|
||||||
|
expect(new Set(forms).size).toBe(forms.length);
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { createDexThroughUi, deleteAllPokedexes } from '../support/app';
|
||||||
|
import { requireLoopbackUrl } from '../../support/loopback';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
async function dexCard(page: Parameters<typeof createDexThroughUi>[0], name: string) {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
const card = page.locator('.card').filter({ hasText: name }).first();
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establishes the precondition rather than asserting it - a fresh user happens to be empty,
|
||||||
|
// which would make an assertion here pass without testing anything.
|
||||||
|
Given('I have no Pokédexes', async ({ state }) => {
|
||||||
|
await deleteAllPokedexes(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have a Living Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Living Dex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have a Form Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Form Dex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given(
|
||||||
|
'I have a Form Dex named {string} scoped to game {string} and dex {string}',
|
||||||
|
async ({ page, state }, name: string, game: string, dex: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Form Dex', game, dex });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Given('I have a Shiny Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Shiny Dex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('another trainer has a Pokédex', async ({ state }) => {
|
||||||
|
const url = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const key = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
if (!key) throw new Error('E2E_SERVICE_ROLE_KEY is required');
|
||||||
|
const headers = {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
const userResponse = await fetch(`${url}/auth/v1/admin/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: `other-${Date.now()}@example.test`,
|
||||||
|
password: 'OtherPassword123!',
|
||||||
|
email_confirm: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const other = (await userResponse.json()) as { id: string };
|
||||||
|
const dexResponse = await fetch(`${url}/rest/v1/pokedexes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, Prefer: 'return=representation' },
|
||||||
|
body: JSON.stringify({ userId: other.id, name: 'Private', isLivingDex: true })
|
||||||
|
});
|
||||||
|
if (!dexResponse.ok) throw new Error(await dexResponse.text());
|
||||||
|
const [dex] = (await dexResponse.json()) as Array<{ id: string }>;
|
||||||
|
state.pokedexId = dex.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I visit my Pokédex list', async ({ page }) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I open the new Pokédex form', async ({ page }) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /Create (New|Your First) Pokédex/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I create a Pokédex named {string} of type {string}', async ({ page, state }, name, type) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type });
|
||||||
|
});
|
||||||
|
|
||||||
|
When(
|
||||||
|
'I create a Living Dex named {string} scoped to game {string} and dex {string}',
|
||||||
|
async ({ page, state }, name: string, game: string, dex: string) => {
|
||||||
|
await createDexThroughUi(page, state, { name, type: 'Living Dex', game, dex });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
When('I try to create another Living Dex named {string}', async ({ page, state }, name: string) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await page.getByRole('button', { name: 'Create New Pokédex', exact: true }).click();
|
||||||
|
const modal = page.locator('.modal-open');
|
||||||
|
await modal.getByLabel('Name').fill(name);
|
||||||
|
await modal.getByText('Living Dex', { exact: false }).locator('..').getByRole('checkbox').check();
|
||||||
|
page.once('dialog', async (dialog) => {
|
||||||
|
state.lastMessage = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await modal.getByRole('button', { name: 'Create', exact: true }).click();
|
||||||
|
await expect.poll(() => state.lastMessage).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I rename it to {string} and enable forms', async ({ page }, name: string) => {
|
||||||
|
const card = await dexCard(page, 'Before Editing');
|
||||||
|
await card.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
const modal = page.locator('.modal-open');
|
||||||
|
await modal.getByLabel('Name').fill(name);
|
||||||
|
await modal.getByText('Form Dex', { exact: false }).locator('..').getByRole('checkbox').check();
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'PUT'
|
||||||
|
);
|
||||||
|
await modal.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
expect((await response).ok()).toBe(true);
|
||||||
|
await expect(modal).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I cancel deleting {string}', async ({ page }, name: string) => {
|
||||||
|
const card = await dexCard(page, name);
|
||||||
|
page.once('dialog', (dialog) => dialog.dismiss());
|
||||||
|
await card.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I confirm deleting {string}', async ({ page }, name: string) => {
|
||||||
|
const card = await dexCard(page, name);
|
||||||
|
page.once('dialog', (dialog) => dialog.accept());
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'DELETE'
|
||||||
|
);
|
||||||
|
await card.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
expect((await response).ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
When("I request the other trainer's Pokédex", async ({ page, state }) => {
|
||||||
|
const response = await page.request.get(`/api/pokedexes/${state.pokedexId}`);
|
||||||
|
state.lastResponseStatus = response.status();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I see the empty Pokédex message', async ({ page }) => {
|
||||||
|
await expect(page.getByText("You haven't created any pokédexes yet!")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I cannot create a Pokédex without a name and type', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('button', { name: 'Create', exact: true })).toBeDisabled();
|
||||||
|
await expect(page.getByText('At least one type required')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the Pokédex {string} is available to view', async ({ page }, name: string) => {
|
||||||
|
await expect((await dexCard(page, name)).getByRole('button', { name: 'View' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I am told that the Pokédex name is already used', async ({ state }) => {
|
||||||
|
expect(state.lastMessage).toMatch(/already have a Pokédex named/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the Pokédex {string} is no longer listed', async ({ page }, name: string) => {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await expect(page.locator('.card').filter({ hasText: name })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the Pokédex is not disclosed', async ({ state }) => {
|
||||||
|
expect(state.lastResponseStatus).toBe(404);
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
import { firstPokemon, openFirstPokemon } from '../support/app';
|
||||||
|
|
||||||
|
const { When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
async function ensurePokemonModal(page: Parameters<typeof firstPokemon>[0]) {
|
||||||
|
if ((await page.getByRole('dialog').count()) === 0) await openFirstPokemon(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistCatchChange(
|
||||||
|
page: Parameters<typeof firstPokemon>[0],
|
||||||
|
action: () => Promise<void>
|
||||||
|
) {
|
||||||
|
const persistence = page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
new URL(response.url()).pathname.endsWith('/catch-records')
|
||||||
|
);
|
||||||
|
await action();
|
||||||
|
const response = await persistence;
|
||||||
|
expect(response.ok(), `catch-record persistence failed: ${response.status()}`).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleAndReload(page: Parameters<typeof firstPokemon>[0]) {
|
||||||
|
await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await expect(firstPokemon(page)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
When('I mark the first Pokémon as caught', async ({ page }) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
const checkbox = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Caught:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await persistCatchChange(page, () => checkbox.check());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I mark the first Pokémon as needing evolution', async ({ page }) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
const checkbox = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('Needs to evolve:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await persistCatchChange(page, () => checkbox.check());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I mark the first Pokémon as in HOME', async ({ page }) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
const checkbox = page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.getByText('In Home:', { exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
await persistCatchChange(page, () => checkbox.check());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I add the note {string} to the first Pokémon', async ({ page }, note: string) => {
|
||||||
|
await ensurePokemonModal(page);
|
||||||
|
await persistCatchChange(page, async () => {
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').fill(note);
|
||||||
|
await page.getByRole('dialog').getByLabel('Notes:').blur();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function boxContainer(page: Parameters<typeof firstPokemon>[0], box: number) {
|
||||||
|
return page
|
||||||
|
.getByRole('heading', { name: `Box ${box}`, exact: true })
|
||||||
|
.locator('..')
|
||||||
|
.locator('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
When('I mark box {int} as caught', async ({ page }, box: number) => {
|
||||||
|
const container = boxContainer(page, box);
|
||||||
|
await container.getByRole('button', { name: 'Open bulk actions menu' }).click();
|
||||||
|
await persistCatchChange(page, () =>
|
||||||
|
container.getByRole('button', { name: 'Mark box as Caught' }).click()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I filter to Pokémon that are not caught', async ({ page, state }) => {
|
||||||
|
if ((await page.getByRole('dialog').count()) > 0) {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
}
|
||||||
|
// Remember which entry was caught, so the assertion can name it rather than trusting
|
||||||
|
// whichever entry happens to be first after filtering.
|
||||||
|
state.caughtEntryLabel = await firstPokemon(page).getAttribute('aria-label');
|
||||||
|
expect(state.caughtEntryLabel).toMatch(/Status: Caught/);
|
||||||
|
await page.getByText('Not caught', { exact: true }).locator('..').getByRole('checkbox').check();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I select the {string} box layout', async ({ page }, layout: string) => {
|
||||||
|
await page.getByLabel('Choose box view layout density').selectOption(layout.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I reload the Pokédex', async ({ page }) => {
|
||||||
|
await settleAndReload(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the first Pokémon is shown as caught after reloading', async ({ page }) => {
|
||||||
|
await settleAndReload(page);
|
||||||
|
await expect(firstPokemon(page)).toHaveAttribute('aria-label', /Status: Caught/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the first Pokémon needs evolution and is not marked caught', async ({ page }) => {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await settleAndReload(page);
|
||||||
|
await expect(firstPokemon(page)).toHaveAttribute('aria-label', /Needs to evolve/);
|
||||||
|
await expect(firstPokemon(page)).not.toHaveAttribute('aria-label', /Status: Caught(?:,|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('its HOME state and note persist after reloading', async ({ page }) => {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await settleAndReload(page);
|
||||||
|
await expect(firstPokemon(page)).toHaveAttribute('aria-label', /In HOME/);
|
||||||
|
await openFirstPokemon(page);
|
||||||
|
await expect(page.getByRole('dialog').getByLabel('Notes:')).toHaveValue(
|
||||||
|
'Caught, traded, and checked'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('box {int} contains {int} caught Pokémon', async ({ page }, box: number, count: number) => {
|
||||||
|
await settleAndReload(page);
|
||||||
|
const entries = boxContainer(page, box).getByRole('button', { name: /^View details for / });
|
||||||
|
await expect(entries).toHaveCount(count);
|
||||||
|
const labels = await entries.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => element.getAttribute('aria-label') ?? '')
|
||||||
|
);
|
||||||
|
expect(labels.filter((label) => /Status: Caught/.test(label))).toHaveLength(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the caught Pokémon is filtered out', async ({ page, state }) => {
|
||||||
|
const label = state.caughtEntryLabel;
|
||||||
|
if (!label) throw new Error('No caught entry was recorded before filtering');
|
||||||
|
// That specific entry is excluded...
|
||||||
|
await expect(page.getByRole('button', { name: label, exact: true })).toHaveAttribute(
|
||||||
|
'aria-disabled',
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
// ...and the filter did not simply exclude everything.
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole('button', { name: /^View details for / })
|
||||||
|
.and(page.locator('[aria-disabled="false"]'))
|
||||||
|
.first()
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the {string} box layout remains selected', async ({ page }, layout: string) => {
|
||||||
|
await expect(page.getByLabel('Choose box view layout density')).toHaveValue(layout.toLowerCase());
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { createBdd } from 'playwright-bdd';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { test, expect } from '../fixtures';
|
||||||
|
|
||||||
|
const { Given, When, Then } = createBdd(test);
|
||||||
|
|
||||||
|
async function waitForServiceWorker(page: Page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
if (!('serviceWorker' in navigator)) throw new Error('Service workers are not supported');
|
||||||
|
const registration = await Promise.race([
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Service worker registration timed out')), 15_000)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
return registration.active?.scriptURL ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheContents(page: Page) {
|
||||||
|
return page.evaluate(async () => {
|
||||||
|
const contents: Record<string, string[]> = {};
|
||||||
|
for (const name of await caches.keys()) {
|
||||||
|
const cache = await caches.open(name);
|
||||||
|
contents[name] = (await cache.keys()).map((request) => request.url);
|
||||||
|
}
|
||||||
|
return contents;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordLegacyWorkerRequest(page: Page, state: { legacyServiceWorkerRequested: boolean }) {
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (new URL(request.url()).pathname === '/service-worker.js') {
|
||||||
|
state.legacyServiceWorkerRequested = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
When('I open the built application', async ({ page, state }) => {
|
||||||
|
recordLegacyWorkerRequest(page, state);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
if (!(await page.evaluate(() => !!navigator.serviceWorker.controller))) {
|
||||||
|
await page.reload();
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('I have opened the built application online', async ({ page, state }) => {
|
||||||
|
recordLegacyWorkerRequest(page, state);
|
||||||
|
await page.context().setOffline(false);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
await page.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and reload the home page', async ({ page }) => {
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and reload the sign-in page', async ({ page }) => {
|
||||||
|
await page.goto('/signin');
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Given('my offline copy is synchronized', async ({ page, state }) => {
|
||||||
|
await waitForServiceWorker(page);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(async (userId) => {
|
||||||
|
const meta = await (
|
||||||
|
await caches.open('livingdex-offline-meta-v1')
|
||||||
|
).match('/__offline/current');
|
||||||
|
if (!meta) return false;
|
||||||
|
const value = await meta.json();
|
||||||
|
return value.userId === userId;
|
||||||
|
}, state.userId)
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
const serializedSnapshot = await page.evaluate(async () => {
|
||||||
|
const metaResponse = await (
|
||||||
|
await caches.open('livingdex-offline-meta-v1')
|
||||||
|
).match('/__offline/current');
|
||||||
|
if (!metaResponse) throw new Error('Offline snapshot metadata was not cached');
|
||||||
|
const meta = await metaResponse.json();
|
||||||
|
const snapshotResponse = await (
|
||||||
|
await caches.open(meta.dataCache)
|
||||||
|
).match(`/__offline/snapshot/${encodeURIComponent(meta.userId)}`);
|
||||||
|
if (!snapshotResponse) throw new Error('Offline snapshot payload was not cached');
|
||||||
|
return snapshotResponse.text();
|
||||||
|
});
|
||||||
|
expect(serializedSnapshot).not.toMatch(/access_token|refresh_token/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and reload the current Pokédex', async ({ page, state }) => {
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.goto(`/pokedex/${state.pokedexId}/offline`, {
|
||||||
|
waitUntil: 'domcontentloaded'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I go offline and then return online', async ({ page }) => {
|
||||||
|
await page.context().setOffline(true);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.context().setOffline(false);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('a service worker controls the page', async ({ page }) => {
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.scriptURL ?? null))
|
||||||
|
.toMatch(/\/(?:sw|prompt-sw)\.js$/);
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() =>
|
||||||
|
navigator.serviceWorker.getRegistrations().then((items) => items.length)
|
||||||
|
)
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('no legacy service worker is requested', async ({ state }) => {
|
||||||
|
expect(state.legacyServiceWorkerRequested).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the deleted client-test/sw.spec.ts assertions. The precise entries differ by build
|
||||||
|
* strategy, so this checks the contract that matters: one workbox precache, the app shell and
|
||||||
|
* web manifest are in it, and `_app/immutable` assets are cached WITHOUT a revision query -
|
||||||
|
* that last one is the `dontCacheBustURLsMatching` behaviour, and it silently regresses.
|
||||||
|
*/
|
||||||
|
Then('the application shell is precached', async ({ page }) => {
|
||||||
|
const contents = await cacheContents(page);
|
||||||
|
const names = Object.keys(contents).filter((name) => name.startsWith('workbox-precache'));
|
||||||
|
expect(names).toHaveLength(1);
|
||||||
|
|
||||||
|
const origin = new URL(page.url()).origin;
|
||||||
|
const urls = contents[names[0]].map((url) => url.slice(`${origin}/`.length));
|
||||||
|
|
||||||
|
expect(urls, 'personalized SSR root must not be precached').not.toContain('');
|
||||||
|
expect(
|
||||||
|
urls.some((url) => /^offline(?:\.html)?(?:\?__WB_REVISION__=|$)/.test(url)),
|
||||||
|
'offline viewer is not precached'
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__=')),
|
||||||
|
'revisioned manifest.webmanifest is not precached'
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
urls.some((url) => url.startsWith('_app/version.json?__WB_REVISION__=')),
|
||||||
|
'revisioned _app/version.json is not precached'
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const immutable = urls.filter((url) => url.startsWith('_app/immutable/'));
|
||||||
|
expect(immutable.some((url) => url.endsWith('.css'))).toBe(true);
|
||||||
|
expect(immutable.some((url) => url.endsWith('.js'))).toBe(true);
|
||||||
|
expect(
|
||||||
|
immutable.filter((url) => url.includes('__WB_REVISION__')),
|
||||||
|
'immutable assets must not be cache-busted'
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the application remains available', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the read-only offline viewer is available', async ({ page }) => {
|
||||||
|
await expect(page.getByText(/offline.*read-only/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('the offline copy contains {string}', async ({ page }, name: string) => {
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible();
|
||||||
|
await expect(page.getByText(/read-only copy/i)).toBeVisible();
|
||||||
|
await expect(page.locator('button, input, textarea, select')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('my offline copy is removed', async ({ page }) => {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
const names = await caches.keys();
|
||||||
|
return names.some((name) => name.startsWith('livingdex-offline-'));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('my offline copy remains', async ({ page, state }) => {
|
||||||
|
const owner = await page.evaluate(async () => {
|
||||||
|
const meta = await (await caches.open('livingdex-offline-meta-v1')).match('/__offline/current');
|
||||||
|
return meta ? (await meta.json()).userId : null;
|
||||||
|
});
|
||||||
|
expect(owner).toBe(state.userId);
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import type { ScenarioState } from '../fixtures';
|
||||||
|
import { requireLoopbackUrl } from '../../support/loopback';
|
||||||
|
|
||||||
|
const SUPABASE_URL = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const SERVICE_ROLE_KEY = process.env.E2E_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
function requireServiceRoleKey(): string {
|
||||||
|
if (!SERVICE_ROLE_KEY) {
|
||||||
|
throw new Error('E2E_SERVICE_ROLE_KEY is required. Run BDD through "npm run test:bdd".');
|
||||||
|
}
|
||||||
|
return SERVICE_ROLE_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfirmedUser(state: ScenarioState): Promise<void> {
|
||||||
|
if (state.userId) return;
|
||||||
|
const key = requireServiceRoleKey();
|
||||||
|
const response = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
apikey: key,
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: state.email,
|
||||||
|
password: state.password,
|
||||||
|
email_confirm: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Unable to create BDD user: ${response.status} ${await response.text()}`);
|
||||||
|
const body = (await response.json()) as { id: string };
|
||||||
|
state.userId = body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllPokedexes(state: ScenarioState): Promise<void> {
|
||||||
|
const key = requireServiceRoleKey();
|
||||||
|
if (!state.userId) throw new Error('A confirmed user is required before clearing Pokédexes');
|
||||||
|
const response = await fetch(
|
||||||
|
`${SUPABASE_URL}/rest/v1/pokedexes?userId=eq.${encodeURIComponent(state.userId)}`,
|
||||||
|
{ method: 'DELETE', headers: { apikey: key, Authorization: `Bearer ${key}` } }
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`Unable to clear Pokédexes: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signIn(page: Page, state: ScenarioState, password = state.password) {
|
||||||
|
await page.goto('/signin');
|
||||||
|
await page.getByLabel('Email').fill(state.email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDexThroughUi(
|
||||||
|
page: Page,
|
||||||
|
state: ScenarioState,
|
||||||
|
options: { name: string; type: string; game?: string; dex?: string }
|
||||||
|
) {
|
||||||
|
await page.goto('/my-pokedexes');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /Create (New|Your First) Pokédex/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.getByLabel('Name').fill(options.name);
|
||||||
|
await page.getByText(options.type, { exact: false }).locator('..').getByRole('checkbox').check();
|
||||||
|
if (options.game) {
|
||||||
|
await page.getByLabel('Game Scope').selectOption({ label: options.game });
|
||||||
|
if (options.dex) {
|
||||||
|
const checkbox = page
|
||||||
|
.getByText(options.dex, { exact: false })
|
||||||
|
.locator('..')
|
||||||
|
.getByRole('checkbox');
|
||||||
|
if (!(await checkbox.isChecked())) await checkbox.check();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.locator('.modal-open').getByRole('button', { name: 'Create', exact: true }).click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
state.pokedexName = options.name;
|
||||||
|
if (page.url().includes('/pokedex/')) {
|
||||||
|
state.pokedexId = page.url().split('/pokedex/')[1].split(/[?#]/)[0];
|
||||||
|
} else {
|
||||||
|
const card = page.locator('.card').filter({ hasText: options.name }).first();
|
||||||
|
await card.getByRole('button', { name: 'View', exact: true }).click();
|
||||||
|
await page.waitForURL('**/pokedex/**');
|
||||||
|
state.pokedexId = page.url().split('/pokedex/')[1].split(/[?#]/)[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntries(page: Page, state: ScenarioState, forms: boolean) {
|
||||||
|
if (!state.pokedexId) throw new Error('A Pokédex must be created before loading entries');
|
||||||
|
const result = await page.evaluate(
|
||||||
|
async ([id, enableForms]) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/pokedexes/${id}/combined-data?page=1&limit=9999&enableForms=${enableForms}`
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
const body = await response.json();
|
||||||
|
return body.combinedData.map(
|
||||||
|
(row: {
|
||||||
|
pokedexEntry: {
|
||||||
|
_id: string;
|
||||||
|
pokemon: string;
|
||||||
|
form: string | null;
|
||||||
|
pokedexNumber: number;
|
||||||
|
};
|
||||||
|
}) => ({
|
||||||
|
id: row.pokedexEntry._id,
|
||||||
|
pokemon: row.pokedexEntry.pokemon,
|
||||||
|
form: row.pokedexEntry.form,
|
||||||
|
num: row.pokedexEntry.pokedexNumber
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[state.pokedexId, String(forms)]
|
||||||
|
);
|
||||||
|
state.entries = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstPokemon(page: Page) {
|
||||||
|
return page.getByRole('button', { name: /^View details for / }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFirstPokemon(page: Page) {
|
||||||
|
const pokemon = firstPokemon(page);
|
||||||
|
await pokemon.click();
|
||||||
|
await page.locator('.modal-open, [role="dialog"]').first().waitFor({ state: 'visible' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
import { generateSW } from '../../pwa.mjs';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
import { nodeAdapter } from '../../adapter.mjs';
|
||||||
|
|
||||||
|
describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => {
|
||||||
|
it(`service worker is generated: ${generateSW ? 'sw.js' : 'prompt-sw.js'}`, () => {
|
||||||
|
const swName = `./build/${nodeAdapter ? 'client/' : ''}${generateSW ? 'sw.js' : 'prompt-sw.js'}`;
|
||||||
|
expect(existsSync(swName), `${swName} doesn't exist`).toBeTruthy();
|
||||||
|
const webManifest = `./build/${nodeAdapter ? 'client/' : ''}manifest.webmanifest`;
|
||||||
|
expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy();
|
||||||
|
const swContent = readFileSync(swName, 'utf-8');
|
||||||
|
let match: RegExpMatchArray | null;
|
||||||
|
if (generateSW) {
|
||||||
|
match = swContent.match(/define\(\['\.\/(workbox-\w+)'/);
|
||||||
|
expect(
|
||||||
|
match && match.length === 2,
|
||||||
|
`workbox-***.js entry not found in ${swName}`
|
||||||
|
).toBeTruthy();
|
||||||
|
const workboxName = `./build/${nodeAdapter ? 'client/' : ''}${match?.[1]}.js`;
|
||||||
|
expect(existsSync(workboxName), `${workboxName} doesn't exist`).toBeTruthy();
|
||||||
|
}
|
||||||
|
match = swContent.match(/"url":\s*"manifest\.webmanifest"/);
|
||||||
|
expect(
|
||||||
|
match && match.length === 1,
|
||||||
|
'missing manifest.webmanifest in sw precache manifest'
|
||||||
|
).toBeTruthy();
|
||||||
|
match = swContent.match(/"?url"?:\s*"\/?offline(?:\.html)?"/);
|
||||||
|
expect(
|
||||||
|
match && match.length === 1,
|
||||||
|
'missing credential-free offline entry point in sw precache manifest'
|
||||||
|
).toBeTruthy();
|
||||||
|
const outputRoot = `./build/${nodeAdapter ? 'client/' : ''}`;
|
||||||
|
expect(existsSync(`${outputRoot}offline.html`)).toBe(true);
|
||||||
|
expect(existsSync(`${outputRoot}offline-worker.js`)).toBe(true);
|
||||||
|
expect(existsSync(`${outputRoot}service-worker.js`)).toBe(false);
|
||||||
|
expect(swContent).not.toMatch(/"?url"?:\s*"\/"/);
|
||||||
|
if (nodeAdapter) {
|
||||||
|
match = swContent.match(/"url":\s*"server\//);
|
||||||
|
expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { normalizedIdentity, readRepoCsv } from '../support/csv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correctness checks for the Pokemon reference data itself - not the code that queries it.
|
||||||
|
*
|
||||||
|
* Everything here is offline and unconditional: it cross-checks `data/csvs/pokemon.csv`
|
||||||
|
* against `static/sprites/pokeapi-pokemon.csv`, a tracked export of PokeAPI's `pokemon`
|
||||||
|
* table (`id` matches this project's spriteKey convention, `species_id` is the national dex
|
||||||
|
* number, `is_default` is PokeAPI's canonical default-variety flag).
|
||||||
|
*
|
||||||
|
* `defaultForm.integration.test.ts` asserts the CSV still mirrors the database, so these
|
||||||
|
* checks cannot quietly drift away from what the app actually serves. The canonical named
|
||||||
|
* forms are test input rather than being parsed from the fix migration, which lets this
|
||||||
|
* suite load on the pre-fix commit and fail only on observable data differences.
|
||||||
|
*/
|
||||||
|
type ApiRow = { id: string; identifier: string; species_id: string; is_default: string };
|
||||||
|
|
||||||
|
const EXPECTED_NATIONAL_DEX_MAX = 1025;
|
||||||
|
const pokemon = readRepoCsv('data/csvs/pokemon.csv');
|
||||||
|
const apiRows = readRepoCsv('static/sprites/pokeapi-pokemon.csv') as unknown as ApiRow[];
|
||||||
|
const api = new Map(apiRows.map((r) => [r.id, r]));
|
||||||
|
|
||||||
|
const canonicalNamedDefaults = {
|
||||||
|
Alcremie: 'Vanilla Strawberry',
|
||||||
|
Basculin: 'Red-striped',
|
||||||
|
Beautifly: 'male',
|
||||||
|
Burmy: 'Leaf Cloak',
|
||||||
|
Deerling: 'Spring',
|
||||||
|
Dudunsparce: '2-Segment',
|
||||||
|
Enamorus: 'Incarnate Form',
|
||||||
|
Flabébé: 'Red',
|
||||||
|
Floette: 'Red',
|
||||||
|
Florges: 'Red',
|
||||||
|
Gastrodon: 'West Sea',
|
||||||
|
Gimmighoul: 'Box Form',
|
||||||
|
Gourgeist: 'Medium',
|
||||||
|
Gulpin: 'male',
|
||||||
|
Hoopa: 'Confined',
|
||||||
|
Landorus: 'Incarnate Form',
|
||||||
|
Lycanroc: 'Midday',
|
||||||
|
Maushold: 'Family of 4',
|
||||||
|
Minior: 'Red',
|
||||||
|
Oricorio: 'Baile (Red)',
|
||||||
|
Poltchageist: 'Phony',
|
||||||
|
Polteageist: 'Phony',
|
||||||
|
Pumpkaboo: 'Medium',
|
||||||
|
Sawsbuck: 'Spring',
|
||||||
|
Shaymin: 'Normal Form',
|
||||||
|
Shellos: 'West Sea',
|
||||||
|
Sinistcha: 'Phony',
|
||||||
|
Sinistea: 'Phony',
|
||||||
|
Squawkabilly: 'Green',
|
||||||
|
Swalot: 'male',
|
||||||
|
Tatsugiri: 'Curly',
|
||||||
|
Thundurus: 'Incarnate Form',
|
||||||
|
Tornadus: 'Incarnate Form',
|
||||||
|
Toxtricity: 'Amped',
|
||||||
|
Unown: 'A',
|
||||||
|
Urshifu: 'Single',
|
||||||
|
Vivillon: 'Meadow (France-Alsace) [Not Ultra Sun compatible]',
|
||||||
|
Wormadam: 'Leaf Cloak',
|
||||||
|
Zygarde: '50%',
|
||||||
|
Rotom: 'Lightbulb'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const declaredDefaults = Object.entries(canonicalNamedDefaults).map(([pokemon, form]) => ({
|
||||||
|
pokemon,
|
||||||
|
form
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PokeAPI's default variety for Minior is `minior-red-meteor` (the shielded Meteor Form),
|
||||||
|
* which this dataset doesn't track because it isn't separately catchable - a caught Minior
|
||||||
|
* always resolves to a core colour. Red is the conventional stand-in.
|
||||||
|
*/
|
||||||
|
const DEFAULT_FORM_EXCEPTIONS = new Set(['Minior']);
|
||||||
|
|
||||||
|
describe('pokemon.csv structure', () => {
|
||||||
|
it('covers every national dex number from 1 through the declared maximum', () => {
|
||||||
|
const numbers = [...new Set(pokemon.map((row) => Number(row.pokedexNumber)))].sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
);
|
||||||
|
expect(numbers).toEqual(
|
||||||
|
Array.from({ length: EXPECTED_NATIONAL_DEX_MAX }, (_, index) => index + 1)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps each number to one species and each species to one number', () => {
|
||||||
|
const speciesByNumber = new Map<number, Set<string>>();
|
||||||
|
const numbersBySpecies = new Map<string, Set<number>>();
|
||||||
|
for (const row of pokemon) {
|
||||||
|
const number = Number(row.pokedexNumber);
|
||||||
|
if (!speciesByNumber.has(number)) speciesByNumber.set(number, new Set());
|
||||||
|
if (!numbersBySpecies.has(row.pokemon)) numbersBySpecies.set(row.pokemon, new Set());
|
||||||
|
speciesByNumber.get(number)?.add(row.pokemon);
|
||||||
|
numbersBySpecies.get(row.pokemon)?.add(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect([...speciesByNumber].filter(([, species]) => species.size !== 1)).toEqual([]);
|
||||||
|
expect([...numbersBySpecies].filter(([, numbers]) => numbers.size !== 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicate species/form rows', () => {
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
for (const r of pokemon) {
|
||||||
|
const key = normalizedIdentity(r.pokemon, r.form);
|
||||||
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
expect([...seen.entries()].filter(([, n]) => n > 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicate national-number/form rows', () => {
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
for (const row of pokemon) {
|
||||||
|
const key = normalizedIdentity(row.pokedexNumber, row.form);
|
||||||
|
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
expect([...seen.entries()].filter(([, count]) => count > 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has valid required fields on every row', () => {
|
||||||
|
const invalid = pokemon.filter(
|
||||||
|
(row) =>
|
||||||
|
!Number.isInteger(Number(row.pokedexNumber)) ||
|
||||||
|
Number(row.pokedexNumber) <= 0 ||
|
||||||
|
!row.pokemon ||
|
||||||
|
!row.originRegionToCatchIn ||
|
||||||
|
!row.originGamesToCatchIn
|
||||||
|
);
|
||||||
|
expect(invalid).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every row a sprite key', () => {
|
||||||
|
expect(pokemon.filter((r) => !r.spriteKey)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pokemon.csv agrees with the PokeAPI reference export', () => {
|
||||||
|
it('matches PokeAPI on the national dex number behind every numeric sprite key', () => {
|
||||||
|
// Wyrdeer was seeded at 999 with sprite 999 - Gimmighoul's - so it sorted into the
|
||||||
|
// wrong dex slot and rendered the wrong artwork. This is the check that catches that.
|
||||||
|
const mismatches = pokemon
|
||||||
|
.filter((r) => /^\d+$/.test(r.spriteKey) && api.has(r.spriteKey))
|
||||||
|
.filter((r) => api.get(r.spriteKey)!.species_id !== r.pokedexNumber)
|
||||||
|
.map((r) => `${r.pokemon} ${r.form} sprite=${r.spriteKey} dex=${r.pokedexNumber}`);
|
||||||
|
|
||||||
|
expect(mismatches).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves every numeric sprite key to a real PokeAPI entry', () => {
|
||||||
|
const unresolved = pokemon
|
||||||
|
.filter((r) => /^\d+$/.test(r.spriteKey) && !api.has(r.spriteKey))
|
||||||
|
.map((r) => `${r.pokemon} ${r.form} -> ${r.spriteKey}`);
|
||||||
|
|
||||||
|
expect(unresolved).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('declared default forms', () => {
|
||||||
|
const bySpeciesForm = new Map(pokemon.map((r) => [`${r.pokemon}|${r.form}`, r]));
|
||||||
|
|
||||||
|
it('names a row that actually exists', () => {
|
||||||
|
// Mirrors the migration's own assertion, but fails in CI rather than only on deploy.
|
||||||
|
const missing = declaredDefaults
|
||||||
|
.filter((d) => !bySpeciesForm.has(`${d.pokemon}|${d.form}`))
|
||||||
|
.map((d) => `${d.pokemon} '${d.form}'`);
|
||||||
|
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the form PokeAPI marks as the default variety', () => {
|
||||||
|
// Independently validates the judgement calls - Zygarde 50% over 10%, Basculin
|
||||||
|
// Red-striped, Toxtricity Amped, Urshifu Single Strike and the rest.
|
||||||
|
const disagreements = declaredDefaults
|
||||||
|
.filter((d) => !DEFAULT_FORM_EXCEPTIONS.has(d.pokemon))
|
||||||
|
.map((d) => ({ d, row: bySpeciesForm.get(`${d.pokemon}|${d.form}`) }))
|
||||||
|
.filter(({ row }) => row && /^\d+$/.test(row.spriteKey) && api.has(row.spriteKey))
|
||||||
|
.filter(({ row }) => api.get(row!.spriteKey)!.is_default !== '1')
|
||||||
|
.map(
|
||||||
|
({ d, row }) =>
|
||||||
|
`${d.pokemon} '${d.form}' -> ${api.get(row!.spriteKey)!.identifier} (is_default=0)`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(disagreements).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks a meaningful number of picks rather than silently resolving none', () => {
|
||||||
|
// Guards the test above: form-suffixed sprite keys (666-meadow, 201-a, ...) have no
|
||||||
|
// PokeAPI equivalent, so if the join broke entirely this would still pass vacuously.
|
||||||
|
const verifiable = declaredDefaults
|
||||||
|
.filter((d) => !DEFAULT_FORM_EXCEPTIONS.has(d.pokemon))
|
||||||
|
.map((d) => bySpeciesForm.get(`${d.pokemon}|${d.form}`))
|
||||||
|
.filter((row) => row && /^\d+$/.test(row.spriteKey) && api.has(row.spriteKey));
|
||||||
|
|
||||||
|
expect(verifiable.length).toBeGreaterThanOrEqual(28);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default-form coverage', () => {
|
||||||
|
it('declares a default for every species whose forms are all named', () => {
|
||||||
|
// The original bug: Basculin's forms are all named, so nothing matched `form IS NULL`
|
||||||
|
// and it disappeared from every non-form dex. Any future species added the same way
|
||||||
|
// must be given an explicit default here.
|
||||||
|
const bySpecies = new Map<string, Record<string, string>[]>();
|
||||||
|
for (const r of pokemon) {
|
||||||
|
bySpecies.set(r.pokemon, [...(bySpecies.get(r.pokemon) ?? []), r]);
|
||||||
|
}
|
||||||
|
const declared = new Set(declaredDefaults.map((d) => d.pokemon));
|
||||||
|
|
||||||
|
const undeclared = [...bySpecies.entries()]
|
||||||
|
.filter(([species, rows]) => rows.every((r) => r.form !== '') && !declared.has(species))
|
||||||
|
.map(([species, rows]) => `${species} (forms: ${rows.map((r) => r.form).join(', ')})`);
|
||||||
|
|
||||||
|
expect(undeclared).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares at most one default per species', () => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const d of declaredDefaults) counts.set(d.pokemon, (counts.get(d.pokemon) ?? 0) + 1);
|
||||||
|
expect([...counts.entries()].filter(([, n]) => n > 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { normalizedIdentity, readRepoCsv } from '../support/csv';
|
||||||
|
|
||||||
|
const pokemon = readRepoCsv('data/csvs/pokemon.csv');
|
||||||
|
const games = readRepoCsv('data/csvs/games.csv');
|
||||||
|
const regions = readRepoCsv('data/csvs/regions.csv');
|
||||||
|
const dexes = readRepoCsv('data/csvs/game-dexes.csv');
|
||||||
|
|
||||||
|
function duplicates(values: string[]) {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||||
|
return [...counts].filter(([, count]) => count > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reference-data relationships', () => {
|
||||||
|
it('uses unique, valid region and game identities', () => {
|
||||||
|
expect(duplicates(regions.map((row) => normalizedIdentity(row.region)))).toEqual([]);
|
||||||
|
expect(duplicates(games.map((row) => normalizedIdentity(row.game)))).toEqual([]);
|
||||||
|
const knownRegions = new Set(regions.map((row) => row.region));
|
||||||
|
expect(games.filter((row) => !knownRegions.has(row.region))).toEqual([]);
|
||||||
|
expect(
|
||||||
|
games.filter(
|
||||||
|
(row) => !Number.isInteger(Number(row.releaseYear)) || Number(row.releaseYear) < 1996
|
||||||
|
)
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses unique dex ids and references known games and tracked files', () => {
|
||||||
|
expect(duplicates(dexes.map((row) => normalizedIdentity(row.dexId)))).toEqual([]);
|
||||||
|
const knownGames = new Set(games.map((row) => row.game));
|
||||||
|
expect(dexes.filter((row) => !knownGames.has(row.gameDisplayName))).toEqual([]);
|
||||||
|
expect(dexes.filter((row) => !existsSync(resolve('data/csvs', row.file)))).toEqual([]);
|
||||||
|
const knownDexes = new Set(dexes.map((row) => row.dexId));
|
||||||
|
expect(dexes.filter((row) => row.parentDexId && !knownDexes.has(row.parentDexId))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Pokémon origin references valid', () => {
|
||||||
|
const knownRegions = new Set(regions.map((row) => row.region));
|
||||||
|
const knownGames = new Set(games.map((row) => row.game));
|
||||||
|
expect(pokemon.filter((row) => !knownRegions.has(row.originRegionToCatchIn))).toEqual([]);
|
||||||
|
const unknownGames = pokemon.flatMap((row) =>
|
||||||
|
row.originGamesToCatchIn
|
||||||
|
.split('/')
|
||||||
|
.map((game) => game.trim())
|
||||||
|
.filter((game) => game && !knownGames.has(game))
|
||||||
|
.map((game) => `${row.pokemon}: ${game}`)
|
||||||
|
);
|
||||||
|
expect(unknownGames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves every native-dex member to a unique Pokémon identity', () => {
|
||||||
|
const identities = new Set(pokemon.map((row) => normalizedIdentity(row.pokemon, row.form)));
|
||||||
|
const species = new Set(pokemon.map((row) => normalizedIdentity(row.pokemon)));
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const file of new Set(dexes.map((row) => row.file))) {
|
||||||
|
const rows = readRepoCsv(`data/csvs/${file}`);
|
||||||
|
const memberIdentities = rows.map((row) => normalizedIdentity(row.pokemon, row.form));
|
||||||
|
for (const duplicate of duplicates(memberIdentities))
|
||||||
|
failures.push(`${file}: duplicate ${duplicate[0]}`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const resolves = row.form
|
||||||
|
? identities.has(normalizedIdentity(row.pokemon, row.form))
|
||||||
|
: species.has(normalizedIdentity(row.pokemon));
|
||||||
|
if (!resolves) {
|
||||||
|
failures.push(`${file}: unknown ${row.pokemon}|${row.form}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.dexNumber &&
|
||||||
|
(!Number.isInteger(Number(row.dexNumber)) || Number(row.dexNumber) < 0)
|
||||||
|
) {
|
||||||
|
failures.push(`${file}: invalid dex number ${row.dexNumber}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a tracked normal sprite for every declared sprite key', () => {
|
||||||
|
const missing = pokemon
|
||||||
|
.filter((row) => {
|
||||||
|
const folder = /^female\b/i.test(row.form) ? 'female' : '';
|
||||||
|
return !existsSync(resolve('static/sprites-small/home', folder, `${row.spriteKey}.webp`));
|
||||||
|
})
|
||||||
|
.map((row) => `${row.pokemon}|${row.form} -> ${row.spriteKey}`);
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { requireLoopbackUrl } from '../support/loopback';
|
||||||
|
|
||||||
|
const url = requireLoopbackUrl(
|
||||||
|
process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321',
|
||||||
|
'TEST_SUPABASE_URL'
|
||||||
|
);
|
||||||
|
const anonKey = process.env.TEST_SUPABASE_ANON_KEY;
|
||||||
|
const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
describe('database integrity and ownership', () => {
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!anonKey || !serviceKey) {
|
||||||
|
throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!serviceKey) return;
|
||||||
|
const admin = createClient(url, serviceKey);
|
||||||
|
await Promise.all(createdUserIds.map((id) => admin.auth.admin.deleteUser(id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const email = `integration-cascade-${Date.now()}@example.test`;
|
||||||
|
const { data: created, error: userError } = await admin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: 'Integration123!',
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
expect(userError).toBeNull();
|
||||||
|
const userId = created.user!.id;
|
||||||
|
createdUserIds.push(userId);
|
||||||
|
const { data: dex, error: dexError } = await admin
|
||||||
|
.from('pokedexes')
|
||||||
|
.insert({ userId, name: 'Cascade', isLivingDex: true })
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
expect(dexError).toBeNull();
|
||||||
|
const { data: pokemon } = await admin
|
||||||
|
.from('pokemon')
|
||||||
|
.select('id')
|
||||||
|
.order('id')
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
const record = { userId, pokedexId: dex!.id, pokemonId: pokemon!.id, caught: true };
|
||||||
|
expect((await admin.from('catch_records').insert(record)).error).toBeNull();
|
||||||
|
expect((await admin.from('catch_records').insert(record)).error?.code).toBe('23505');
|
||||||
|
|
||||||
|
expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull();
|
||||||
|
const { count } = await admin
|
||||||
|
.from('catch_records')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('pokedexId', dex!.id);
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not disclose another user's Pokédex through row-level security", async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const stamp = Date.now();
|
||||||
|
const password = 'Integration123!';
|
||||||
|
const firstEmail = `integration-owner-${stamp}@example.test`;
|
||||||
|
const secondEmail = `integration-other-${stamp}@example.test`;
|
||||||
|
const first = await admin.auth.admin.createUser({
|
||||||
|
email: firstEmail,
|
||||||
|
password,
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
const second = await admin.auth.admin.createUser({
|
||||||
|
email: secondEmail,
|
||||||
|
password,
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
expect(first.error).toBeNull();
|
||||||
|
expect(second.error).toBeNull();
|
||||||
|
createdUserIds.push(first.data.user!.id, second.data.user!.id);
|
||||||
|
const { data: dex } = await admin
|
||||||
|
.from('pokedexes')
|
||||||
|
.insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true })
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const other = createClient(url, anonKey!);
|
||||||
|
expect(
|
||||||
|
(await other.auth.signInWithPassword({ email: secondEmail, password })).error
|
||||||
|
).toBeNull();
|
||||||
|
const { data, error } = await other.from('pokedexes').select('id').eq('id', dex!.id);
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps mapping membership unique for each Pokédex', async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const email = `integration-mapping-${Date.now()}@example.test`;
|
||||||
|
const created = await admin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: 'Integration123!',
|
||||||
|
email_confirm: true
|
||||||
|
});
|
||||||
|
createdUserIds.push(created.data.user!.id);
|
||||||
|
const { data: dex } = await admin
|
||||||
|
.from('pokedexes')
|
||||||
|
.insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true })
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
const { data: pokemon } = await admin
|
||||||
|
.from('pokemon')
|
||||||
|
.select('id')
|
||||||
|
.order('id')
|
||||||
|
.limit(1)
|
||||||
|
.single();
|
||||||
|
const mapping = { pokedexId: dex!.id, pokemonId: pokemon!.id };
|
||||||
|
expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error).toBeNull();
|
||||||
|
expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error?.code).toBe('23505');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mapping rows that reference missing records', async () => {
|
||||||
|
const admin = createClient(url, serviceKey!);
|
||||||
|
const { error } = await admin.from('pokedex_pokemon_mapping').insert({
|
||||||
|
pokedexId: '00000000-0000-0000-0000-000000000000',
|
||||||
|
pokemonId: -1
|
||||||
|
});
|
||||||
|
expect(error?.code).toBe('23503');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
import { readRepoCsv } from '../support/csv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Black-box data and repository regressions. These tests deliberately use only the schema
|
||||||
|
* available before the fix: on master they load normally and fail on behavior, while the
|
||||||
|
* fix branch makes the same assertions pass without test-only schema knowledge.
|
||||||
|
*/
|
||||||
|
const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
|
||||||
|
const SETUP_HINT =
|
||||||
|
'Run integration tests through "npm run test:integration", which reads the local keys from "supabase status".';
|
||||||
|
|
||||||
|
// No baked-in key: a fallback would silently point a misconfigured run at the wrong stack
|
||||||
|
// instead of failing with the instruction above.
|
||||||
|
function requireAnonKey(): string {
|
||||||
|
const key = process.env.TEST_SUPABASE_ANON_KEY;
|
||||||
|
if (!key) throw new Error(`Integration tests require TEST_SUPABASE_ANON_KEY. ${SETUP_HINT}`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireSupabase() {
|
||||||
|
const key = requireAnonKey();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_entries?select=id&limit=1`, {
|
||||||
|
headers: { apikey: key, Authorization: `Bearer ${key}` },
|
||||||
|
signal: AbortSignal.timeout(2000)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Local Supabase is required for integration tests at ${SUPABASE_URL}. Run "npm run supabase:start" and "npm run supabase:reset" first. ${String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: number;
|
||||||
|
pokedexNumber: number;
|
||||||
|
pokemon: string;
|
||||||
|
form: string | null;
|
||||||
|
spriteKey: string | null;
|
||||||
|
isDefaultForm: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('pokedex behavior regressions', () => {
|
||||||
|
let supabase: SupabaseClient;
|
||||||
|
let rows: Row[];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await requireSupabase();
|
||||||
|
supabase = createClient(SUPABASE_URL, requireAnonKey());
|
||||||
|
const all: Row[] = [];
|
||||||
|
for (let from = 0; ; from += 1000) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('pokedex_entries')
|
||||||
|
.select('id, pokedexNumber, pokemon, form, spriteKey, isDefaultForm, notes')
|
||||||
|
.order('id', { ascending: true })
|
||||||
|
.range(from, from + 999);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
if (!data?.length) break;
|
||||||
|
all.push(...(data as Row[]));
|
||||||
|
if (data.length < 1000) break;
|
||||||
|
}
|
||||||
|
rows = all;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns one canonical representative for species whose forms are all named', async () => {
|
||||||
|
const repo = new CombinedDataRepository(supabase, null, null);
|
||||||
|
const entries = (await repo.findAllCombinedData('', false)).map((item) => item.pokedexEntry);
|
||||||
|
|
||||||
|
for (const [species, form] of Object.entries({
|
||||||
|
Basculin: 'Red-striped',
|
||||||
|
Tornadus: 'Incarnate Form',
|
||||||
|
Oricorio: 'Baile (Red)',
|
||||||
|
Zygarde: '50%',
|
||||||
|
Gimmighoul: 'Box Form',
|
||||||
|
Rotom: 'Lightbulb'
|
||||||
|
})) {
|
||||||
|
const found = entries.filter((entry) => entry.pokemon === species);
|
||||||
|
expect(found, `${species} should appear exactly once`).toHaveLength(1);
|
||||||
|
expect(found[0].form).toBe(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Beautifly').map((e) => e.form)).toEqual([
|
||||||
|
'male'
|
||||||
|
]);
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Unown').map((e) => e.form)).toEqual(['A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns every alternate form exactly once when forms are enabled', async () => {
|
||||||
|
const repo = new CombinedDataRepository(supabase, null, null);
|
||||||
|
const entries = (await repo.findAllCombinedData('', true)).map((item) => item.pokedexEntry);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.pokemon === 'Basculin')
|
||||||
|
.map((entry) => entry.form)
|
||||||
|
.sort()
|
||||||
|
).toEqual(['Blue-striped', 'Red-striped', 'White-striped']);
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Alcremie')).toHaveLength(63);
|
||||||
|
expect(entries.filter((entry) => entry.pokemon === 'Unown')).toHaveLength(28);
|
||||||
|
expect(new Set(entries.map((entry) => entry._id)).size).toBe(entries.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps named default forms in a game-scoped form dex without duplicates', async () => {
|
||||||
|
const repo = new CombinedDataRepository(supabase, null, null);
|
||||||
|
const rotom = (await repo.findAllCombinedData('', true, '', 'Black', ['black-unova']))
|
||||||
|
.map((item) => item.pokedexEntry)
|
||||||
|
.filter((entry) => entry.pokemon === 'Rotom');
|
||||||
|
|
||||||
|
expect(rotom.map((entry) => entry.form)).toContain('Lightbulb');
|
||||||
|
expect(rotom).toHaveLength(6);
|
||||||
|
expect(new Set(rotom.map((entry) => entry._id)).size).toBe(rotom.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the correct national dex numbers and sprite for corrected rows', () => {
|
||||||
|
const wyrdeer = rows.find((row) => row.pokemon === 'Wyrdeer');
|
||||||
|
expect(wyrdeer).toMatchObject({ pokedexNumber: 899, spriteKey: '899' });
|
||||||
|
expect(rows.find((row) => row.pokemon === 'Gimmighoul')?.pokedexNumber).toBe(999);
|
||||||
|
expect(
|
||||||
|
rows.find((row) => row.pokemon === 'Ursaluna' && row.form === 'Bloodmoon')?.pokedexNumber
|
||||||
|
).toBe(901);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns every expected entry despite the PostgREST row cap', async () => {
|
||||||
|
const { calculateExpectedEntries } = await import('$lib/services/PokedexMappingService');
|
||||||
|
const baseDex = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
isLivingDex: true,
|
||||||
|
isShinyDex: false,
|
||||||
|
isOriginDex: false,
|
||||||
|
isFormDex: false,
|
||||||
|
gameScope: null,
|
||||||
|
dexScopes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseIds = await calculateExpectedEntries(supabase, baseDex as never);
|
||||||
|
const formIds = await calculateExpectedEntries(supabase, {
|
||||||
|
...baseDex,
|
||||||
|
isFormDex: true
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(baseIds).toHaveLength(1025);
|
||||||
|
expect(new Set(baseIds).size).toBe(baseIds.length);
|
||||||
|
expect(formIds).toHaveLength(rows.length);
|
||||||
|
expect(new Set(formIds).size).toBe(formIds.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the tracked CSV synchronized with the database', () => {
|
||||||
|
const fromCsv = new Map(
|
||||||
|
readRepoCsv('data/csvs/pokemon.csv').map((row) => [
|
||||||
|
`${row.pokemon}|${row.form}`,
|
||||||
|
row.pokedexNumber
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const fromDb = new Map(
|
||||||
|
rows.map((row) => [`${row.pokemon}|${row.form ?? ''}`, String(row.pokedexNumber)])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect({
|
||||||
|
onlyInCsv: [...fromCsv.keys()].filter((key) => !fromDb.has(key)),
|
||||||
|
onlyInDb: [...fromDb.keys()].filter((key) => !fromCsv.has(key)),
|
||||||
|
differing: [...fromCsv.entries()]
|
||||||
|
.filter(([key, value]) => fromDb.has(key) && fromDb.get(key) !== value)
|
||||||
|
.map(([key, value]) => `${key}: csv ${value} vs db ${fromDb.get(key)}`)
|
||||||
|
}).toEqual({ onlyInCsv: [], onlyInDb: [], differing: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects exactly one default form per species through the public view', () => {
|
||||||
|
const bySpecies = new Map<string, Row[]>();
|
||||||
|
for (const row of rows)
|
||||||
|
bySpecies.set(row.pokemon, [...(bySpecies.get(row.pokemon) ?? []), row]);
|
||||||
|
const invalid = [...bySpecies]
|
||||||
|
.filter(([, entries]) => entries.filter((entry) => entry.isDefaultForm).length !== 1)
|
||||||
|
.map(([species, entries]) => ({
|
||||||
|
species,
|
||||||
|
defaults: entries.filter((entry) => entry.isDefaultForm).map((entry) => entry.form)
|
||||||
|
}));
|
||||||
|
expect(invalid).toEqual([]);
|
||||||
|
expect(rows[0]).toHaveProperty('notes');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export type CsvRow = Record<string, string>;
|
||||||
|
|
||||||
|
export function parseCsv(text: string): CsvRow[] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let cell = '';
|
||||||
|
let quoted = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < text.length; index++) {
|
||||||
|
const char = text[index];
|
||||||
|
if (char === '"') {
|
||||||
|
if (quoted && text[index + 1] === '"') {
|
||||||
|
cell += '"';
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
quoted = !quoted;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !quoted) {
|
||||||
|
row.push(cell);
|
||||||
|
cell = '';
|
||||||
|
} else if ((char === '\n' || char === '\r') && !quoted) {
|
||||||
|
if (char === '\r' && text[index + 1] === '\n') index++;
|
||||||
|
row.push(cell);
|
||||||
|
if (row.some((value) => value.length > 0)) rows.push(row);
|
||||||
|
row = [];
|
||||||
|
cell = '';
|
||||||
|
} else {
|
||||||
|
cell += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quoted) throw new Error('Malformed CSV: unclosed quoted field');
|
||||||
|
if (cell.length > 0 || row.length > 0) {
|
||||||
|
row.push(cell);
|
||||||
|
if (row.some((value) => value.length > 0)) rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headers, ...records] = rows;
|
||||||
|
if (!headers) return [];
|
||||||
|
return records.map((record, rowIndex) => {
|
||||||
|
if (record.length !== headers.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Malformed CSV row ${rowIndex + 2}: expected ${headers.length} columns, received ${record.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.fromEntries(
|
||||||
|
headers.map((header, index) => [header.trim(), record[index].trim()])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRepoCsv(relativePath: string): CsvRow[] {
|
||||||
|
return parseCsv(readFileSync(resolve(process.cwd(), relativePath), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typographic and straight apostrophes are the same identity here (Farfetch'd, Sirfetch'd), so
|
||||||
|
* the comparison absorbs the difference rather than forcing every data file to agree on one
|
||||||
|
* code point.
|
||||||
|
*/
|
||||||
|
export function normalizedIdentity(...parts: Array<string | null | undefined>): string {
|
||||||
|
return parts
|
||||||
|
.map((part) => (part ?? '').trim().replace(/[‘’ʼ]/g, "'").toLocaleLowerCase('en-GB'))
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Stands in for `$env/dynamic/private` so modules that reach for runtime env can be unit
|
||||||
|
* tested. Vitest maps the virtual module here; see vitest.config.mts.
|
||||||
|
*/
|
||||||
|
export const env: Record<string, string | undefined> = process.env;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
||||||
|
|
||||||
|
export function isLoopbackUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
return LOOPBACK_HOSTS.has(new URL(value).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireLoopbackUrl(value: string, label: string): string {
|
||||||
|
if (!isLoopbackUrl(value))
|
||||||
|
throw new Error(`Refusing to send credentials to non-loopback ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';
|
||||||
|
|
||||||
|
export default class NoSkipsReporter implements Reporter {
|
||||||
|
private skipped: string[] = [];
|
||||||
|
|
||||||
|
onTestEnd(test: TestCase, result: TestResult) {
|
||||||
|
if (result.status === 'skipped') this.skipped.push(test.titlePath().join(' › '));
|
||||||
|
}
|
||||||
|
|
||||||
|
onEnd(result: FullResult) {
|
||||||
|
if (this.skipped.length === 0) return;
|
||||||
|
for (const title of this.skipped)
|
||||||
|
process.stderr.write(`Unexpected skipped BDD scenario: ${title}\n`);
|
||||||
|
return { status: 'failed' as const, startTime: result.startTime, duration: result.duration };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getOptionalUserId, requireAuth } from '$lib/utils/auth';
|
||||||
|
|
||||||
|
function eventReturning(value: unknown) {
|
||||||
|
return { locals: { safeGetSession: vi.fn(async () => value) } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('authentication guards', () => {
|
||||||
|
it('returns the authenticated user id', async () => {
|
||||||
|
await expect(
|
||||||
|
requireAuth(eventReturning({ session: {}, user: { id: 'user-1' } }))
|
||||||
|
).resolves.toBe('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ session: null, user: null },
|
||||||
|
{ session: {}, user: null },
|
||||||
|
{ session: null, user: { id: 'user-1' } }
|
||||||
|
])('rejects an incomplete authenticated session', async (value) => {
|
||||||
|
await expect(requireAuth(eventReturning(value))).rejects.toMatchObject({ status: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('optionally returns a user id or null', async () => {
|
||||||
|
await expect(
|
||||||
|
getOptionalUserId(eventReturning({ session: {}, user: { id: 'user-2' } }))
|
||||||
|
).resolves.toBe('user-2');
|
||||||
|
await expect(
|
||||||
|
getOptionalUserId(eventReturning({ session: null, user: null }))
|
||||||
|
).resolves.toBeNull();
|
||||||
|
const throwing = {
|
||||||
|
locals: { safeGetSession: vi.fn(async () => Promise.reject(new Error('unavailable'))) }
|
||||||
|
} as never;
|
||||||
|
await expect(getOptionalUserId(throwing)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { calculateBoxNumbers, calculateBoxPlacement } from '$lib/utils/boxPlacement';
|
||||||
|
|
||||||
|
describe('box placement', () => {
|
||||||
|
it.each([
|
||||||
|
[0, { box: 1, row: 1, column: 1 }],
|
||||||
|
[5, { box: 1, row: 1, column: 6 }],
|
||||||
|
[6, { box: 1, row: 2, column: 1 }],
|
||||||
|
[29, { box: 1, row: 5, column: 6 }],
|
||||||
|
[30, { box: 2, row: 1, column: 1 }]
|
||||||
|
])('places zero-based entry %i in its box grid', (index, expected) => {
|
||||||
|
expect(calculateBoxPlacement(index)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, []],
|
||||||
|
[1, [1]],
|
||||||
|
[30, [1]],
|
||||||
|
[31, [1, 2]],
|
||||||
|
[1025, Array.from({ length: 35 }, (_, index) => index + 1)]
|
||||||
|
])('calculates box numbers for %i entries', (count, expected) => {
|
||||||
|
expect(calculateBoxNumbers(count)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createCatchRecordWriteQueue } from '$lib/utils/catchRecordWriteQueue';
|
||||||
|
import type { CatchRecord } from '$lib/models/CatchRecord';
|
||||||
|
|
||||||
|
function mkRecord(overrides: Partial<CatchRecord> = {}): CatchRecord {
|
||||||
|
return {
|
||||||
|
_id: '',
|
||||||
|
userId: 'u1',
|
||||||
|
pokedexId: 'p1',
|
||||||
|
pokemonId: '25',
|
||||||
|
haveToEvolve: false,
|
||||||
|
caught: false,
|
||||||
|
inHome: false,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: '',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createCatchRecordWriteQueue()', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces multiple updates for the same key and flushes only the latest state', async () => {
|
||||||
|
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
return new Response(init?.body as string, { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const queue = createCatchRecordWriteQueue({
|
||||||
|
endpointUrl: '/api/pokedexes/p1/catch-records',
|
||||||
|
fetchFn,
|
||||||
|
batchSize: 50,
|
||||||
|
concurrency: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue(mkRecord({ caught: true }));
|
||||||
|
queue.enqueue(mkRecord({ caught: false, haveToEvolve: true }));
|
||||||
|
|
||||||
|
await queue.flushNow();
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body));
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].haveToEvolve).toBe(true);
|
||||||
|
expect(body[0].caught).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces notes updates before flushing', async () => {
|
||||||
|
const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
return new Response(init?.body as string, { status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const queue = createCatchRecordWriteQueue({
|
||||||
|
endpointUrl: '/api/pokedexes/p1/catch-records',
|
||||||
|
fetchFn,
|
||||||
|
batchSize: 50,
|
||||||
|
concurrency: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 });
|
||||||
|
await queue.flushNow();
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(499);
|
||||||
|
await queue.flushNow();
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
await queue.flushNow();
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports failures, clears errors, and retries after exponential backoff', async () => {
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(new Response('temporarily unavailable', { status: 503 }))
|
||||||
|
.mockResolvedValueOnce(new Response('[]', { status: 200 }));
|
||||||
|
const queue = createCatchRecordWriteQueue({
|
||||||
|
endpointUrl: '/catch-records',
|
||||||
|
fetchFn
|
||||||
|
});
|
||||||
|
let status = null as null | {
|
||||||
|
pending: number;
|
||||||
|
lastError: string | null;
|
||||||
|
lastSuccessfulFlushAt: number | null;
|
||||||
|
};
|
||||||
|
const unsubscribe = queue.getStatus.subscribe((value) => (status = value));
|
||||||
|
|
||||||
|
queue.enqueue(mkRecord(), { flushSoon: false });
|
||||||
|
expect(queue.getPendingCount()).toBe(1);
|
||||||
|
await queue.flushNow();
|
||||||
|
expect(status?.lastError).toContain('503 temporarily unavailable');
|
||||||
|
expect(queue.getPendingCount()).toBe(1);
|
||||||
|
|
||||||
|
queue.clearError();
|
||||||
|
expect(status?.lastError).toBeNull();
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
await queue.flushNow();
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(queue.getPendingCount()).toBe(0);
|
||||||
|
expect(status?.lastSuccessfulFlushAt).not.toBeNull();
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours batch limits and keepalive while draining eligible records', async () => {
|
||||||
|
const fetchFn = vi.fn<typeof fetch>(async () => new Response('[]', { status: 200 }));
|
||||||
|
const queue = createCatchRecordWriteQueue({
|
||||||
|
endpointUrl: '/catch-records',
|
||||||
|
fetchFn,
|
||||||
|
batchSize: 50
|
||||||
|
});
|
||||||
|
queue.enqueue(mkRecord({ pokemonId: '1' }), { flushSoon: false });
|
||||||
|
queue.enqueue(mkRecord({ pokemonId: '2' }), { flushSoon: false });
|
||||||
|
queue.enqueue(mkRecord({ pokemonId: '3' }), { flushSoon: false });
|
||||||
|
|
||||||
|
await queue.flushNow({ limit: 2, keepalive: true });
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body))).toHaveLength(2);
|
||||||
|
expect(JSON.parse(String(fetchFn.mock.calls[1][1]?.body))).toHaveLength(1);
|
||||||
|
expect(fetchFn.mock.calls.every(([, init]) => init?.keepalive === true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains work while the browser is offline', async () => {
|
||||||
|
vi.stubGlobal('navigator', { onLine: false });
|
||||||
|
const fetchFn = vi.fn<typeof fetch>();
|
||||||
|
const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn });
|
||||||
|
queue.enqueue(mkRecord(), { flushSoon: false });
|
||||||
|
|
||||||
|
await queue.flushNow();
|
||||||
|
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled();
|
||||||
|
expect(queue.getPendingCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not discard a newer version enqueued during an in-flight request', async () => {
|
||||||
|
let resolveFirst: ((response: Response) => void) | undefined;
|
||||||
|
const firstResponse = new Promise<Response>((resolve) => (resolveFirst = resolve));
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockReturnValueOnce(firstResponse)
|
||||||
|
.mockResolvedValueOnce(new Response('[]', { status: 200 }));
|
||||||
|
const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn });
|
||||||
|
queue.enqueue(mkRecord({ personalNotes: 'old' }), { flushSoon: false });
|
||||||
|
|
||||||
|
const flushing = queue.flushNow();
|
||||||
|
await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
|
||||||
|
queue.enqueue(mkRecord({ personalNotes: 'new' }), { flushSoon: false });
|
||||||
|
resolveFirst?.(new Response('[]', { status: 200 }));
|
||||||
|
await flushing;
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||||
|
const latest = JSON.parse(String(fetchFn.mock.calls[1][1]?.body));
|
||||||
|
expect(latest[0].personalNotes).toBe('new');
|
||||||
|
expect(queue.getPendingCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores non-Error failures as readable status text', async () => {
|
||||||
|
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||||
|
throw 'network down';
|
||||||
|
});
|
||||||
|
const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn });
|
||||||
|
let lastError: string | null = null;
|
||||||
|
queue.getStatus.subscribe((status) => (lastError = status.lastError));
|
||||||
|
queue.enqueue(mkRecord(), { flushSoon: false });
|
||||||
|
await queue.flushNow();
|
||||||
|
expect(lastError).toBe('network down');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||||
|
|
||||||
|
type Call = { method: string; args: unknown[] };
|
||||||
|
type TableQuery = { table: string; calls: Call[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal recording stand-in for a Supabase query builder.
|
||||||
|
*
|
||||||
|
* Every chained call is recorded and returns the builder, and the builder is thenable so
|
||||||
|
* `await query` / `await query.range(...)` resolve like a real PostgREST response. Returning
|
||||||
|
* an empty data set keeps the repository's paging loops to a single iteration.
|
||||||
|
*/
|
||||||
|
function createSupabaseStub() {
|
||||||
|
const queries: TableQuery[] = [];
|
||||||
|
|
||||||
|
const from = (table: string) => {
|
||||||
|
const record: TableQuery = { table, calls: [] };
|
||||||
|
queries.push(record);
|
||||||
|
|
||||||
|
const builder: Record<string, unknown> = new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, prop: string) {
|
||||||
|
if (prop === 'then') {
|
||||||
|
return (resolve: (value: unknown) => unknown) =>
|
||||||
|
resolve({ data: [], error: null, count: 0 });
|
||||||
|
}
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
record.calls.push({ method: prop, args });
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { supabase: { from } as never, queries };
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryFor = (queries: TableQuery[], table: string) => queries.filter((q) => q.table === table);
|
||||||
|
|
||||||
|
const hasCall = (q: TableQuery, method: string, args: unknown[]) =>
|
||||||
|
q.calls.some((c) => c.method === method && JSON.stringify(c.args) === JSON.stringify(args));
|
||||||
|
|
||||||
|
const mentionsIsDefaultForm = (q: TableQuery) =>
|
||||||
|
q.calls.some((c) => JSON.stringify(c.args).includes('isDefaultForm'));
|
||||||
|
|
||||||
|
describe('CombinedDataRepository base-form filtering', () => {
|
||||||
|
it('filters to default forms only when the form dex toggle is off', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', null);
|
||||||
|
|
||||||
|
await repo.findAllCombinedData('user-1', false, '', '', []);
|
||||||
|
|
||||||
|
const [entries] = queryFor(queries, 'pokedex_entries');
|
||||||
|
expect(entries).toBeDefined();
|
||||||
|
expect(hasCall(entries, 'eq', ['isDefaultForm', true])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no form filter at all when the form dex toggle is on', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', null);
|
||||||
|
|
||||||
|
await repo.findAllCombinedData('user-1', true, '', '', []);
|
||||||
|
|
||||||
|
const [entries] = queryFor(queries, 'pokedex_entries');
|
||||||
|
expect(mentionsIsDefaultForm(entries)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters dex-scoped queries to default forms when the form dex toggle is off', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', null);
|
||||||
|
|
||||||
|
await repo.findAllCombinedData('user-1', false, '', '', ['black-unova']);
|
||||||
|
|
||||||
|
const [dexEntries] = queryFor(queries, 'game_pokedex_entry_details');
|
||||||
|
expect(dexEntries).toBeDefined();
|
||||||
|
expect(hasCall(dexEntries, 'eq', ['isDefaultForm', true])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts with the same default-form filter the listing uses', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', null);
|
||||||
|
|
||||||
|
await repo.countCombinedData(false, '', '', []);
|
||||||
|
|
||||||
|
const [entries] = queryFor(queries, 'pokedex_entries');
|
||||||
|
expect(hasCall(entries, 'eq', ['isDefaultForm', true])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression guard. game_pokedex_entries is seeded from `form IS NULL` rows, so a default
|
||||||
|
* form that has a NAME (e.g. Rotom "Lightbulb", Basculin "Red-striped") is absent from the
|
||||||
|
* game dex tables and can only reach a game-scoped form dex through this supplement query.
|
||||||
|
*
|
||||||
|
* Switching this filter to `isDefaultForm` looks like a tidy-up, but it silently drops
|
||||||
|
* those rows: base Rotom disappeared from the Black form dex while its five appliance
|
||||||
|
* forms remained. Keep it keyed on `form` - excludeIds already dedupes whatever the dex
|
||||||
|
* table does list.
|
||||||
|
*/
|
||||||
|
it('supplements game forms by form name, never by isDefaultForm', async () => {
|
||||||
|
const { supabase, queries } = createSupabaseStub();
|
||||||
|
const repo = new CombinedDataRepository(supabase, 'user-1', null);
|
||||||
|
|
||||||
|
await repo.findAllCombinedData('user-1', true, '', 'Black', ['black-unova']);
|
||||||
|
|
||||||
|
const supplements = queryFor(queries, 'pokedex_entries');
|
||||||
|
expect(supplements.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const supplement = supplements[0];
|
||||||
|
expect(hasCall(supplement, 'not', ['form', 'is', null])).toBe(true);
|
||||||
|
expect(mentionsIsDefaultForm(supplement)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
clearOAuthStateCookie,
|
||||||
|
createOAuthState,
|
||||||
|
readOAuthStateCookie,
|
||||||
|
setOAuthStateCookie,
|
||||||
|
type OAuthStatePayload
|
||||||
|
} from '$lib/utils/oauthState';
|
||||||
|
|
||||||
|
function eventWithCookie(raw?: string) {
|
||||||
|
return {
|
||||||
|
url: new URL('https://example.test/callback'),
|
||||||
|
cookies: {
|
||||||
|
get: vi.fn(() => raw),
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn()
|
||||||
|
}
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OAuth state cookies', () => {
|
||||||
|
it('creates opaque unique state values', () => {
|
||||||
|
const first = createOAuthState();
|
||||||
|
const second = createOAuthState();
|
||||||
|
expect(first).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a secure, short-lived, provider-scoped cookie', () => {
|
||||||
|
const event = eventWithCookie();
|
||||||
|
const payload: OAuthStatePayload = {
|
||||||
|
state: 'state-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
provider: 'google_drive',
|
||||||
|
returnTo: '/backup-settings'
|
||||||
|
};
|
||||||
|
setOAuthStateCookie(event, 'google_drive', payload);
|
||||||
|
expect(event.cookies.set).toHaveBeenCalledWith(
|
||||||
|
'oauth_state_google_drive',
|
||||||
|
JSON.stringify(payload),
|
||||||
|
expect.objectContaining({ httpOnly: true, sameSite: 'lax', secure: true, maxAge: 600 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads only structurally valid state', () => {
|
||||||
|
expect(readOAuthStateCookie(eventWithCookie('{bad json'), 'dropbox')).toBeNull();
|
||||||
|
expect(readOAuthStateCookie(eventWithCookie('{}'), 'dropbox')).toBeNull();
|
||||||
|
expect(readOAuthStateCookie(eventWithCookie(), 'dropbox')).toBeNull();
|
||||||
|
expect(
|
||||||
|
readOAuthStateCookie(
|
||||||
|
eventWithCookie(JSON.stringify({ state: 's', userId: 'u', provider: 'dropbox' })),
|
||||||
|
'dropbox'
|
||||||
|
)
|
||||||
|
).toMatchObject({ state: 's', userId: 'u' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the provider cookie at the shared path', () => {
|
||||||
|
const event = eventWithCookie();
|
||||||
|
clearOAuthStateCookie(event, 'dropbox');
|
||||||
|
expect(event.cookies.delete).toHaveBeenCalledWith('oauth_state_dropbox', { path: '/' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildCsv,
|
||||||
|
csvEscape,
|
||||||
|
sanitizeFileName,
|
||||||
|
shouldRefreshToken
|
||||||
|
} from '$lib/services/PokedexExportFormatting';
|
||||||
|
|
||||||
|
describe('Pokédex export formatting', () => {
|
||||||
|
it.each([
|
||||||
|
[null, ''],
|
||||||
|
[undefined, ''],
|
||||||
|
['plain', 'plain'],
|
||||||
|
['comma,value', '"comma,value"'],
|
||||||
|
['a "quote"', '"a ""quote"""'],
|
||||||
|
['two\nlines', '"two\nlines"']
|
||||||
|
])('escapes CSV value %j', (value, expected) => {
|
||||||
|
expect(csvEscape(value)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes provider filenames while preserving a CSV suffix', () => {
|
||||||
|
expect(sanitizeFileName(' My: Dex? ', 'fallback')).toBe('My- Dex-.csv');
|
||||||
|
expect(sanitizeFileName('already.csv', 'fallback')).toBe('already.csv');
|
||||||
|
expect(sanitizeFileName('***', 'fallback')).toBe('-.csv');
|
||||||
|
expect(sanitizeFileName(' ', 'fallback')).toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a stable, escaped CSV with defaults for missing catch records', () => {
|
||||||
|
const csv = buildCsv([
|
||||||
|
{
|
||||||
|
pokedexEntry: {
|
||||||
|
_id: '25',
|
||||||
|
pokedexNumber: 25,
|
||||||
|
pokemon: 'Pikachu',
|
||||||
|
form: null
|
||||||
|
},
|
||||||
|
catchRecord: {
|
||||||
|
caught: true,
|
||||||
|
haveToEvolve: false,
|
||||||
|
inHome: true,
|
||||||
|
hasGigantamaxed: false,
|
||||||
|
personalNotes: 'Comma, and "quote"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pokedexEntry: {
|
||||||
|
_id: '26',
|
||||||
|
pokedexNumber: 26,
|
||||||
|
pokemon: 'Raichu',
|
||||||
|
form: 'Alolan'
|
||||||
|
},
|
||||||
|
catchRecord: null
|
||||||
|
}
|
||||||
|
] as never);
|
||||||
|
expect(csv.split('\r\n')).toEqual([
|
||||||
|
'pokemonId,pokedexNumber,pokemon,form,caught,haveToEvolve,inHome,personalNotes',
|
||||||
|
'25,25,Pikachu,,true,false,true,"Comma, and ""quote"""',
|
||||||
|
'26,26,Raichu,Alolan,false,false,false,'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes only finite expiries within the next minute', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-09-13T12:00:00Z'));
|
||||||
|
expect(shouldRefreshToken(null)).toBe(false);
|
||||||
|
expect(shouldRefreshToken('not-a-date')).toBe(false);
|
||||||
|
expect(shouldRefreshToken('2026-09-13T12:02:00Z')).toBe(false);
|
||||||
|
expect(shouldRefreshToken('2026-09-13T12:00:30Z')).toBe(true);
|
||||||
|
expect(shouldRefreshToken('2026-09-13T11:59:00Z')).toBe(true);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
PROVIDER_ENDPOINT_DEFAULTS,
|
||||||
|
resolveProviderEndpoints
|
||||||
|
} from '$lib/services/providerEndpoints';
|
||||||
|
|
||||||
|
const localOverrides = {
|
||||||
|
TEST_SUPABASE_URL: 'http://127.0.0.1:54321',
|
||||||
|
E2E_SERVICE_ROLE_KEY: 'local-test-service-key',
|
||||||
|
GOOGLE_OAUTH_AUTHORIZE_URL: 'http://127.0.0.1:4199/google/authorize',
|
||||||
|
GOOGLE_OAUTH_TOKEN_URL: 'http://127.0.0.1:4199/google/token',
|
||||||
|
GOOGLE_DRIVE_API_URL: 'http://127.0.0.1:4199/google/drive',
|
||||||
|
GOOGLE_DRIVE_UPLOAD_URL: 'http://127.0.0.1:4199/google/upload',
|
||||||
|
DROPBOX_OAUTH_AUTHORIZE_URL: 'http://127.0.0.1:4199/dropbox/authorize',
|
||||||
|
DROPBOX_OAUTH_TOKEN_URL: 'http://127.0.0.1:4199/dropbox/token',
|
||||||
|
DROPBOX_UPLOAD_URL: 'http://127.0.0.1:4199/dropbox/files/upload'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('provider endpoints', () => {
|
||||||
|
it('uses the real provider endpoints when nothing is configured', () => {
|
||||||
|
expect(resolveProviderEndpoints({})).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores overrides unless they are explicitly allowed', () => {
|
||||||
|
// The exfiltration case: these variables carry the client secret and refresh token, and
|
||||||
|
// the env is read per request in production.
|
||||||
|
expect(resolveProviderEndpoints(localOverrides)).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||||
|
expect(
|
||||||
|
resolveProviderEndpoints({ ...localOverrides, ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'false' })
|
||||||
|
).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||||
|
expect(
|
||||||
|
resolveProviderEndpoints({ ...localOverrides, ALLOW_PROVIDER_ENDPOINT_OVERRIDES: '1' })
|
||||||
|
).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies allowed loopback overrides', () => {
|
||||||
|
expect(
|
||||||
|
resolveProviderEndpoints({ ...localOverrides, ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true' })
|
||||||
|
).toEqual({
|
||||||
|
google: {
|
||||||
|
authorize: localOverrides.GOOGLE_OAUTH_AUTHORIZE_URL,
|
||||||
|
token: localOverrides.GOOGLE_OAUTH_TOKEN_URL,
|
||||||
|
driveApi: localOverrides.GOOGLE_DRIVE_API_URL,
|
||||||
|
driveUpload: localOverrides.GOOGLE_DRIVE_UPLOAD_URL
|
||||||
|
},
|
||||||
|
dropbox: {
|
||||||
|
authorize: localOverrides.DROPBOX_OAUTH_AUTHORIZE_URL,
|
||||||
|
token: localOverrides.DROPBOX_OAUTH_TOKEN_URL,
|
||||||
|
upload: localOverrides.DROPBOX_UPLOAD_URL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses HTTP overrides without a verified local test stack', () => {
|
||||||
|
const { TEST_SUPABASE_URL, E2E_SERVICE_ROLE_KEY, ...deployedOverrides } = localOverrides;
|
||||||
|
expect(TEST_SUPABASE_URL).toBeTruthy();
|
||||||
|
expect(E2E_SERVICE_ROLE_KEY).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
resolveProviderEndpoints({
|
||||||
|
...deployedOverrides,
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true'
|
||||||
|
})
|
||||||
|
).toEqual(PROVIDER_ENDPOINT_DEFAULTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://attacker.example/token',
|
||||||
|
'http://127.0.0.1.attacker.example/token',
|
||||||
|
'http://[::2]/token',
|
||||||
|
'file:///etc/passwd',
|
||||||
|
'not-a-url',
|
||||||
|
''
|
||||||
|
])('refuses the non-loopback override %j even when overrides are allowed', (value) => {
|
||||||
|
const endpoints = resolveProviderEndpoints({
|
||||||
|
TEST_SUPABASE_URL: localOverrides.TEST_SUPABASE_URL,
|
||||||
|
E2E_SERVICE_ROLE_KEY: localOverrides.E2E_SERVICE_ROLE_KEY,
|
||||||
|
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
|
||||||
|
GOOGLE_OAUTH_TOKEN_URL: value,
|
||||||
|
DROPBOX_OAUTH_TOKEN_URL: value
|
||||||
|
});
|
||||||
|
expect(endpoints.google.token).toBe(PROVIDER_ENDPOINT_DEFAULTS.google.token);
|
||||||
|
expect(endpoints.dropbox.token).toBe(PROVIDER_ENDPOINT_DEFAULTS.dropbox.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts localhost as well as 127.0.0.1', () => {
|
||||||
|
expect(
|
||||||
|
resolveProviderEndpoints({
|
||||||
|
TEST_SUPABASE_URL: localOverrides.TEST_SUPABASE_URL,
|
||||||
|
E2E_SERVICE_ROLE_KEY: localOverrides.E2E_SERVICE_ROLE_KEY,
|
||||||
|
ALLOW_PROVIDER_ENDPOINT_OVERRIDES: 'true',
|
||||||
|
GOOGLE_OAUTH_TOKEN_URL: 'http://localhost:4199/google/token'
|
||||||
|
}).google.token
|
||||||
|
).toBe('http://localhost:4199/google/token');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
getRegionalDexColumnName,
|
||||||
|
getRegionalDexFieldName,
|
||||||
|
getRegionalDexKey,
|
||||||
|
hasRegionalDex
|
||||||
|
} from '$lib/utils/regionalDexMapping';
|
||||||
|
|
||||||
|
describe('regional dex mapping', () => {
|
||||||
|
it.each([
|
||||||
|
['Red', 'kanto', 'kanto_dex_number', 'kantoDexNumber'],
|
||||||
|
['Black2', 'unova_b2w2', 'unova_b2w2_dex_number', 'unovaB2w2DexNumber'],
|
||||||
|
['UltraMoon', 'alola_usum', 'alola_usum_dex_number', 'alolaUsumDexNumber'],
|
||||||
|
['Scarlet', 'paldea', 'paldea_dex_number', 'paldeaDexNumber']
|
||||||
|
])('maps %s consistently', (game, key, column, field) => {
|
||||||
|
expect(hasRegionalDex(game)).toBe(true);
|
||||||
|
expect(getRegionalDexKey(game)).toBe(key);
|
||||||
|
expect(getRegionalDexColumnName(game)).toBe(column);
|
||||||
|
expect(getRegionalDexFieldName(game)).toBe(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no mapping for unknown games', () => {
|
||||||
|
expect(hasRegionalDex('Legends Z-A')).toBe(false);
|
||||||
|
expect(getRegionalDexKey('Legends Z-A')).toBeUndefined();
|
||||||
|
expect(getRegionalDexColumnName('Legends Z-A')).toBeUndefined();
|
||||||
|
expect(getRegionalDexFieldName('Legends Z-A')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||||
|
|
||||||
|
describe('resolveSpriteUrl', () => {
|
||||||
|
it('uses a supplied sprite key and local shiny path', () => {
|
||||||
|
expect(
|
||||||
|
resolveSpriteUrl({ pokedexNumber: 25, form: '', spriteKey: '25-partner-cap' }, true, true)
|
||||||
|
).toBe('/sprites-small/home/shiny/25-partner-cap.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places female forms in the female folder', () => {
|
||||||
|
expect(
|
||||||
|
resolveSpriteUrl({ pokedexNumber: 592, form: 'female', spriteKey: '592' }, false, true)
|
||||||
|
).toBe('/sprites-small/home/female/592.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the established fallback key for regional forms', () => {
|
||||||
|
expect(resolveSpriteUrl({ pokedexNumber: 83, form: 'Galarian' }, false, false)).toBe(
|
||||||
|
'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home/83-galar.webp'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the Pokédex number for the default male form', () => {
|
||||||
|
expect(resolveSpriteUrl({ pokedexNumber: 25, form: 'Male' }, false, true)).toBe(
|
||||||
|
'/sprites-small/home/25.webp'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes decorated form names and an all-zero number', () => {
|
||||||
|
expect(
|
||||||
|
resolveSpriteUrl({ pokedexNumber: 0, form: 'Form 2 [event] (legacy)' }, false, true)
|
||||||
|
).toBe('/sprites-small/home/0-form-two.webp');
|
||||||
|
});
|
||||||
|
});
|
||||||
+4
-17
@@ -45,27 +45,14 @@ export default defineConfig({
|
|||||||
background_color: '#f0f0f0'
|
background_color: '#f0f0f0'
|
||||||
},
|
},
|
||||||
injectManifest: {
|
injectManifest: {
|
||||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
||||||
globIgnores: ['**/sprites/**', '**/sprites-small/**']
|
globIgnores: ['**/sprites/**', '**/sprites-small/**']
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
globPatterns: ['client/**/*.{html,js,css,ico,png,svg,webp,woff,woff2,webmanifest}'],
|
||||||
globIgnores: ['**/sprites/**', '**/sprites-small/**'],
|
globIgnores: ['**/sprites/**', '**/sprites-small/**'],
|
||||||
runtimeCaching: [
|
// Shared message/fetch handling keeps generateSW and injectManifest behavior equal.
|
||||||
{
|
importScripts: ['/offline-worker.js']
|
||||||
urlPattern: ({ request }) => request.destination === 'image',
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'image-cache',
|
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 3000,
|
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
|
||||||
purgeOnQuotaError: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
allowOnly: false,
|
||||||
|
include: ['tests/build/**/*.test.ts']
|
||||||
|
}
|
||||||
|
});
|
||||||
+30
-5
@@ -1,7 +1,32 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
resolve: {
|
||||||
include: ['test/*.test.ts']
|
alias: {
|
||||||
}
|
$lib: fileURLToPath(new URL('./src/lib', import.meta.url)),
|
||||||
})
|
'$env/dynamic/private': fileURLToPath(new URL('./tests/support/envStub.ts', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
allowOnly: false,
|
||||||
|
include: ['tests/unit/**/*.test.ts', 'tests/data/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json-summary', 'html'],
|
||||||
|
reportsDirectory: 'coverage',
|
||||||
|
// The whole library surface is measured, so anything new and untested drags the
|
||||||
|
// numbers down instead of being invisible to the gate. Excluded here: type-only
|
||||||
|
// models, and the store/action modules that only run in a browser.
|
||||||
|
include: ['src/lib/**/*.ts'],
|
||||||
|
exclude: ['src/lib/models/**', 'src/lib/stores/**', 'src/lib/actions/**'],
|
||||||
|
// Set to the measured baseline. Ratchet these up as coverage grows; never down.
|
||||||
|
thresholds: {
|
||||||
|
statements: 34.58,
|
||||||
|
functions: 73.68,
|
||||||
|
lines: 34.58,
|
||||||
|
branches: 79.79
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
$lib: fileURLToPath(new URL('./src/lib', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
allowOnly: false,
|
||||||
|
include: ['tests/integration/**/*.test.ts'],
|
||||||
|
testTimeout: 30_000,
|
||||||
|
hookTimeout: 30_000,
|
||||||
|
sequence: { concurrent: false }
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user