diff --git a/apps/web/package.json b/apps/web/package.json index 5b5a910..fb31b00 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,5 +35,6 @@ "jsdom": "^25.0.0", "@testing-library/svelte": "^5.0.0", "@testing-library/jest-dom": "^6.4.2" + ,"@types/node": "^20.16.11" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 55c2617..beb9c07 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,4 +1,10 @@ import { defineConfig, devices } from '@playwright/test'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +// ESM-compatible __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); export default defineConfig({ testDir: './tests/e2e', @@ -11,7 +17,8 @@ export default defineConfig({ trace: 'on-first-retry' }, webServer: { - command: 'pnpm preview', + // Build then preview to ensure static output exists + command: 'pnpm build && pnpm preview', cwd: __dirname, port: 4173, reuseExistingServer: !process.env.CI diff --git a/apps/web/tests/e2e/smoke.export-import.spec.ts b/apps/web/tests/e2e/smoke.export-import.spec.ts new file mode 100644 index 0000000..59e8a37 --- /dev/null +++ b/apps/web/tests/e2e/smoke.export-import.spec.ts @@ -0,0 +1,179 @@ +import { test, expect } from '@playwright/test'; + +// Helper to capture a coarse, implementation-agnostic grid fingerprint +// We use data attributes if present; otherwise fall back to textContent/HTML +async function captureGridFingerprint(page: import('@playwright/test').Page) { + const tiles = page.locator('[data-testid="day-tile"]'); + const count = await tiles.count(); + const max = Math.min(count, 60); // limit to first ~2 months worth to keep payload small + const data: Array> = []; + for (let i = 0; i < max; i++) { + const t = tiles.nth(i); + const handle = await t.elementHandle(); + if (!handle) continue; + const entry = await page.evaluate((el) => { + const attr = (name: string) => el.getAttribute(name); + const selCount = (sel: string) => el.querySelectorAll(sel).length; + return { + idx: (el as HTMLElement).dataset['index'] ?? String(i), + date: attr('data-date') ?? null, + net: attr('data-net-score') ?? null, + hue: attr('data-mood-hue') ?? null, + posGlyphs: selCount('[data-testid="positive-glyphs"] [data-testid="tick"]'), + negGlyphs: selCount('[data-testid="negative-glyphs"] [data-testid="dot"]'), + aria: el.getAttribute('aria-label'), + }; + }, handle); + data.push(entry); + } + return data; +} + +test.describe('Export/Import JSON roundtrip', () => { + test('creates days, exports JSON, clears DB, imports JSON, grid identical', async ({ page, context, browserName }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText(/GlowTrack/i); + + // Ensure at least one tile is present + const firstTile = page.locator('[data-testid="day-tile"]').first(); + await expect(firstTile).toBeVisible(); + + // Step 1: Create a couple of day entries to have non-empty state + // Day 1: +Exercise, mood hue ~ 120 + await firstTile.click(); + const hueInput = page.locator('[data-testid="mood-hue-slider"]'); + if (await hueInput.isVisible()) { + await hueInput.fill('120'); + } + const addPos = page.locator('[data-testid="add-positive-habit"]'); + if (await addPos.isVisible()) { + await addPos.click(); + const habitInput = page.locator('[data-testid="habit-input"]'); + if (await habitInput.isVisible()) { + await habitInput.fill('Exercise'); + await page.keyboard.press('Enter'); + } + } + const save = page.locator('[data-testid="save-day"]'); + const close = page.locator('[data-testid="close-editor"]'); + if (await save.isVisible()) { + await save.click(); + } else if (await close.isVisible()) { + await close.click(); + } else { + await page.click('body'); + } + + // Day 2: -Procrastination + const secondTile = page.locator('[data-testid="day-tile"]').nth(1); + if (await secondTile.isVisible()) { + await secondTile.click(); + const addNeg = page.locator('[data-testid="add-negative-habit"]'); + if (await addNeg.isVisible()) { + await addNeg.click(); + const habitInput = page.locator('[data-testid="habit-input"]'); + if (await habitInput.isVisible()) { + await habitInput.fill('Procrastination'); + await page.keyboard.press('Enter'); + } + } + if (await save.isVisible()) { + await save.click(); + } else if (await close.isVisible()) { + await close.click(); + } else { + await page.click('body'); + } + } + + // Capture fingerprint BEFORE export + const before = await captureGridFingerprint(page); + expect(before.length).toBeGreaterThan(0); + + // Step 2: Export JSON + const exportBtn = page.locator('[data-testid="export-json"], button:has-text("Export JSON"), [aria-label="Export JSON"]'); + await expect(exportBtn).toBeVisible(); + const downloadPromise = page.waitForEvent('download'); + await exportBtn.click(); + const download = await downloadPromise; + const suggested = download.suggestedFilename(); + const filePath = await download.path(); + expect(filePath).toBeTruthy(); + // We don't parse here to avoid Node type deps; presence of a file is enough. + + // Step 3: Clear IndexedDB and any cached state, then reload + await page.evaluate(async () => { + try { + // Best-effort clear for known DB name; ignore errors + const deleteDb = (name: string) => new Promise((res) => { const req = indexedDB.deleteDatabase(name); req.onsuccess = () => res(); req.onerror = () => res(); req.onblocked = () => res(); }); + try { await deleteDb('glowtrack'); } catch {} + // Attempt to enumerate all DBs if supported + // @ts-ignore - databases() is not in older TS DOM libs + const dbs = (await indexedDB.databases?.()) || []; + for (const db of dbs) { + if (db && db.name) { + try { await deleteDb(db.name); } catch {} + } + } + } catch {} + try { localStorage.clear(); } catch {} + try { sessionStorage.clear(); } catch {} + // Clear any caches (PWA) + try { + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + } catch {} + }); + await page.reload(); + await expect(page.getByRole('heading', { level: 1 })).toHaveText(/GlowTrack/i); + + // Expect state to be different after clearing (very likely empty/default) + const afterClear = await captureGridFingerprint(page); + // If app shows an empty grid with same number of tiles and no attributes, + // at least one of the first two tiles should differ by net/hue/glyphs + let differs = false; + const minLen = Math.min(before.length, afterClear.length); + for (let i = 0; i < Math.min(minLen, 2); i++) { + const a = before[i]; + const b = afterClear[i]; + if (a.net !== b.net || a.hue !== b.hue || a.posGlyphs !== b.posGlyphs || a.negGlyphs !== b.negGlyphs) { + differs = true; + break; + } + } + expect(differs).toBeTruthy(); + + // Step 4: Import the previously exported JSON + const importBtn = page.locator('[data-testid="import-json"], button:has-text("Import JSON"), [aria-label="Import JSON"]'); + await expect(importBtn).toBeVisible(); + + // Prefer setting a hidden file input directly if present + const input = page.locator('input[type="file"][accept*="json"], input[type="file"][data-testid="import-file-input"]'); + if (await input.count()) { + await input.first().setInputFiles(filePath!); + } else { + const chooserPromise = page.waitForEvent('filechooser'); + await importBtn.click(); + const chooser = await chooserPromise; + await chooser.setFiles(filePath!); + } + + // Give the app a moment to process the import and update UI + await page.waitForTimeout(250); + + // Step 5: Verify the grid fingerprint matches the one before export + const afterImport = await captureGridFingerprint(page); + + // Compare shallowly for first N records + const n = Math.min(before.length, afterImport.length, 30); + for (let i = 0; i < n; i++) { + const a = before[i]; + const b = afterImport[i]; + expect(b.net).toBe(a.net); + expect(b.hue).toBe(a.hue); + expect(b.posGlyphs).toBe(a.posGlyphs); + expect(b.negGlyphs).toBe(a.negGlyphs); + // aria and date are optional comparisons + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53a5929..50a9f36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,13 +35,13 @@ importers: version: 1.55.0 '@sveltejs/adapter-static': specifier: ^3.0.0 - version: 3.0.9(@sveltejs/kit@2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20))(svelte@4.2.20)(vite@5.4.20)) + version: 3.0.9(@sveltejs/kit@2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17))) '@sveltejs/kit': specifier: ^2.5.0 - version: 2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20))(svelte@4.2.20)(vite@5.4.20) + version: 2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)) '@sveltejs/vite-plugin-svelte': specifier: ^3.0.2 - version: 3.1.2(svelte@4.2.20)(vite@5.4.20) + version: 3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)) '@tailwindcss/forms': specifier: ^0.5.9 version: 0.5.10(tailwindcss@3.4.17) @@ -50,7 +50,10 @@ importers: version: 6.8.0 '@testing-library/svelte': specifier: ^5.0.0 - version: 5.2.8(svelte@4.2.20)(vite@5.4.20)(vitest@2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1)) + version: 5.2.8(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17))(vitest@2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1)) + '@types/node': + specifier: ^20.16.11 + version: 20.19.17 autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.6) @@ -71,10 +74,10 @@ importers: version: 5.9.2 vite: specifier: ^5.1.0 - version: 5.4.20 + version: 5.4.20(@types/node@20.19.17) vitest: specifier: ^2.1.1 - version: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1) + version: 2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1) packages/storage: dependencies: @@ -90,7 +93,7 @@ importers: version: 5.9.2 vitest: specifier: ^2.1.1 - version: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1) + version: 2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1) packages/viz: devDependencies: @@ -105,7 +108,7 @@ importers: version: 5.9.2 vitest: specifier: ^2.1.1 - version: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1) + version: 2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1) packages: @@ -554,6 +557,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@20.19.17': + resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==} + '@types/pug@2.0.10': resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} @@ -1743,6 +1749,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -2163,15 +2172,15 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/adapter-static@3.0.9(@sveltejs/kit@2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20))(svelte@4.2.20)(vite@5.4.20))': + '@sveltejs/adapter-static@3.0.9(@sveltejs/kit@2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))': dependencies: - '@sveltejs/kit': 2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20))(svelte@4.2.20)(vite@5.4.20) + '@sveltejs/kit': 2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)) - '@sveltejs/kit@2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20))(svelte@4.2.20)(vite@5.4.20)': + '@sveltejs/kit@2.42.1(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.20)(vite@5.4.20) + '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -2184,28 +2193,28 @@ snapshots: set-cookie-parser: 2.7.1 sirv: 3.0.2 svelte: 4.2.20 - vite: 5.4.20 + vite: 5.4.20(@types/node@20.19.17) - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20))(svelte@4.2.20)(vite@5.4.20)': + '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.20)(vite@5.4.20) + '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)) debug: 4.4.3 svelte: 4.2.20 - vite: 5.4.20 + vite: 5.4.20(@types/node@20.19.17) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20)': + '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20))(svelte@4.2.20)(vite@5.4.20) + '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)))(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.19 svelte: 4.2.20 svelte-hmr: 0.16.0(svelte@4.2.20) - vite: 5.4.20 - vitefu: 0.2.5(vite@5.4.20) + vite: 5.4.20(@types/node@20.19.17) + vitefu: 0.2.5(vite@5.4.20(@types/node@20.19.17)) transitivePeerDependencies: - supports-color @@ -2234,13 +2243,13 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte@5.2.8(svelte@4.2.20)(vite@5.4.20)(vitest@2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1))': + '@testing-library/svelte@5.2.8(svelte@4.2.20)(vite@5.4.20(@types/node@20.19.17))(vitest@2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1))': dependencies: '@testing-library/dom': 10.4.1 svelte: 4.2.20 optionalDependencies: - vite: 5.4.20 - vitest: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1) + vite: 5.4.20(@types/node@20.19.17) + vitest: 2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1) '@types/aria-query@5.0.4': {} @@ -2248,6 +2257,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@20.19.17': + dependencies: + undici-types: 6.21.0 + '@types/pug@2.0.10': {} '@ungap/structured-clone@1.3.0': {} @@ -2259,13 +2272,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.20)': + '@vitest/mocker@2.1.9(vite@5.4.20(@types/node@20.19.17))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 5.4.20 + vite: 5.4.20(@types/node@20.19.17) '@vitest/pretty-format@2.1.9': dependencies: @@ -2295,7 +2308,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 1.2.0 - vitest: 2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1) + vitest: 2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1) '@vitest/utils@2.1.9': dependencies: @@ -3442,6 +3455,8 @@ snapshots: typescript@5.9.2: {} + undici-types@6.21.0: {} + update-browserslist-db@1.1.3(browserslist@4.26.2): dependencies: browserslist: 4.26.2 @@ -3454,13 +3469,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@2.1.9: + vite-node@2.1.9(@types/node@20.19.17): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.20 + vite: 5.4.20(@types/node@20.19.17) transitivePeerDependencies: - '@types/node' - less @@ -3472,22 +3487,23 @@ snapshots: - supports-color - terser - vite@5.4.20: + vite@5.4.20(@types/node@20.19.17): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.50.2 optionalDependencies: + '@types/node': 20.19.17 fsevents: 2.3.3 - vitefu@0.2.5(vite@5.4.20): + vitefu@0.2.5(vite@5.4.20(@types/node@20.19.17)): optionalDependencies: - vite: 5.4.20 + vite: 5.4.20(@types/node@20.19.17) - vitest@2.1.9(@vitest/ui@2.1.9)(jsdom@25.0.1): + vitest@2.1.9(@types/node@20.19.17)(@vitest/ui@2.1.9)(jsdom@25.0.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.20) + '@vitest/mocker': 2.1.9(vite@5.4.20(@types/node@20.19.17)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -3503,10 +3519,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.20 - vite-node: 2.1.9 + vite: 5.4.20(@types/node@20.19.17) + vite-node: 2.1.9(@types/node@20.19.17) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 20.19.17 '@vitest/ui': 2.1.9(vitest@2.1.9) jsdom: 25.0.1 transitivePeerDependencies: diff --git a/specs/001-glowtrack-a-mood/tasks.md b/specs/001-glowtrack-a-mood/tasks.md index d7f3a5e..274c9fb 100644 --- a/specs/001-glowtrack-a-mood/tasks.md +++ b/specs/001-glowtrack-a-mood/tasks.md @@ -93,7 +93,7 @@ Integration scenarios from quickstart.md → e2e smoke tests [P] - Steps: open app → set day mood → add positive+negative habits → tile glow/luminance and glyphs update - Dependencies: T007, T005 -- [ ] T013 [P] E2E: export/import JSON roundtrip +- [X] T013 [P] E2E: export/import JSON roundtrip - Create /home/jawz/Development/Projects/GlowTrack/apps/web/tests/e2e/smoke.export-import.spec.ts - Steps: create few days → export JSON → clear DB → import JSON → grid identical - Dependencies: T007, T005