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>
139 lines
6.8 KiB
JavaScript
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');
|
|
});
|
|
});
|