ZDDC/tests/landing.spec.js
ZDDC cc515b0f56 feat(landing): single-project click navigates to <project>/archive.html
Previously every project click — single or group — built
archive.html?projects=<list> and let the archive tool's URL-state
detection fan out from there. For a single project that's a
single-page-app trick that obscures the canonical URL.

Now single-project clicks navigate to <project>/archive.html instead.
The benefit is direct URL manipulation: the user can swap archive.html
for working/, staging/, reviewing/, archive/<party>/mdl/table.html etc.
in the address bar without going back through landing. zddc-server's
availability.go already auto-serves the right tool at each canonical
folder, so the destinations resolve without any server change.

Multi-project clicks (groups) keep the ?projects=A,B form because
there's no single subtree root. ACL-trimmed groups that collapse to
one project also take the new single-project path, since the result
is effectively a single-project view either way.

The ?v= channel selector continues to carry across both paths.

Two existing landing.spec.js assertions updated to match the new
single-project URL shape; multi-project assertion unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:46:14 -05:00

250 lines
12 KiB
JavaScript

import { test, expect } from '@playwright/test';
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('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');
});
});