import { test, expect } from '@playwright/test'; import * as http from 'http'; import * as fs from 'fs'; import * as path from 'path'; const HTML_PATH = path.resolve('landing/dist/index.html'); const FILE_URL = 'file://' + HTML_PATH; // The landing page fetches its containing directory with Accept: application/json // to get the project list. Chromium refuses fetch() on file:// URLs entirely, so // page.route() can't help — we monkey-patch window.fetch in an init script and // drive the response from window.__mockProjects, set per-test. const FETCH_MOCK = ` window.__mockProjects = []; const realFetch = window.fetch; window.fetch = async function(url, opts) { const accept = opts && opts.headers && opts.headers.Accept; if (accept && String(accept).includes('json')) { return new Response(JSON.stringify(window.__mockProjects), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } return realFetch.apply(this, arguments); }; `; const SAMPLE_PROJECTS = [ { name: '176109', url: '/176109/', title: 'Greenfield Substation' }, { name: '197072', url: '/197072/', title: 'Brownfield Tap' }, { name: '210045', url: '/210045/', title: '' }, ]; async function loadLandingWithProjects(page, projects) { await page.addInitScript({ content: FETCH_MOCK }); await page.addInitScript(p => { window.__mockProjects = p; }, projects); await page.goto(FILE_URL, { waitUntil: 'domcontentloaded' }); } test.describe('Landing page', () => { test('renders welcome hero and a project table when projects come back', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); await expect(page.locator('.landing-hero h1')).toContainText(/Welcome/i); const rowCount = await page.locator('.project-table tbody tr').count(); expect(rowCount).toBe(3); await expect(page.locator('th.project-table-title-col')).toBeVisible(); await expect(page.locator('.project-table tbody')).toContainText('Greenfield Substation'); await expect(page.locator('.project-table-no-title')).toHaveCount(1); }); test('default mode has no project checkbox column; click row opens that single project', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); // Default mode: no checkbox column on rows. await expect(page.locator('.project-table tbody td.project-table-checkbox-col')).toHaveCount(0); // Stub navigation so the click doesn't navigate the test page. await page.evaluate(() => { window.__navTo = null; window.LandingApp._setNavigate(url => { window.__navTo = url; }); }); await page.locator('.project-table-row[data-name="197072"]').click(); const navTo = await page.evaluate(() => window.__navTo); // Single-project click navigates to the project's canonical subtree // (so the user can swap archive.html for working/, staging/, etc.). expect(navTo).toContain('/197072/archive.html'); expect(navTo).not.toContain('?projects='); }); test('column filters narrow the table; filters persist in URL', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); await page.locator('input.column-filter[data-column="pn"]').fill('176'); await page.waitForTimeout(150); const rowsAfterPn = await page.locator('.project-table tbody tr').count(); expect(rowsAfterPn).toBe(1); await expect(page.locator('.project-table tbody')).toContainText('176109'); const search = await page.evaluate(() => location.search); expect(search).toContain('pn=176'); await page.locator('input.column-filter[data-column="pn"]').fill(''); await page.locator('input.column-filter[data-column="pt"]').fill('Brown'); await page.waitForTimeout(150); await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap'); }); test('shows a friendly empty state when the server returns no projects', async ({ page }) => { await loadLandingWithProjects(page, []); await page.waitForSelector('.project-list-empty', { timeout: 5000 }); await expect(page.locator('.project-list-empty')).toContainText(/No projects to show/); await expect(page.locator('.project-list-empty')).toContainText(/access/i); }); test('"+ New group" enters select-mode: checkboxes appear, action bar shows', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); await expect(page.locator('#selectActionBar')).toBeHidden(); await expect(page.locator('.project-table tbody td.project-table-checkbox-col')).toHaveCount(0); await page.locator('#newGroupBtn').click(); await expect(page.locator('#selectActionBar')).toBeVisible(); await expect(page.locator('.project-table tbody td.project-table-checkbox-col')).toHaveCount(3); await expect(page.locator('#groupNameInput')).toBeFocused(); }); test('Save group writes to localStorage and renders in the groups table', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); await page.locator('#newGroupBtn').click(); // Click two project rows to check them. await page.locator('.project-table-row[data-name="176109"]').click(); await page.locator('.project-table-row[data-name="197072"]').click(); await page.locator('#groupNameInput').fill('Greenfield Sites'); await page.locator('#saveGroupBtn').click(); // Action bar closed, group rendered in groups table. await expect(page.locator('#selectActionBar')).toBeHidden(); await expect(page.locator('.groups-table')).toContainText('Greenfield Sites'); await expect(page.locator('.groups-row[data-name="Greenfield Sites"] .groups-row-count')).toContainText('2 projects'); const stored = await page.evaluate(() => localStorage.getItem('zddc_landing_groups')); expect(stored).toContain('Greenfield Sites'); expect(stored).toContain('176109'); expect(stored).toContain('197072'); }); test('clicking a group row opens archive with that group\'s projects', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); // Seed a group via storage and reload. await page.evaluate(() => { localStorage.setItem('zddc_landing_groups', JSON.stringify([ { name: 'Both', projects: ['176109', '197072'] } ])); }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.groups-row', { timeout: 5000 }); await page.evaluate(() => { window.__navTo = null; window.LandingApp._setNavigate(url => { window.__navTo = url; }); }); await page.locator('.groups-row[data-name="Both"]').click(); const navTo = await page.evaluate(() => window.__navTo); expect(navTo).toMatch(/archive\.html\?projects=176109,197072|archive\.html\?projects=197072,176109/); }); test('delete button removes a group', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.evaluate(() => { localStorage.setItem('zddc_landing_groups', JSON.stringify([ { name: 'ToGo', projects: ['176109'] } ])); }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.groups-row', { timeout: 5000 }); // Auto-confirm the confirm() dialog. page.once('dialog', d => d.accept()); await page.locator('.groups-row[data-name="ToGo"] .groups-btn-delete').click(); await expect(page.locator('.groups-row[data-name="ToGo"]')).toHaveCount(0); const stored = await page.evaluate(() => localStorage.getItem('zddc_landing_groups')); expect(stored).not.toContain('ToGo'); }); test('edit button enters select-mode pre-populated with that group\'s projects', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.evaluate(() => { localStorage.setItem('zddc_landing_groups', JSON.stringify([ { name: 'OnlyOne', projects: ['176109'] } ])); }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.groups-row', { timeout: 5000 }); await page.locator('.groups-row[data-name="OnlyOne"] .groups-btn-edit').click(); await expect(page.locator('#selectActionBar')).toBeVisible(); await expect(page.locator('#groupNameInput')).toHaveValue('OnlyOne'); // Pre-checked: 176109 only. const checked = page.locator('.project-table tbody input[type="checkbox"]:checked'); await expect(checked).toHaveCount(1); const checkedValue = await checked.getAttribute('value'); expect(checkedValue).toBe('176109'); }); test('"Open selected" excludes filtered-out checked projects', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); await page.waitForSelector('.project-table', { timeout: 5000 }); await page.locator('#newGroupBtn').click(); // Check two: 176109 and 197072. await page.locator('.project-table-row[data-name="176109"]').click(); await page.locator('.project-table-row[data-name="197072"]').click(); // Filter to hide 197072 (its name doesn't contain "176"). await page.locator('input.column-filter[data-column="pn"]').fill('176'); await page.waitForTimeout(150); // Stub navigation. await page.evaluate(() => { window.__navTo = null; window.LandingApp._setNavigate(url => { window.__navTo = url; }); }); await page.locator('#openSelectedBtn').click(); const navTo = await page.evaluate(() => window.__navTo); // After the visibility-filter trims to one project, this collapses // to the single-project path (no ?projects= form). expect(navTo).toContain('/176109/archive.html'); expect(navTo).not.toContain('197072'); expect(navTo).not.toContain('?projects='); }); test('project mode: detectMode classifies URLs correctly', async ({ page }) => { await loadLandingWithProjects(page, []); const result = await page.evaluate(() => ({ picker: window.LandingApp.detectMode === undefined ? 'no-fn' : null, })); // Sanity: the project-mode entry points are exposed. expect(result.picker).toBeNull(); }); }); // project-mode tests need a real http(s) origin so location.pathname can be // /. Spin up a tiny in-process server that serves the same // landing HTML at any path. test.describe('Landing project mode', () => { let server; let baseUrl; test.beforeAll(async () => { const html = fs.readFileSync(HTML_PATH, 'utf8'); server = http.createServer((req, res) => { // The page itself fetches //archive/ for the // party listing. Stub that with a small JSON listing so the // direct-link section renders. Anything else returns the // landing HTML. if (req.url === '/Project-1/archive/' && (req.headers.accept || '').includes('json')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify([ { name: 'PartyA/', is_dir: true, size: 0, url: '/Project-1/Archive/PartyA/' }, { name: 'PartyB/', is_dir: true, size: 0, url: '/Project-1/Archive/PartyB/' }, { name: '.hidden/', is_dir: true, size: 0, url: '/Project-1/Archive/.hidden/' }, ])); return; } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); }); await new Promise(r => server.listen(0, '127.0.0.1', r)); baseUrl = `http://127.0.0.1:${server.address().port}`; }); test.afterAll(async () => { if (server) await new Promise(r => server.close(r)); }); test('renders project workspace at /', async ({ page }) => { await page.goto(`${baseUrl}/Project-1`, { waitUntil: 'load' }); await page.waitForSelector('#projectView:not(.hidden)', { timeout: 5000 }); // Project name surfaces in the H1 + the document title. await expect(page.locator('#projectName')).toHaveText('Project-1'); expect(await page.title()).toBe('Project-1 — ZDDC'); // Picker view is hidden. await expect(page.locator('#pickerView')).toBeHidden(); // Four stage cards with the expected hrefs. const stageHrefs = await page.evaluate(() => ({ archive: document.getElementById('stageArchive').getAttribute('href'), working: document.getElementById('stageWorking').getAttribute('href'), staging: document.getElementById('stageStaging').getAttribute('href'), reviewing: document.getElementById('stageReviewing').getAttribute('href'), })); expect(stageHrefs).toEqual({ archive: '/Project-1/archive', working: '/Project-1/working', staging: '/Project-1/staging', reviewing: '/Project-1/reviewing', }); }); test('lists existing parties as direct MDL links', async ({ page }) => { await page.goto(`${baseUrl}/Project-1`, { waitUntil: 'load' }); // Wait for the async party fetch to populate. await page.waitForSelector('.party-list a', { timeout: 5000 }); const links = await page.locator('.party-list a').allTextContents(); expect(links.sort()).toEqual(['PartyA MDL →', 'PartyB MDL →']); // Hidden dot-prefixed entry was filtered out. const hrefs = await page.evaluate(() => [...document.querySelectorAll('.party-list a')].map(a => a.getAttribute('href')) ); expect(hrefs).toContain('/Project-1/Archive/PartyA/mdl/'); expect(hrefs).toContain('/Project-1/Archive/PartyB/mdl/'); expect(hrefs.some(h => h.includes('.hidden'))).toBe(false); }); test('legacy presets are migrated to groups on first load', async ({ page }) => { await loadLandingWithProjects(page, SAMPLE_PROJECTS); // Seed legacy presets and clear the new key. await page.evaluate(() => { localStorage.removeItem('zddc_landing_groups'); localStorage.setItem('zddc_landing_presets', JSON.stringify([ { name: 'Old Preset', state: { projects: ['176109', '210045'], pn: 'foo', sort: 'name' } } ])); }); await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.groups-row', { timeout: 5000 }); await expect(page.locator('.groups-row[data-name="Old Preset"]')).toBeVisible(); const stored = await page.evaluate(() => localStorage.getItem('zddc_landing_groups')); expect(stored).toContain('Old Preset'); expect(stored).toContain('176109'); expect(stored).toContain('210045'); // Filter/sort metadata should NOT carry over. expect(stored).not.toContain('foo'); }); });