ZDDC/tests/landing.spec.js
ZDDC c95f07966d feat(tools,build): in-flight HTML-tool reworks and build-infra updates
Bundles a stretch of in-progress work across the SPA tools so the
tree returns to a coherent shippable state ahead of cutting a new
zddc-server stable image:

- landing: substantial rework of the project picker (sortable/filterable
  table, presets refactor, ?projects= filter, ?v= channel propagation,
  loading/error states)
- archive: presets cleanup, source.js refactor, filtering/url-state
  alignment with the landing page
- mdedit: file-system module split, resizer, file-tree improvements,
  base/toc styling tweaks
- transmittal/classifier: small template touch-ups for shared chrome
- shared: build-lib.sh helpers, new favicon.svg
- bootstrap, build.sh: pick up the channel-aware install/track zip
  generation
- tests: new landing.spec.js, expanded archive/mdedit/build-label specs
- docs: CLAUDE.md picks up the zddc-server section and freshens the
  alpha-build exception note
- regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip,
  *_alpha.html — these are produced by `sh build.sh` and per project
  convention are committed alongside the source changes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:52:27 -05:00

139 lines
6.8 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 the 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);
// Title column is shown because at least one project has a title.
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 tbody')).toContainText('Brownfield Tap');
// 210045 has no title — should render a dash placeholder, not the empty string.
await expect(page.locator('.project-table-no-title')).toHaveCount(1);
});
test('column filters narrow the table; URL is updated', 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);
const rowsAfterPt = await page.locator('.project-table tbody tr').count();
expect(rowsAfterPt).toBe(1);
await expect(page.locator('.project-table tbody')).toContainText('Brownfield Tap');
});
test('selecting projects enables Open Archive and writes ?projects=', async ({ page }) => {
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
await page.waitForSelector('.project-table', { timeout: 5000 });
await expect(page.locator('#openArchiveBtn')).toBeDisabled();
await page.locator('.project-table-row[data-name="176109"]').click();
await expect(page.locator('#openArchiveBtn')).toBeEnabled();
await expect(page.locator('#selectionSummary')).toContainText('1 project selected');
const search = await page.evaluate(() => location.search);
expect(search).toContain('projects=176109');
});
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);
await expect(page.locator('#openArchiveBtn')).toBeDisabled();
});
test('save / load named preset round-trips selection + filters', async ({ page }) => {
await loadLandingWithProjects(page, SAMPLE_PROJECTS);
await page.waitForSelector('.project-table', { timeout: 5000 });
// Set up state: pick 176109 and filter title by "Green".
await page.locator('.project-table-row[data-name="176109"]').click();
await page.locator('input.column-filter[data-column="pt"]').fill('Green');
await page.waitForTimeout(150);
// Save preset (open the split-button caret menu).
await page.locator('#openArchiveMenuBtn').click();
await page.locator('button:has-text("Save current as preset")').click();
await page.locator('#presetNameInput').fill('My View');
await page.locator('button:has-text("Save")').click();
// Clear state so we can verify preset application restores it.
// (Manual clear: unclick the row and empty the title filter.)
await page.locator('.project-table-row[data-name="176109"]').click();
await page.locator('input.column-filter[data-column="pt"]').fill('');
await page.waitForTimeout(150);
// Sanity: nothing selected, no filter.
const cleared = await page.evaluate(() => ({
selectedRows: document.querySelectorAll('.project-table-row.is-selected').length,
ptValue: document.querySelector('input.column-filter[data-column="pt"]').value,
}));
expect(cleared.selectedRows).toBe(0);
expect(cleared.ptValue).toBe('');
// Open the menu and click "Load" on the preset (apply-stay variant —
// clicking the preset name itself would navigate to archive.html).
await page.locator('#openArchiveMenuBtn').click();
await page.waitForTimeout(100);
await page.locator('.preset-menu-item:has(.preset-menu-item-name:has-text("My View")) .preset-load-btn').click();
await page.waitForTimeout(150);
await expect(page.locator('.project-table-row[data-name="176109"]')).toHaveClass(/is-selected/);
const ptVal = await page.locator('input.column-filter[data-column="pt"]').inputValue();
expect(ptVal).toBe('Green');
});
});