Files
jobtrackingapp/job-tracker-ui/scripts/verify-job-export-lifetime.mjs
T

106 lines
8.6 KiB
JavaScript

// Real UI/blob downloads; all API traffic and input files are synthetic.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { gzipSync } from 'node:zlib';
import ts from 'typescript';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/job-export-lifetime');
fs.mkdirSync(out, { recursive: true });
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'jobjakt-export-'));
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
const json = '[{"id":42,"jobTitle":"Synthetic lærer","company":{"name":"Synthetic skole"},"dateApplied":null}]';
const csv = '\ufeffJob Title,Company\r\nSynthetic lærer,Synthetic skole\r\n';
const conversion = 'Job Title,Company\nSynthetic lærer,Synthetic skole';
const report = { journeys: [], downloads: [], captures: [], errors: [], writes: [], unavailableReads: [], blockedExternal: [] };
const configurations = process.env.A3_PILOT ? [[390, 'nb', 'dark']] : [390, 768, 1440].flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(theme => [width, language, theme])));
const browser = await chromium.launch();
try {
for (const [width, language, theme] of configurations) {
const name = `${width}-${language}-${theme}`, copy = translations[language];
console.log(`Checking ${name}`);
const context = await browser.newContext({ viewport: { width, height: 1000 }, hasTouch: width === 390, isMobile: width === 390, acceptDownloads: true, reducedMotion: 'reduce' });
const page = await context.newPage(); let downloads = 0;
page.on('download', () => { downloads += 1; });
page.on('pageerror', error => report.errors.push({ name, message: error.message }));
await page.addInitScript(({ language, theme, conversion }) => {
localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme);
const original = File.prototype.text;
File.prototype.text = function () {
if (this.name !== 'held.csv') return original.call(this);
return new Promise(resolve => { window.__releaseSyntheticCsv = () => resolve(conversion); });
};
}, { language, theme, conversion });
let holdExport = false, releaseExport;
const heldExport = new Promise(resolve => { releaseExport = resolve; });
await page.route('**/*', async route => {
const request = route.request(), url = new URL(request.url()), p = url.pathname;
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && p.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!p.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') { report.writes.push({ name, p, method: request.method() }); return route.abort(); }
if (p === '/api/export/jobs') {
assert.equal(url.searchParams.get('includeDeleted'), 'false');
if (holdExport) await heldExport;
const format = url.searchParams.get('format'); assert.ok(format === 'json' || format === 'csv');
return route.fulfill({ contentType: format === 'csv' ? 'text/csv;charset=utf-8' : 'application/json', body: format === 'csv' ? csv : json });
}
const fixtures = {
'/api/auth/config': { requireAuth: true },
'/api/auth/me': { id: 'a3-export-owner', sessionContext: 'a3-export-session', firstName: 'Alex', displayName: 'Alex Sample', roles: [], plan: 'pro', entitlements: { ai: false }, uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
'/api/companies': [], '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
'/api/notification-settings': { emailFollowUpRemindersEnabled: false },
'/api/ai/settings': { enabled: false, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local' },
'/api/ai/usage': { currentMonth: { calls: 0, estimatedTokens: 0 }, plan: 'pro', monthlyCallLimit: 100, monthlyTokenLimit: 10000, storageUsedBytes: 0, storageLimitBytes: 250000000 },
'/api/billing/status': { enabled: false, canCheckout: false, canManage: false },
};
if (Object.hasOwn(fixtures, p)) return route.fulfill({ json: fixtures[p] });
report.unavailableReads.push(p); return route.fulfill({ status: 404, json: {} });
});
const group = page.getByRole('group', { name: copy.importExportTitle, exact: true });
const saveDownload = async (kind, trigger) => {
const event = page.waitForEvent('download'); await trigger(); const download = await event;
assert.match(download.suggestedFilename(), kind === 'converted' ? /^job-tracker-import-.*\.json$/ : new RegExp(`^job-tracker-export-.*\.${kind}$`));
const target = path.join(temporary, `${name}-${kind}`); await download.saveAs(target);
const bytes = fs.readFileSync(target);
if (kind === 'json') assert.deepEqual(bytes, Buffer.from(json));
else if (kind === 'csv') assert.deepEqual(bytes, Buffer.from(csv));
else { const rows = JSON.parse(bytes.toString('utf8')); assert.equal(rows.length, 1); assert.equal(rows[0].jobTitle, 'Synthetic lærer'); assert.equal(rows[0].company.name, 'Synthetic skole'); }
const compressed = gzipSync(bytes), filename = `${name}-${kind}.gz`; fs.writeFileSync(path.join(out, filename), compressed);
report.downloads.push({ name, kind, filename, bytes: bytes.length, rawSha256: sha(bytes), gzipSha256: sha(compressed) });
};
const spaNavigate = async destination => {
await page.evaluate(value => { history.pushState({}, '', value); window.dispatchEvent(new PopStateEvent('popstate')); }, destination);
};
await page.goto('http://localhost:4338/settings'); await expect(group).toBeVisible();
await saveDownload('json', () => group.getByRole('button', { name: copy.exportJson, exact: true }).click());
await saveDownload('csv', () => group.getByRole('button', { name: copy.exportCsv, exact: true }).click());
await saveDownload('converted', () => page.locator('input[accept=".csv,text/csv"]').setInputFiles({ name: 'normal.csv', mimeType: 'text/csv', buffer: Buffer.from(conversion) }));
await group.scrollIntoViewIfNeeded();
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
report.captures.push({ name, violations: scan.violations }); assert.deepEqual(scan.violations, []);
await page.screenshot({ path: path.join(out, `${name}-exports.png`) });
holdExport = true; await group.getByRole('button', { name: copy.exportJson, exact: true }).click();
await spaNavigate('/export-lifetime-fixture-unmount'); await expect(group).toHaveCount(0);
const response = page.waitForResponse(value => new URL(value.url()).pathname === '/api/export/jobs'); releaseExport(); await (await response).finished();
await page.waitForTimeout(250); assert.equal(downloads, 3, 'No stale export download after SPA unmount');
await spaNavigate('/settings'); await expect(group).toBeVisible();
await page.locator('input[accept=".csv,text/csv"]').setInputFiles({ name: 'held.csv', mimeType: 'text/csv', buffer: Buffer.from(conversion) });
await expect.poll(() => page.evaluate(() => typeof window.__releaseSyntheticCsv)).toBe('function');
await spaNavigate('/export-lifetime-fixture-unmount'); await expect(group).toHaveCount(0);
await page.evaluate(() => window.__releaseSyntheticCsv()); await page.waitForTimeout(250);
assert.equal(downloads, 3, 'No stale conversion download after SPA unmount');
report.journeys.push({ name, downloads, jsonAndCsvBytes: 'PASS', convertedUnicode: 'PASS', unmountProbes: 2, quietWindowMs: 250 });
await context.close();
}
assert.equal(report.journeys.length, configurations.length); assert.deepEqual(report.errors, []); assert.deepEqual(report.writes, []); assert.deepEqual(report.unavailableReads, []);
console.log(`PASS ${report.journeys.length} configurations, ${report.downloads.length} native downloads, ${report.captures.length} accessibility scans; no API writes`);
} finally {
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify(report, null, 2));
await browser.close();
}