106 lines
8.6 KiB
JavaScript
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();
|
|
}
|