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 } }); });