180 lines
7.2 KiB
TypeScript
180 lines
7.2 KiB
TypeScript
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<Record<string, string | number | null>> = [];
|
|
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<void>((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
|
|
}
|
|
});
|
|
});
|