From 7240376e008f6dc12e2bd07c1e5551d838f5090c Mon Sep 17 00:00:00 2001
From: Josh Creek <8179928+jcreek@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:25:09 +0100
Subject: [PATCH 1/6] feat(pokedex): add shareable read-only dex links
---
package-lock.json | 37 +---
package.json | 4 +-
.../pokedex/PokedexEntryCatchRecord.svelte | 138 +++++++------
.../pokedex/PokedexViewBoxes.svelte | 187 +++++++++---------
src/lib/models/Pokedex.ts | 2 +
src/lib/models/SharedPokedex.ts | 36 ++++
src/lib/repositories/PokedexRepository.ts | 1 +
src/lib/services/SharePreviewService.ts | 88 +++++++++
src/lib/services/SharedPokedexService.ts | 69 +++++++
src/routes/+layout.svelte | 7 -
src/routes/+page.svelte | 8 +
src/routes/pokedex/[id]/+page.svelte | 95 ++++++++-
src/routes/shared/[token]/+page.server.ts | 19 ++
src/routes/shared/[token]/+page.svelte | 103 ++++++++++
.../shared/[token]/preview.png/+server.ts | 18 ++
.../20260914000000_add_pokedex_sharing.sql | 73 +++++++
16 files changed, 693 insertions(+), 192 deletions(-)
create mode 100644 src/lib/models/SharedPokedex.ts
create mode 100644 src/lib/services/SharePreviewService.ts
create mode 100644 src/lib/services/SharedPokedexService.ts
create mode 100644 src/routes/shared/[token]/+page.server.ts
create mode 100644 src/routes/shared/[token]/+page.svelte
create mode 100644 src/routes/shared/[token]/preview.png/+server.ts
create mode 100644 supabase/migrations/20260914000000_add_pokedex_sharing.sql
diff --git a/package-lock.json b/package-lock.json
index ff78751..3568ad1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0",
- "nanoid": "^5.0.4"
+ "nanoid": "^5.0.4",
+ "sharp": "^0.33.4"
},
"devDependencies": {
"@playwright/test": "1.55.1",
@@ -36,7 +37,6 @@
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
- "sharp": "^0.33.4",
"supabase": "2.72.7",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
@@ -2118,7 +2118,6 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -2698,7 +2697,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2721,7 +2719,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2744,7 +2741,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2761,7 +2757,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2778,7 +2773,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2795,7 +2789,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2812,7 +2805,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2829,7 +2821,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2846,7 +2837,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2863,7 +2853,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2880,7 +2869,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2903,7 +2891,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2926,7 +2913,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2949,7 +2935,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2972,7 +2957,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2995,7 +2979,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3018,7 +3001,6 @@
"cpu": [
"wasm32"
],
- "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -3038,7 +3020,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3058,7 +3039,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -5315,7 +5295,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
- "dev": true,
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
@@ -5328,7 +5307,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -5339,14 +5317,12 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
- "dev": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
@@ -5742,7 +5718,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -7140,8 +7115,7 @@
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
- "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
- "dev": true
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"node_modules/is-bigint": {
"version": "1.0.4",
@@ -9484,7 +9458,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
- "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -9547,7 +9520,6 @@
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
- "dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -9703,7 +9675,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
- "dev": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
diff --git a/package.json b/package.json
index de48747..8e0f65c 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,6 @@
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
- "sharp": "^0.33.4",
"supabase": "2.72.7",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
@@ -81,7 +80,8 @@
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0",
- "nanoid": "^5.0.4"
+ "nanoid": "^5.0.4",
+ "sharp": "^0.33.4"
},
"engines": {
"node": ">=18.13.0"
diff --git a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
index ffc43d1..a991184 100644
--- a/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
+++ b/src/lib/components/pokedex/PokedexEntryCatchRecord.svelte
@@ -1,5 +1,6 @@
+
+
+ {shared.name} - Living Dex Tracker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{shared.name}
+ Read-only shared Pokédex
+
+
+ {#each typeBadges as badge}
+ {badge}
+ {/each}
+ {shared.gameScope || 'All Games'}
+
+ {#if shared.description}
{shared.description}
{/if}
+
+ {shared.caught} of {shared.total} Pokémon caught · {shared.completionPercentage}% complete
+
+
+
+
+
+
+
+
+{#if showModal && selectedPokemon}
+
+
+
+{/if}
diff --git a/src/routes/shared/[token]/preview.png/+server.ts b/src/routes/shared/[token]/preview.png/+server.ts
new file mode 100644
index 0000000..d3d17d9
--- /dev/null
+++ b/src/routes/shared/[token]/preview.png/+server.ts
@@ -0,0 +1,18 @@
+import { error } from '@sveltejs/kit';
+import { loadSharedPokedex } from '$lib/services/SharedPokedexService';
+import { renderSharePreview } from '$lib/services/SharePreviewService';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ locals, params }) => {
+ const shared = await loadSharedPokedex(locals.supabase, params.token);
+ if (!shared) throw error(404, 'Shared Pokédex not found');
+
+ const image = await renderSharePreview(shared);
+ return new Response(image, {
+ headers: {
+ 'Content-Type': 'image/png',
+ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
+ 'Content-Length': String(image.byteLength)
+ }
+ });
+};
diff --git a/supabase/migrations/20260914000000_add_pokedex_sharing.sql b/supabase/migrations/20260914000000_add_pokedex_sharing.sql
new file mode 100644
index 0000000..f850752
--- /dev/null
+++ b/supabase/migrations/20260914000000_add_pokedex_sharing.sql
@@ -0,0 +1,73 @@
+-- Stable capability links for read-only Pokédex sharing.
+ALTER TABLE pokedexes
+ ADD COLUMN "shareToken" UUID NOT NULL DEFAULT gen_random_uuid();
+
+ALTER TABLE pokedexes
+ ADD CONSTRAINT pokedexes_share_token_key UNIQUE ("shareToken");
+
+CREATE OR REPLACE FUNCTION prevent_pokedex_share_token_update()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = public, pg_temp
+AS $$
+BEGIN
+ IF NEW."shareToken" IS DISTINCT FROM OLD."shareToken" THEN
+ RAISE EXCEPTION 'shareToken is immutable';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER prevent_pokedex_share_token_update
+ BEFORE UPDATE OF "shareToken" ON pokedexes
+ FOR EACH ROW EXECUTE FUNCTION prevent_pokedex_share_token_update();
+
+-- This is the only anonymous path into user-owned Pokédex data. Keep the returned
+-- shape deliberately narrow: no owner identity, row IDs, timestamps, or personal notes.
+CREATE OR REPLACE FUNCTION get_shared_pokedex(p_share_token UUID)
+RETURNS JSONB
+SECURITY DEFINER
+STABLE
+SET search_path = public, pg_temp
+LANGUAGE sql
+AS $$
+ SELECT jsonb_build_object(
+ 'name', p.name,
+ 'description', COALESCE(p.description, ''),
+ 'isLivingDex', COALESCE(p."isLivingDex", false),
+ 'isShinyDex', COALESCE(p."isShinyDex", false),
+ 'isOriginDex', COALESCE(p."isOriginDex", false),
+ 'isFormDex', COALESCE(p."isFormDex", false),
+ 'gameScope', p."gameScope",
+ 'dexScopes', COALESCE(
+ (
+ SELECT jsonb_agg(pds."dexId" ORDER BY pds."dexId")
+ FROM pokedex_dex_scopes pds
+ WHERE pds."pokedexId" = p.id
+ ),
+ '[]'::jsonb
+ ),
+ 'catchStatuses', COALESCE(
+ (
+ SELECT jsonb_agg(
+ jsonb_build_object(
+ 'pokemonId', cr."pokemonId"::text,
+ 'caught', COALESCE(cr.caught, false),
+ 'haveToEvolve', COALESCE(cr."haveToEvolve", false),
+ 'inHome', COALESCE(cr."inHome", false),
+ 'hasGigantamaxed', COALESCE(cr."hasGigantamaxed", false)
+ )
+ ORDER BY cr."pokemonId"
+ )
+ FROM catch_records cr
+ WHERE cr."pokedexId" = p.id
+ ),
+ '[]'::jsonb
+ )
+ )
+ FROM pokedexes p
+ WHERE p."shareToken" = p_share_token;
+$$;
+
+REVOKE ALL ON FUNCTION get_shared_pokedex(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION get_shared_pokedex(UUID) TO anon, authenticated;
From 27274171fe054a791c070e0a196652eb8424eba4 Mon Sep 17 00:00:00 2001
From: Josh Creek <8179928+jcreek@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:25:22 +0100
Subject: [PATCH 2/6] test(pokedex): cover shared dex privacy
---
tests/bdd/features/pokedex-sharing.feature | 19 +++
tests/bdd/fixtures.ts | 4 +-
tests/bdd/steps/sharing.steps.ts | 65 +++++++++
tests/integration/sharing.integration.test.ts | 138 ++++++++++++++++++
tests/unit/sharePreviewService.test.ts | 52 +++++++
tests/unit/sharedPokedexService.test.ts | 34 +++++
6 files changed, 311 insertions(+), 1 deletion(-)
create mode 100644 tests/bdd/features/pokedex-sharing.feature
create mode 100644 tests/bdd/steps/sharing.steps.ts
create mode 100644 tests/integration/sharing.integration.test.ts
create mode 100644 tests/unit/sharePreviewService.test.ts
create mode 100644 tests/unit/sharedPokedexService.test.ts
diff --git a/tests/bdd/features/pokedex-sharing.feature b/tests/bdd/features/pokedex-sharing.feature
new file mode 100644
index 0000000..9b650bf
--- /dev/null
+++ b/tests/bdd/features/pokedex-sharing.feature
@@ -0,0 +1,19 @@
+Feature: Share a Pokédex
+ As a trainer
+ I want to share my progress without granting edit access
+ So that friends can follow my collection safely
+
+ Background:
+ Given I am signed in
+
+ Scenario: Share a live read-only Pokédex
+ Given I have a Living Dex named "Public Journey"
+ When I mark the first Pokémon as caught
+ And I add the note "share-secret-note" to the first Pokémon
+ When I open the Pokédex share dialog
+ Then I receive an unguessable read-only link
+ When I visit the shared link while signed out
+ Then I can browse the shared Pokédex without editing it
+ And the shared page does not expose the private note
+ And the shared page advertises a social progress image
+ And the social progress image is a PNG
diff --git a/tests/bdd/fixtures.ts b/tests/bdd/fixtures.ts
index 0c23723..dc647e7 100644
--- a/tests/bdd/fixtures.ts
+++ b/tests/bdd/fixtures.ts
@@ -12,6 +12,7 @@ export type ScenarioState = {
lastMessage: string | null;
caughtEntryLabel: string | null;
legacyServiceWorkerRequested: boolean;
+ shareUrl: string | null;
};
type Fixtures = { state: ScenarioState; providerMock: void };
@@ -53,7 +54,8 @@ export const test = base.extend({
lastResponseStatus: null,
lastMessage: null,
caughtEntryLabel: null,
- legacyServiceWorkerRequested: false
+ legacyServiceWorkerRequested: false,
+ shareUrl: null
});
}
});
diff --git a/tests/bdd/steps/sharing.steps.ts b/tests/bdd/steps/sharing.steps.ts
new file mode 100644
index 0000000..82ea817
--- /dev/null
+++ b/tests/bdd/steps/sharing.steps.ts
@@ -0,0 +1,65 @@
+import { createBdd } from 'playwright-bdd';
+import { test, expect } from '../fixtures';
+
+const { When, Then } = createBdd(test);
+
+When('I open the Pokédex share dialog', async ({ page, state }) => {
+ const detailsDialog = page.getByRole('dialog').filter({ hasText: 'Notes:' });
+ if (await detailsDialog.count()) {
+ await detailsDialog.getByRole('button', { name: 'Close', exact: true }).click();
+ }
+ await page.getByRole('button', { name: 'Share', exact: true }).click();
+ const dialog = page.getByRole('dialog', { name: /Share Public Journey/ });
+ await expect(dialog).toBeVisible();
+ state.shareUrl = await dialog.getByLabel('Read-only link').inputValue();
+});
+
+Then('I receive an unguessable read-only link', async ({ state }) => {
+ expect(state.shareUrl).toMatch(
+ /^https?:\/\/[^/]+\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
+ );
+});
+
+When('I visit the shared link while signed out', async ({ page, context, state }) => {
+ if (!state.shareUrl) throw new Error('A share URL is required');
+ await context.clearCookies();
+ await page.goto(state.shareUrl);
+});
+
+Then('I can browse the shared Pokédex without editing it', async ({ page }) => {
+ await expect(page.getByRole('heading', { name: 'Public Journey' })).toBeVisible();
+ await expect(page.getByText('Read-only shared Pokédex')).toBeVisible();
+ await expect(page.getByLabel('Choose box view layout density')).toBeVisible();
+ await expect(page.getByText('Filters:', { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Open bulk actions menu' })).toHaveCount(0);
+ await expect(page.getByRole('button', { name: /Create Pokédex data/ })).toHaveCount(0);
+ await expect(page.getByText('Personal notes')).toHaveCount(0);
+});
+
+Then('the shared page does not expose the private note', async ({ page }) => {
+ await expect(page.getByText('share-secret-note')).toHaveCount(0);
+});
+
+Then('the shared page advertises a social progress image', async ({ page }) => {
+ const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
+ const image = await page.locator('meta[property="og:image"]').getAttribute('content');
+ expect(canonical).toBe(page.url());
+ expect(image).toBe(`${page.url()}/preview.png`);
+ await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute(
+ 'content',
+ 'summary_large_image'
+ );
+ await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex, nofollow');
+ await expect(page.locator('meta[name="referrer"]')).toHaveAttribute('content', 'no-referrer');
+});
+
+Then('the social progress image is a PNG', async ({ page }) => {
+ const image = await page.locator('meta[property="og:image"]').getAttribute('content');
+ if (!image) throw new Error('Open Graph image URL is required');
+ const response = await page.request.get(image);
+ expect(response.status()).toBe(200);
+ expect(response.headers()['content-type']).toBe('image/png');
+ expect(response.headers()['cache-control']).toContain('max-age=300');
+ const body = await response.body();
+ expect([...body.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
+});
diff --git a/tests/integration/sharing.integration.test.ts b/tests/integration/sharing.integration.test.ts
new file mode 100644
index 0000000..5afd914
--- /dev/null
+++ b/tests/integration/sharing.integration.test.ts
@@ -0,0 +1,138 @@
+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('read-only Pokédex sharing', () => {
+ 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('generates a stable token and exposes only sanitized data through the RPC', async () => {
+ const admin = createClient(url, serviceKey!);
+ const email = `integration-sharing-${Date.now()}@example.test`;
+ const password = 'Integration123!';
+ const created = await admin.auth.admin.createUser({ email, password, email_confirm: true });
+ expect(created.error).toBeNull();
+ const userId = created.data.user!.id;
+ createdUserIds.push(userId);
+
+ const { data: dex, error: dexError } = await admin
+ .from('pokedexes')
+ .insert({
+ userId,
+ name: 'Shared & Safe',
+ description: 'Public description',
+ isLivingDex: true
+ })
+ .select('id, shareToken')
+ .single();
+ expect(dexError).toBeNull();
+ expect(dex!.shareToken).toMatch(/^[0-9a-f-]{36}$/i);
+
+ const { data: pokemon } = await admin
+ .from('pokemon')
+ .select('id')
+ .order('id')
+ .limit(1)
+ .single();
+ expect(
+ (
+ await admin.from('catch_records').insert({
+ userId,
+ pokedexId: dex!.id,
+ pokemonId: pokemon!.id,
+ caught: true,
+ inHome: true,
+ personalNotes: 'This must remain private'
+ })
+ ).error
+ ).toBeNull();
+
+ const anonymous = createClient(url, anonKey!);
+ const direct = await anonymous.from('pokedexes').select('*').eq('id', dex!.id);
+ expect(direct.error).toBeNull();
+ expect(direct.data).toEqual([]);
+ const directCatchRecords = await anonymous
+ .from('catch_records')
+ .select('*')
+ .eq('pokedexId', dex!.id);
+ expect(directCatchRecords.error).toBeNull();
+ expect(directCatchRecords.data).toEqual([]);
+
+ const result = await anonymous.rpc('get_shared_pokedex', {
+ p_share_token: dex!.shareToken
+ });
+ expect(result.error).toBeNull();
+ expect(result.data).toMatchObject({
+ name: 'Shared & Safe',
+ description: 'Public description',
+ catchStatuses: [
+ {
+ pokemonId: String(pokemon!.id),
+ caught: true,
+ inHome: true
+ }
+ ]
+ });
+ const serialized = JSON.stringify(result.data);
+ expect(serialized).not.toContain(userId);
+ expect(serialized).not.toContain(dex!.shareToken);
+ expect(serialized).not.toContain('This must remain private');
+ expect(serialized).not.toContain('personalNotes');
+
+ const attemptedWrite = await anonymous
+ .from('catch_records')
+ .update({ caught: false })
+ .eq('pokedexId', dex!.id)
+ .eq('pokemonId', pokemon!.id)
+ .select();
+ expect(attemptedWrite.error).toBeNull();
+ expect(attemptedWrite.data).toEqual([]);
+ const unchanged = await admin
+ .from('catch_records')
+ .select('caught')
+ .eq('pokedexId', dex!.id)
+ .eq('pokemonId', pokemon!.id)
+ .single();
+ expect(unchanged.data?.caught).toBe(true);
+
+ const owner = createClient(url, anonKey!);
+ expect((await owner.auth.signInWithPassword({ email, password })).error).toBeNull();
+ const ownerDex = await owner.from('pokedexes').select('shareToken').eq('id', dex!.id).single();
+ expect(ownerDex.data?.shareToken).toBe(dex!.shareToken);
+ expect(
+ (await owner.from('pokedexes').update({ shareToken: crypto.randomUUID() }).eq('id', dex!.id))
+ .error
+ ).not.toBeNull();
+
+ const missing = await anonymous.rpc('get_shared_pokedex', {
+ p_share_token: crypto.randomUUID()
+ });
+ expect(missing.error).toBeNull();
+ expect(missing.data).toBeNull();
+
+ expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull();
+ const deleted = await anonymous.rpc('get_shared_pokedex', {
+ p_share_token: dex!.shareToken
+ });
+ expect(deleted.error).toBeNull();
+ expect(deleted.data).toBeNull();
+ });
+});
diff --git a/tests/unit/sharePreviewService.test.ts b/tests/unit/sharePreviewService.test.ts
new file mode 100644
index 0000000..09d1530
--- /dev/null
+++ b/tests/unit/sharePreviewService.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import sharp from 'sharp';
+import {
+ buildSharePreviewSvg,
+ escapeXml,
+ renderSharePreview,
+ SHARE_PREVIEW_HEIGHT,
+ SHARE_PREVIEW_WIDTH,
+ truncatePreviewText
+} from '$lib/services/SharePreviewService';
+import type { SharedPokedexData } from '$lib/models/SharedPokedex';
+
+const shared: SharedPokedexData = {
+ name: 'Johto & ',
+ description: 'A shared collection',
+ isLivingDex: true,
+ isShinyDex: false,
+ isOriginDex: false,
+ isFormDex: false,
+ gameScope: null,
+ dexScopes: [],
+ combinedData: [],
+ total: 100,
+ caught: 42,
+ completionPercentage: 42
+};
+
+describe('share preview rendering', () => {
+ it('escapes XML and truncates normalized user text', () => {
+ expect(escapeXml(`Tom & Jerry's`)).toBe(
+ '<tag attr="x">Tom & Jerry's</tag>'
+ );
+ expect(truncatePreviewText(' lots of\nspace ', 20)).toBe('lots of space');
+ expect(truncatePreviewText('abcdefghij', 6)).toBe('abcde…');
+ });
+
+ it('builds a branded progress card without raw user markup', () => {
+ const svg = buildSharePreviewSvg(shared);
+ expect(svg).toContain('Johto & <Friends>');
+ expect(svg).not.toContain('Johto & ');
+ expect(svg).toContain('42 of 100 Pokémon caught');
+ expect(svg).toContain('42%');
+ });
+
+ it('renders a valid 1200 by 630 PNG', async () => {
+ const png = await renderSharePreview(shared);
+ const metadata = await sharp(png).metadata();
+ expect(metadata.format).toBe('png');
+ expect(metadata.width).toBe(SHARE_PREVIEW_WIDTH);
+ expect(metadata.height).toBe(SHARE_PREVIEW_HEIGHT);
+ });
+});
diff --git a/tests/unit/sharedPokedexService.test.ts b/tests/unit/sharedPokedexService.test.ts
new file mode 100644
index 0000000..24c4097
--- /dev/null
+++ b/tests/unit/sharedPokedexService.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { calculateSharedProgress, isShareToken } from '$lib/services/SharedPokedexService';
+
+describe('shared Pokédex helpers', () => {
+ it('accepts UUID capability tokens and rejects malformed route values', () => {
+ expect(isShareToken('123e4567-e89b-42d3-a456-426614174000')).toBe(true);
+ expect(isShareToken('not-a-token')).toBe(false);
+ expect(isShareToken('123e4567-e89b-12d3-a456-426614174000/extra')).toBe(false);
+ });
+
+ it('counts caught and needs-to-evolve entries as progress', () => {
+ const progress = calculateSharedProgress(
+ [
+ {
+ pokemonId: '1',
+ caught: true,
+ haveToEvolve: false,
+ inHome: false,
+ hasGigantamaxed: false
+ },
+ {
+ pokemonId: '2',
+ caught: false,
+ haveToEvolve: true,
+ inHome: false,
+ hasGigantamaxed: false
+ }
+ ],
+ 4
+ );
+ expect(progress).toEqual({ caught: 2, completionPercentage: 50 });
+ expect(calculateSharedProgress([], 0)).toEqual({ caught: 0, completionPercentage: 0 });
+ });
+});
From ca7f9c0e485ceb40804d887d130340e03c47db79 Mon Sep 17 00:00:00 2001
From: Josh Creek <8179928+jcreek@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:47:27 +0100
Subject: [PATCH 3/6] test(pokedex): cover shared dex loading and preview
badges
Adds loadSharedPokedex unit tests and a share preview case for every badge, restoring the branch coverage gate. Gives the offline sync BDD poll 30s so a full Living Dex snapshot can finish caching on CI.
---
tests/bdd/steps/pwa.steps.ts | 21 +++---
tests/unit/sharePreviewService.test.ts | 16 +++++
tests/unit/sharedPokedexService.test.ts | 96 ++++++++++++++++++++++++-
3 files changed, 122 insertions(+), 11 deletions(-)
diff --git a/tests/bdd/steps/pwa.steps.ts b/tests/bdd/steps/pwa.steps.ts
index e38cafe..4ec42f6 100644
--- a/tests/bdd/steps/pwa.steps.ts
+++ b/tests/bdd/steps/pwa.steps.ts
@@ -67,16 +67,19 @@ When('I go offline and reload the sign-in page', async ({ page }) => {
Given('my offline copy is synchronized', async ({ page, state }) => {
await waitForServiceWorker(page);
+ // A full Living Dex snapshot plus its artwork can take longer than the default poll window on CI.
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)
+ .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),
+ { timeout: 30_000 }
)
.toBe(true);
const serializedSnapshot = await page.evaluate(async () => {
diff --git a/tests/unit/sharePreviewService.test.ts b/tests/unit/sharePreviewService.test.ts
index 09d1530..001beb8 100644
--- a/tests/unit/sharePreviewService.test.ts
+++ b/tests/unit/sharePreviewService.test.ts
@@ -42,6 +42,22 @@ describe('share preview rendering', () => {
expect(svg).toContain('42%');
});
+ it('lists every enabled dex badge and omits an empty description', () => {
+ const svg = buildSharePreviewSvg({
+ ...shared,
+ description: '',
+ isShinyDex: true,
+ isOriginDex: true,
+ isFormDex: true,
+ gameScope: 'Scarlet'
+ });
+ for (const badge of ['Living', 'Shiny', 'Origin', 'Form', 'Scarlet']) {
+ expect(svg).toContain(`class="badge">${badge}`);
+ }
+ expect(svg).not.toContain('All Games');
+ expect(svg).not.toContain('class="description"');
+ });
+
it('renders a valid 1200 by 630 PNG', async () => {
const png = await renderSharePreview(shared);
const metadata = await sharp(png).metadata();
diff --git a/tests/unit/sharedPokedexService.test.ts b/tests/unit/sharedPokedexService.test.ts
index 24c4097..5b5cc49 100644
--- a/tests/unit/sharedPokedexService.test.ts
+++ b/tests/unit/sharedPokedexService.test.ts
@@ -1,5 +1,25 @@
-import { describe, expect, it } from 'vitest';
-import { calculateSharedProgress, isShareToken } from '$lib/services/SharedPokedexService';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { SupabaseClient } from '@supabase/supabase-js';
+import type { PokedexEntry } from '$lib/models/PokedexEntry';
+import type { SharedPokedexRpcData } from '$lib/models/SharedPokedex';
+
+const { findAllCombinedData } = vi.hoisted(() => ({ findAllCombinedData: vi.fn() }));
+vi.mock('$lib/repositories/CombinedDataRepository', () => ({
+ default: vi.fn().mockImplementation(() => ({ findAllCombinedData }))
+}));
+
+import {
+ calculateSharedProgress,
+ isShareToken,
+ loadSharedPokedex
+} from '$lib/services/SharedPokedexService';
+
+const TOKEN = '123e4567-e89b-42d3-a456-426614174000';
+
+function supabaseWithRpc(result: { data: unknown; error: unknown }) {
+ const rpc = vi.fn().mockResolvedValue(result);
+ return { supabase: { rpc } as unknown as SupabaseClient, rpc };
+}
describe('shared Pokédex helpers', () => {
it('accepts UUID capability tokens and rejects malformed route values', () => {
@@ -32,3 +52,75 @@ describe('shared Pokédex helpers', () => {
expect(calculateSharedProgress([], 0)).toEqual({ caught: 0, completionPercentage: 0 });
});
});
+
+describe('loadSharedPokedex', () => {
+ beforeEach(() => {
+ findAllCombinedData.mockReset();
+ });
+
+ it('rejects malformed tokens without querying', async () => {
+ const { supabase, rpc } = supabaseWithRpc({ data: null, error: null });
+ expect(await loadSharedPokedex(supabase, 'not-a-token')).toBeNull();
+ expect(rpc).not.toHaveBeenCalled();
+ });
+
+ it('returns null when the token matches nothing or the RPC fails', async () => {
+ expect(
+ await loadSharedPokedex(supabaseWithRpc({ data: null, error: null }).supabase, TOKEN)
+ ).toBeNull();
+ expect(
+ await loadSharedPokedex(
+ supabaseWithRpc({ data: null, error: { message: 'boom' } }).supabase,
+ TOKEN
+ )
+ ).toBeNull();
+ expect(findAllCombinedData).not.toHaveBeenCalled();
+ });
+
+ it('maps shared catch statuses onto the scoped dex entries', async () => {
+ const rpcData: SharedPokedexRpcData = {
+ name: 'Kanto',
+ description: 'Gen 1',
+ isLivingDex: true,
+ isShinyDex: false,
+ isOriginDex: false,
+ isFormDex: true,
+ gameScope: null,
+ dexScopes: ['kanto'],
+ catchStatuses: [
+ { pokemonId: '1', caught: true, haveToEvolve: false, inHome: true, hasGigantamaxed: false },
+ {
+ pokemonId: '99',
+ caught: true,
+ haveToEvolve: false,
+ inHome: false,
+ hasGigantamaxed: false
+ }
+ ]
+ };
+ const entries = ['1', '2', '3', '4'].map((id) => ({
+ pokedexEntry: { _id: id } as PokedexEntry,
+ catchRecord: null
+ }));
+ findAllCombinedData.mockResolvedValue(entries);
+ const { supabase, rpc } = supabaseWithRpc({ data: rpcData, error: null });
+
+ const shared = await loadSharedPokedex(supabase, TOKEN);
+
+ expect(rpc).toHaveBeenCalledWith('get_shared_pokedex', { p_share_token: TOKEN });
+ expect(findAllCombinedData).toHaveBeenCalledWith('', true, '', '', ['kanto']);
+ expect(shared?.combinedData.map((entry) => entry.catchRecord?.pokemonId ?? null)).toEqual([
+ '1',
+ null,
+ null,
+ null
+ ]);
+ // Statuses for Pokémon outside the dex scope must not inflate progress.
+ expect(shared).toMatchObject({
+ name: 'Kanto',
+ total: 4,
+ caught: 1,
+ completionPercentage: 25
+ });
+ });
+});
From f2d8451c347f591282ac875a914d252de7cb7383 Mon Sep 17 00:00:00 2001
From: Josh Creek <8179928+jcreek@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:47:27 +0100
Subject: [PATCH 4/6] chore(deps): match Node engine range to sharp
sharp is now a runtime dependency and requires ^18.17.0 || ^20.3.0 || >=21.0.0.
---
package-lock.json | 2 +-
package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 3568ad1..7d733c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,7 +46,7 @@
"vitest": "^1.0.4"
},
"engines": {
- "node": ">=18.13.0"
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
diff --git a/package.json b/package.json
index 8e0f65c..850b3d9 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,6 @@
"sharp": "^0.33.4"
},
"engines": {
- "node": ">=18.13.0"
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
}
}
From c3a3d438836bbc17545895beb48c34f047888ae5 Mon Sep 17 00:00:00 2001
From: Josh Creek <8179928+jcreek@users.noreply.github.com>
Date: Mon, 14 Sep 2026 15:22:05 +0100
Subject: [PATCH 5/6] fix(offline): cache sprite artwork once instead of on
every sync
Each sync created a new artwork cache and re-fetched every sprite with no-cors. Opaque responses are padded to several MB each for storage quota, so a Living Dex grew to tens of GB, syncs never finished, and the offline copy could fail to save.
Artwork now lives in one cache per user that is topped up with only missing sprites, fetched with CORS so they count at their real size, with a per-fetch timeout. The offline copy is committed before any artwork, legacy per-sync artwork caches are removed first, and sprites shown online are cached on first load. Also requests persistent storage.
---
src/lib/stores/offlineSync.ts | 2 +
static/offline-worker.js | 113 ++++++++++++++++++++++------------
2 files changed, 76 insertions(+), 39 deletions(-)
diff --git a/src/lib/stores/offlineSync.ts b/src/lib/stores/offlineSync.ts
index 5a85d38..bfacbfa 100644
--- a/src/lib/stores/offlineSync.ts
+++ b/src/lib/stores/offlineSync.ts
@@ -158,6 +158,8 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
}, 1_000);
};
+ // Best effort: ask the browser not to evict the offline artwork cache under storage pressure.
+ void navigator.storage?.persist?.().catch(() => undefined);
window.addEventListener(SYNC_EVENT, schedule);
window.addEventListener('online', schedule);
schedule();
diff --git a/static/offline-worker.js b/static/offline-worker.js
index 90c1941..6619a3c 100644
--- a/static/offline-worker.js
+++ b/static/offline-worker.js
@@ -1,6 +1,7 @@
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
const OFFLINE_META_URL = '/__offline/current';
+const ARTWORK_FETCH_TIMEOUT_MS = 15_000;
let offlineEpoch = 0;
let offlineOperation = Promise.resolve();
let claimedUserId = null;
@@ -15,8 +16,15 @@ function dataCacheName(userId, generation) {
return `${OFFLINE_CACHE_PREFIX}data-v1-${userId}-${generation}`;
}
-function artworkCacheName(userId, generation) {
- return `${OFFLINE_CACHE_PREFIX}art-v1-${userId}-${generation}`;
+// Sprites never change at a given URL, so each user keeps one artwork cache that is topped up
+// incrementally instead of being rebuilt on every sync.
+function artworkCacheName(userId) {
+ return `${OFFLINE_CACHE_PREFIX}art-v2-${userId}`;
+}
+
+// Covers both the local `/sprites-small/...` folder and the GitHub-hosted copy of it.
+function isSpriteUrl(url) {
+ return /\/sprites(-small)?\//.test(url.pathname) && url.pathname.endsWith('.webp');
}
async function currentOfflineMeta() {
@@ -38,22 +46,39 @@ async function notifyOfflineDataCleared() {
for (const client of windows) client.postMessage({ type: 'OFFLINE_DATA_CLEARED' });
}
-async function cacheArtwork(cache, urls) {
+async function cacheArtwork(cache, urls, isCurrent) {
+ const wanted = new Set(urls.map((url) => new URL(url, self.location.origin).href));
+ const cachedRequests = await cache.keys();
+ const cached = new Set(cachedRequests.map((request) => request.url));
+ await Promise.all(
+ cachedRequests
+ .filter((request) => !wanted.has(request.url))
+ .map((request) => cache.delete(request))
+ );
+ const missing = [...wanted].filter((url) => !cached.has(url));
+
let next = 0;
let failed = 0;
- const workers = Array.from({ length: Math.min(6, urls.length) }, async () => {
+ const workers = Array.from({ length: Math.min(6, missing.length) }, async () => {
for (;;) {
const index = next++;
- if (index >= urls.length) return;
- const url = urls[index];
+ if (index >= missing.length || !isCurrent()) return;
+ const url = missing[index];
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), ARTWORK_FETCH_TIMEOUT_MS);
try {
+ // CORS rather than no-cors: opaque responses are padded to several MB each for storage
+ // quota, which a full Living Dex of artwork would exhaust.
const response = await fetch(url, {
- mode: url.startsWith(self.location.origin) ? 'same-origin' : 'no-cors'
+ mode: url.startsWith(self.location.origin) ? 'same-origin' : 'cors',
+ signal: controller.signal
});
- if (!response.ok && response.type !== 'opaque') throw new Error(`HTTP ${response.status}`);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
await cache.put(url, response);
} catch {
failed++;
+ } finally {
+ clearTimeout(timeout);
}
}
});
@@ -98,11 +123,12 @@ self.addEventListener('message', (event) => {
if (event.data?.type !== 'SYNC_OFFLINE_SNAPSHOT') return;
const syncEpoch = offlineEpoch;
const syncUserId = claimedUserId;
+ const isCurrent = () => syncEpoch === offlineEpoch;
event.waitUntil(
queueOfflineOperation(async () => {
let nextData;
- let nextArtwork;
+ let committed = false;
try {
const snapshot = event.data.snapshot;
if (!snapshot || snapshot.version !== 1 || typeof snapshot.userId !== 'string') {
@@ -114,6 +140,13 @@ self.addEventListener('message', (event) => {
const previousMeta = await currentOfflineMeta();
if (previousMeta?.userId && previousMeta.userId !== snapshot.userId)
await clearOfflineData();
+ // Per-sync artwork caches of opaque responses could fill the whole storage quota, so drop
+ // them before writing anything new.
+ await Promise.all(
+ (await caches.keys())
+ .filter((name) => name.startsWith(`${OFFLINE_CACHE_PREFIX}art-v1-`))
+ .map((name) => caches.delete(name))
+ );
const timestamp = String(snapshot.generatedAt).replace(/[^0-9]/g, '');
const generation = `${timestamp}-${crypto.randomUUID()}`;
@@ -125,18 +158,12 @@ self.addEventListener('message', (event) => {
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)]);
+ if (!isCurrent())
throw new Error('Offline synchronization was superseded by an account change');
- }
+ // Commit the collection before any artwork so a slow or failing sprite download can never
+ // prevent the offline copy from being saved.
+ const artworkCache = artworkCacheName(snapshot.userId);
const metaCache = await caches.open(OFFLINE_META_CACHE);
await metaCache.put(
OFFLINE_META_URL,
@@ -145,40 +172,41 @@ self.addEventListener('message', (event) => {
userId: snapshot.userId,
generatedAt: snapshot.generatedAt,
dataCache: nextData,
- artworkCache: nextArtwork
+ artworkCache
}),
{
headers: { 'Content-Type': 'application/json' }
}
)
);
- if (syncEpoch !== offlineEpoch) {
+ if (!isCurrent()) {
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);
- }
+ committed = true;
+
const currentCaches = await caches.keys();
await Promise.all(
currentCaches
.filter(
(name) =>
name.startsWith(OFFLINE_CACHE_PREFIX) &&
- ![OFFLINE_META_CACHE, nextData, nextArtwork].includes(name)
+ ![OFFLINE_META_CACHE, nextData, artworkCache].includes(name)
)
.map((name) => caches.delete(name))
);
+
+ const failedArtwork = await cacheArtwork(
+ await caches.open(artworkCache),
+ Array.from(new Set(event.data.artworkUrls ?? [])),
+ isCurrent
+ );
+ if (!isCurrent())
+ throw new Error('Offline synchronization was superseded by an account change');
reply({ ok: true, failedArtwork });
} catch (error) {
- await Promise.allSettled(
- [nextData, nextArtwork].filter(Boolean).map((name) => caches.delete(name))
- );
+ if (!committed && nextData) await caches.delete(nextData).catch(() => undefined);
reply({ ok: false, error: error instanceof Error ? error.message : String(error) });
}
})
@@ -203,15 +231,22 @@ self.addEventListener('fetch', (event) => {
event.respondWith(
(async () => {
const meta = await currentOfflineMeta();
- if (meta?.artworkCache) {
- const cached = await (
- await caches.open(meta.artworkCache)
- ).match(event.request, {
- ignoreSearch: true
+ if (!meta?.artworkCache) return fetch(event.request);
+ const cache = await caches.open(meta.artworkCache);
+ const cached = await cache.match(event.request, { ignoreSearch: true });
+ if (cached) return cached;
+ if (!isSpriteUrl(url)) return fetch(event.request);
+ // Cache-first with fill-on-miss: a sprite shown online is stored once and served from the
+ // cache from then on, so the bulk sync never has to download it again.
+ try {
+ const response = await fetch(url.href, {
+ mode: url.origin === self.location.origin ? 'same-origin' : 'cors'
});
- if (cached) return cached;
+ if (response.ok) event.waitUntil(cache.put(url.href, response.clone()));
+ return response;
+ } catch {
+ return fetch(event.request);
}
- return fetch(event.request);
})()
);
});
From 3f8daea8d29a91e84f5d76709ff2cd499b3cf84d Mon Sep 17 00:00:00 2001
From: Josh Creek <8179928+jcreek@users.noreply.github.com>
Date: Mon, 14 Sep 2026 15:22:05 +0100
Subject: [PATCH 6/6] ci: serve local sprites in the bdd job
Stops each BDD run downloading every Living Dex sprite from GitHub.
---
.github/workflows/tests.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 36f50c2..32bb592 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -83,6 +83,10 @@ jobs:
bdd:
runs-on: ubuntu-latest
timeout-minutes: 30
+ # Serve the committed sprites so offline sync doesn't download every Living Dex sprite from
+ # GitHub on each run.
+ env:
+ PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER: 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5