The /<project> landing page was server-rendered via
internal/handler/projecthandler.go's html/template — an inconsistency
against the project's "every tool is a single-file HTML" convention.
Convert it to a mode of the existing landing/ tool: same bundle now
serves both / (project picker) and /<project> (project workspace).
Mechanics:
- landing/template.html: pickerView (existing markup) + projectView
(new: stage cards, browse-all, MDL section, party-list slot).
Mode toggles by adding/removing .hidden on the two containers.
- landing/js/landing.js: detectMode() reads location.pathname;
renderProjectMode() populates stage hrefs from the project segment
and fetches /<project>/archive/?json=1 for the party list. init()
forks based on mode; picker init was extracted to initPicker().
Existing public API + behaviour unchanged for picker mode.
- landing/css/landing.css: appended ~115 lines for the project view
(.stages grid, .stage-card hover, .party-list, MDL formatting).
- cmd/zddc-server/main.go: dispatcher's IsProjectRootURL fork now
calls appsSrv.Serve(w, r, "landing", chain, absPath) rather than
the deleted ServeProjectLanding handler.
- internal/handler/projecthandler.go: trimmed to just the
IsProjectRootURL predicate (the dispatcher still needs it for
routing). Template + render code (~220 lines) deleted.
Net effect: same UI as before — same logo wrapping (now via
shared/logo.js, no longer a hand-rolled inline anchor), same stage
cards, same MDL instructions with party links — but the page is now a
single-file SPA that themes like the rest, follows the same logo and
stage-strip conventions, and could in principle be downloaded and
served standalone.
Tests:
- 3 new tests/landing.spec.js cases: detectMode exposure, project
workspace renders at /<project> with correct stage hrefs + title,
party listing populates from JSON fetch and filters dot-prefixed
entries.
- The dispatcher test for /Project no-slash still asserts 200 +
no-redirect; the served body is now landing.html instead of the
server-rendered template, but both pass the assertion.
LOC: roughly net-neutral. -220 in projecthandler.go, +115 in
landing.css, +130 in landing.js, +60 in template.html.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
339 lines
16 KiB
JavaScript
339 lines
16 KiB
JavaScript
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
|
|
// /<project>. 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 /<project>/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 /<project>', 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');
|
|
});
|
|
});
|