ZDDC/tests/nav.spec.js
ZDDC 7ced0395b6 feat(shared): lateral project-stage strip in every tool's header
Adds a thin nav strip directly under the app-header showing the four
canonical lifecycle stages from the transmittal-workflow spec:
archive · working · staging · reviewing. Each is a link to that
stage's directory under the current project. Current stage is
highlighted (bold + primary color, aria-current="page"). Strip
mounts as a sibling of .app-header on DOMContentLoaded — no
template changes needed in any tool.

Render rules (shared/nav.js shouldRender):
- location.protocol must be http: or https: (file:// has no project
  structure to navigate within)
- a project segment must be detectable as the first path segment
  (when it isn't a tool HTML file like /index.html or
  /archive.html?projects=A,B). Multi-project view at the deployment
  root therefore shows no strip.

Stage URL targets:
- Archive   → <project>/archive.html       (project-root archive view)
- Working   → <project>/working/           (directory listing — mdedit auto-served)
- Staging   → <project>/staging/           (directory listing — transmittal auto-served)
- Reviewing → <project>/reviewing/         (directory listing)

Convention-driven, not probed: if a deployment doesn't have one of
these folders the link returns 404. Operators on non-standard layouts
can opt out by setting window.zddc.nav.disabled = true before
DOMContentLoaded.

This pairs with the previous landing-tool change (single-project
click → <project>/archive.html). Together they give the user
both URL-bar manipulation AND visible navigation across the four
canonical project stages.

Five Playwright tests in tests/nav.spec.js exercise:
- non-render at deployment root
- render + active stage on <project>/archive.html
- render + active stage deep inside <project>/working/foo/mdedit.html
- canonical link targets
- mount position is sibling of .app-header

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

91 lines
3.8 KiB
JavaScript

// Tests for shared/nav.js — the lateral project-stage strip.
//
// The strip's render decision depends on location.protocol and
// location.pathname. file:// won't render at all (online-only). To
// exercise online behavior we spin up a tiny in-process HTTP server
// for this spec so the page can be served from http://127.0.0.1:<port>
// at arbitrary paths.
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('classifier/dist/classifier.html');
let server;
let baseUrl;
test.beforeAll(async () => {
const html = fs.readFileSync(HTML_PATH, 'utf8');
server = http.createServer((req, res) => {
// Serve the same classifier HTML at every path. The strip's
// detection logic uses location.pathname; the bytes don't have
// to vary.
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const port = server.address().port;
baseUrl = `http://127.0.0.1:${port}`;
});
test.afterAll(async () => {
if (server) await new Promise(resolve => server.close(resolve));
});
test.describe('shared/nav.js stage strip', () => {
test('does NOT render at the deployment root', async ({ page }) => {
await page.goto(`${baseUrl}/index.html`, { waitUntil: 'load' });
await page.waitForSelector('.app-header', { timeout: 5000 });
await expect(page.locator('.zddc-stage-strip')).toHaveCount(0);
});
test('renders for <project>/archive.html with archive active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
const strip = page.locator('.zddc-stage-strip');
await expect(strip).toHaveCount(1);
await expect(strip.locator('.zddc-stage-strip__project')).toHaveText('projA');
const stages = await strip.locator('.zddc-stage').allTextContents();
expect(stages).toEqual(['Archive', 'Working', 'Staging', 'Reviewing']);
const active = strip.locator('.zddc-stage--active');
await expect(active).toHaveCount(1);
await expect(active).toHaveText('Archive');
await expect(active).toHaveAttribute('aria-current', 'page');
});
test('renders for <project>/working/foo/mdedit.html with working active', async ({ page }) => {
await page.goto(`${baseUrl}/projA/working/casey/mdedit.html`, { waitUntil: 'load' });
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
await expect(active).toHaveText('Working');
});
test('stage links point to the canonical <project>/<stage>/ URLs', async ({ page }) => {
await page.goto(`${baseUrl}/projA/staging/`, { waitUntil: 'load' });
await page.waitForSelector('.zddc-stage-strip');
const links = await page.evaluate(() => {
const xs = document.querySelectorAll('.zddc-stage-strip .zddc-stage');
return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') }));
});
expect(links).toEqual([
{ text: 'Archive', href: '/projA/archive.html' },
{ text: 'Working', href: '/projA/working/' },
{ text: 'Staging', href: '/projA/staging/' },
{ text: 'Reviewing', href: '/projA/reviewing/' },
]);
});
test('mounts immediately under the app-header', async ({ page }) => {
await page.goto(`${baseUrl}/projA/archive.html`, { waitUntil: 'load' });
const next = await page.evaluate(() => {
const h = document.querySelector('.app-header');
return h && h.nextElementSibling && h.nextElementSibling.className;
});
expect(next).toContain('zddc-stage-strip');
});
});