ZDDC/tests/archive.spec.js
ZDDC db1f44cf74 test,docs(zip): browse/archive zip-transmittal coverage + fixture + docs
- tests/browse.spec.js: expand a .zip in the file tree (offline), drill
  into a member subdir, preview a text member — exercises shared/zip-source.js
  and the migrated offline path end to end.
- tests/archive.spec.js: a .zip whose name parses as a transmittal folder
  is scanned like an uncompressed one — members land in the file list with
  tracking numbers parsed, tied to the zip transmittal's folder.
- tests/fixtures/mock-fs-api.js: __setMockDirectoryTree now keeps binary
  leaf values (Uint8Array/ArrayBuffer/Blob) intact instead of String()-ing
  them — needed to feed real zip bytes through the mock FS.
- tests/data/test-archive.sh: each party gets one transmittal delivered as
  a single .zip in received/, so the bitnest fixture exercises the
  zip-as-virtual-directory path.
- ARCHITECTURE.md / AGENTS.md: document .zip-as-navigable-directory (server
  route + ACL model + shared client adapter + the one-level nesting limit).

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

566 lines
26 KiB
JavaScript

import { test, expect } from '@playwright/test';
import { MOCK_FS_INIT_SCRIPT } from './fixtures/mock-fs-api.js';
import * as path from 'path';
const HTML_PATH = path.resolve('archive/dist/archive.html');
test.describe('Archive Browser', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
});
test('loads without errors', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#appContainer', { timeout: 15000 });
// Page title contains "Archive"
await expect(page).toHaveTitle(/Archive/i);
// No-directory message is shown before any directory is opened
await expect(page.locator('#noDirectoryMessage')).toBeVisible();
// The open-directory button is present
await expect(page.locator('#addDirectoryBtn')).toBeVisible();
});
test('scans a mock directory tree and displays files', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// Log to see if the scan is working
page.on('console', msg => {
console.log('Browser console:', msg.text());
});
page.on('pageerror', err => {
console.log('Page error:', err.message);
});
// The archive expects a two-level structure: root → transmittal-folder → files.
// Flat files at the root are not counted — they must be inside subdirectories.
await page.evaluate(() => {
window.__setMockDirectoryTree('test-project', {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First Transmittal': {
'123456-EL-SPC-2623_A (IFC) - Specification.pdf': '%PDF',
'123456-EL-DRW-0001_B (IFR) - Drawing.dwg': 'DWG',
},
'2025-02-10_123456-EM-TRN-0002 (IFC) - Second Transmittal': {
'789012-ME-CAL-0001_A (IFA) - Calculation.pdf': '%PDF',
},
});
console.log('Mock directory set up');
});
await page.locator('#addDirectoryBtn').click();
// Wait for scanning to complete
await page.waitForTimeout(2000);
// Log state for debugging
await page.evaluate(() => {
console.log('After scanning:');
console.log(' window.app exists:', typeof window.app !== 'undefined');
if (typeof window.app !== 'undefined') {
console.log(' window.app type:', typeof window.app);
console.log(' window.app constructor:', window.app.constructor.name);
console.log(' window.app.directories:', Array.isArray(window.app.directories) ? window.app.directories.length : window.app.directories);
console.log(' window.app.files:', Array.isArray(window.app.files) ? window.app.files.length : window.app.files);
console.log(' window.app.filteredFiles:', Array.isArray(window.app.filteredFiles) ? window.app.filteredFiles.length : window.app.filteredFiles);
console.log(' window.app modules:', Object.keys(window.app || {}).filter(k => k.startsWith('modules')));
} else {
console.log(' window.app is undefined!');
}
});
// Select all grouping folders so files are included in the file list
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(500);
// The files table should have at least one data row
const rowCount = await page.locator('#filesTableBody tr').count();
expect(rowCount).toBeGreaterThanOrEqual(1);
// Status bar should show files were found (any non-empty text is fine)
const fileCountText = await page.locator('#fileCount').textContent();
expect(fileCountText).toBeTruthy();
});
test('a .zip transmittal folder is scanned like an uncompressed one', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// One plain transmittal folder + one delivered as a single .zip
// whose name parses as a transmittal-folder name. The zip's
// members should land in the file list exactly like the plain
// folder's files. (JSZip is bundled into archive.html.)
await page.evaluate(async () => {
const zip = new window.JSZip();
zip.file('789012-ME-CAL-0001_A (IFA) - Calculation.pdf', '%PDF calc');
zip.file('sub/789012-ME-DRW-0001_A (IFA) - Detail.dwg', 'DWG detail');
const buf = await zip.generateAsync({ type: 'uint8array' });
window.__setMockDirectoryTree('test-project', {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First Transmittal': {
'123456-EL-SPC-2623_A (IFC) - Specification.pdf': '%PDF',
},
'2025-02-10_123456-EM-TRN-0002 (IFC) - Second Transmittal.zip': buf,
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForFunction(() => window.app && Array.isArray(window.app.files) && window.app.files.length >= 3, { timeout: 10000 });
// 1 file from the plain folder + 2 from inside the zip.
const fileCount = await page.evaluate(() => window.app.files.length);
expect(fileCount).toBeGreaterThanOrEqual(3);
// The zip is surfaced as a transmittal folder, named without ".zip".
const zipFolder = await page.evaluate(() =>
window.app.transmittalFolders.find(f => /Second Transmittal$/.test(f.name)) || null);
expect(zipFolder).toBeTruthy();
expect(zipFolder.name).not.toMatch(/\.zip$/i);
// The zip's members are parsed like normal archive files (tracking
// numbers extracted) and tied to the zip transmittal's folder.
const memberTracking = await page.evaluate(() =>
window.app.files.filter(f => f.folderPath && /\.zip$/i.test(f.folderPath)).map(f => f.trackingNumber).sort());
expect(memberTracking).toEqual(['789012-ME-CAL-0001', '789012-ME-DRW-0001']);
// Select all grouping folders + render the table; zip members show.
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(300);
const rowCount = await page.locator('#filesTableBody tr').count();
expect(rowCount).toBeGreaterThanOrEqual(3);
});
test('Mode 1: ?projects=A,B enters each project\'s Archive subfolder', async ({ page }) => {
// Multi-project layout: server root holds project folders, each containing an
// Archive/ subfolder with third-party folders. ?projects=A,B (set as
// window.app.projectFilter; url-state.restore() handles parsing in real flow)
// selects the projects to scan. Each is descended into <project>/Archive/.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('combined-root', {
'Project-A': {
'Archive': {
'ACME': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF',
},
},
},
},
'Project-B': {
'Archive': {
'Beta': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
'789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF',
},
},
},
},
'Project-C': {
'Archive': {
'Gamma': {
'2025-03-01_345678-EM-TRN-0001 (IFC) - Third': {
'345678-EL-SPC-0003_A (IFC) - SpecC.pdf': '%PDF',
},
},
},
},
});
});
await page.evaluate(() => {
window.app.projectFilter = new Set(['Project-A', 'Project-B']);
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
// Third-party (grouping) folders should be ACME and Beta — never project IDs or "Archive"
const groupingFolders = await page.evaluate(() =>
window.app.groupingFolders.map(f => f.name)
);
expect(groupingFolders).toContain('ACME');
expect(groupingFolders).toContain('Beta');
expect(groupingFolders).not.toContain('Project-A');
expect(groupingFolders).not.toContain('Project-B');
expect(groupingFolders).not.toContain('Archive');
expect(groupingFolders).not.toContain('Gamma');
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(500);
const tableText = await page.locator('#filesTableBody').textContent();
expect(tableText).toContain('SpecA');
expect(tableText).toContain('SpecB');
expect(tableText).not.toContain('SpecC');
});
test('same-name third-parties across projects merge into one party row', async ({ page }) => {
// Both projects have a "BM" third-party folder. The parties pane must
// show "BM" once, and selecting it must surface files from both projects.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('combined-root', {
'176109': {
'Archive': {
'BM': {
'Issued': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - SpecFromA.pdf': '%PDF',
},
},
},
},
},
'197072': {
'Archive': {
'BM': {
'Issued': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
'789012-EL-SPC-0002_A (IFC) - SpecFromB.pdf': '%PDF',
},
},
},
},
},
});
});
await page.evaluate(() => {
window.app.projectFilter = new Set(['176109', '197072']);
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
// The DOM should render exactly one BM row, not two.
const renderedNames = await page.locator('#groupingFoldersList .folder-item-name').allTextContents();
const bmRows = renderedNames.filter(n => n.trim() === 'BM');
expect(bmRows.length).toBe(1);
// Both projects' files should be visible under the single BM party.
const tableText = await page.locator('#filesTableBody').textContent();
expect(tableText).toContain('SpecFromA');
expect(tableText).toContain('SpecFromB');
});
test('toggling visibleProjects hides files without re-scanning', async ({ page }) => {
// Two projects scanned. Toggling 197072 out of visibleProjects should
// hide its files; window.app.files (the in-memory scan result) must NOT
// shrink — only the visible/filtered set changes.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('combined-root', {
'176109': {
'Archive': {
'BM': {
'Issued': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - SpecFromA.pdf': '%PDF',
},
},
},
},
},
'197072': {
'Archive': {
'BM': {
'Issued': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
'789012-EL-SPC-0002_A (IFC) - SpecFromB.pdf': '%PDF',
},
},
},
},
},
});
});
await page.evaluate(() => {
window.app.projectFilter = new Set(['176109', '197072']);
window.app.visibleProjects = new Set(['176109', '197072']);
window.app.isMultiProject = true;
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
const beforeFileCount = await page.evaluate(() => window.app.files.length);
expect(beforeFileCount).toBeGreaterThanOrEqual(2);
// Toggle 197072 out of the visible set — must NOT shrink window.app.files.
await page.evaluate(() => {
window.app.visibleProjects = new Set(['176109']);
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
});
const afterFileCount = await page.evaluate(() => window.app.files.length);
expect(afterFileCount).toBe(beforeFileCount);
const tableText = await page.locator('#filesTableBody').textContent();
expect(tableText).toContain('SpecFromA');
expect(tableText).not.toContain('SpecFromB');
// Toggle 197072 back — file reappears immediately, no rescan needed.
await page.evaluate(() => {
window.app.visibleProjects = new Set(['176109', '197072']);
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
});
const tableTextAfter = await page.locator('#filesTableBody').textContent();
expect(tableTextAfter).toContain('SpecFromA');
expect(tableTextAfter).toContain('SpecFromB');
});
test('?show= URL param round-trips: serialize emits it, restore reads it', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
// Serialize: visibleProjects ⊊ projectFilter → ?show= present
const qsWithSubset = await page.evaluate(() => {
window.app.projectFilter = new Set(['A', 'B', 'C']);
window.app.visibleProjects = new Set(['A']);
return window.app.modules.urlState.serialize();
});
expect(qsWithSubset).toContain('projects=A%2CB%2CC');
expect(qsWithSubset).toContain('show=A');
// Serialize: visibleProjects equals projectFilter → ?show= omitted
const qsAllVisible = await page.evaluate(() => {
window.app.projectFilter = new Set(['A', 'B']);
window.app.visibleProjects = new Set(['A', 'B']);
return window.app.modules.urlState.serialize();
});
expect(qsAllVisible).not.toContain('show=');
// Restore: ?show= populates visibleProjects from URL.
await page.goto(`file://${HTML_PATH}?projects=A,B,C&show=A,B`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
const restored = await page.evaluate(() => {
// Re-run restore in case the early init() raced with DOM availability —
// matches how the existing projectFilter test bypasses this path too.
window.app.modules.urlState.restore();
return {
search: location.search,
projectFilter: Array.from(window.app.projectFilter).sort(),
visibleProjects: Array.from(window.app.visibleProjects).sort(),
};
});
expect(restored.search).toBe('?projects=A,B,C&show=A,B');
expect(restored.projectFilter).toEqual(['A', 'B', 'C']);
expect(restored.visibleProjects).toEqual(['A', 'B']);
});
test('Mode 2: project-root with Archive child auto-descends into Archive only', async ({ page }) => {
// Picked directory is a project root containing Archive/ plus Reviewing/.
// The app should enter Archive/ automatically and ignore Reviewing/.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('project-176109', {
'Archive': {
'ACME': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - InArchive.pdf': '%PDF',
},
},
},
'Reviewing': {
'ShouldNotShow': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - HiddenStage': {
'789012-EL-SPC-0002_A (IFC) - InReviewing.pdf': '%PDF',
},
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
const groupingFolders = await page.evaluate(() =>
window.app.groupingFolders.map(f => f.name)
);
expect(groupingFolders).toContain('ACME');
expect(groupingFolders).not.toContain('Archive');
expect(groupingFolders).not.toContain('Reviewing');
expect(groupingFolders).not.toContain('ShouldNotShow');
const fileNames = await page.evaluate(() => window.app.files.map(f => f.name));
expect(fileNames.some(n => n.includes('InArchive'))).toBe(true);
expect(fileNames.some(n => n.includes('InReviewing'))).toBe(false);
});
test('Mode 3: in-archive layout (no projects=, no Archive child) uses today\'s scan', async ({ page }) => {
// Picked directory IS the Archive folder — children are third-parties directly.
// Behavior must match pre-change baseline.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('Archive', {
'ACME': {
'Issued': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - SpecA.pdf': '%PDF',
},
},
},
'Beta': {
'Received': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
'789012-EL-SPC-0002_A (IFC) - SpecB.pdf': '%PDF',
},
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
const groupingFolders = await page.evaluate(() =>
window.app.groupingFolders.map(f => f.name)
);
expect(groupingFolders).toContain('ACME');
expect(groupingFolders).toContain('Beta');
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(500);
const tableText = await page.locator('#filesTableBody').textContent();
expect(tableText).toContain('SpecA');
expect(tableText).toContain('SpecB');
});
test('disabled folder types are skipped at scan time (not just hidden in UI)', async ({ page }) => {
// Default enabledFolderTypes is {issued, received}. The Incoming folder should
// never be listed — its files must not appear in window.app.files at all.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('Archive', {
'ACME': {
'Issued': {
'2025-01-15_123456-EM-TRN-0001 (IFC) - First': {
'123456-EL-SPC-0001_A (IFC) - SpecIssued.pdf': '%PDF',
},
},
'Incoming': {
'2025-02-10_789012-EM-TRN-0001 (IFC) - Second': {
'789012-EL-SPC-0002_A (IFC) - SpecIncoming.pdf': '%PDF',
},
},
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
const fileNames = await page.evaluate(() => window.app.files.map(f => f.name));
expect(fileNames.some(n => n.includes('SpecIssued'))).toBe(true);
// Incoming is in FOLDER_TYPE_NAMES but not in default enabledFolderTypes — must be skipped
expect(fileNames.some(n => n.includes('SpecIncoming'))).toBe(false);
});
test('Preview toggle is checked by default', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
// The checkbox lives inside the (hidden) preview-controls area
// before any directory is loaded — waitForSelector defaults to
// 'visible' which would time out. Wait for it to be ATTACHED
// and verify the underlying state instead.
await page.waitForSelector('#filePreviewToggle', { state: 'attached', timeout: 15000 });
await expect(page.locator('#filePreviewToggle')).toBeChecked();
});
test('default modifier filter selects only base and +C revisions', async ({ page }) => {
// Files with mixed revision modifiers — by default only base + +C should
// surface in the visible file table; +B (and any other non-+C modifier)
// should be hidden until the user explicitly opts in.
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => {
window.__setMockDirectoryTree('test-project', {
'2025-01-15_123456-EM-TRN-0001 (IFC) - Mixed Mods': {
'123456-EL-SPC-0001_A (IFC) - SpecBase.pdf': '%PDF',
'123456-EL-SPC-0001_A+C1 (IFC) - SpecComment.pdf': '%PDF',
'123456-EL-SPC-0001_A+B1 (IFC) - SpecScratch.pdf': '%PDF',
'123456-EL-SPC-0001_A+D1 (IFC) - SpecDraft.pdf': '%PDF',
},
});
});
await page.locator('#addDirectoryBtn').click();
await page.waitForTimeout(2000);
await page.evaluate(() => {
const cb = document.getElementById('selectAllGroupingCheckbox');
if (cb && !cb.checked) cb.click();
});
await page.waitForTimeout(300);
const state = await page.evaluate(() => ({
available: Array.from(window.app.availableModifiers).sort(),
selected: Array.from(window.app.selectedModifiers).sort(),
visibleNames: window.app.filteredFiles.map(f => f.name),
}));
// The dropdown still LISTS all modifiers found in the data…
expect(state.available).toEqual(['+B', '+C', '+D', 'base']);
// …but only base + +C are pre-selected by default.
expect(state.selected).toEqual(['+C', 'base']);
// Files with hidden modifier types must be absent from the table.
expect(state.visibleNames.some(n => n.includes('SpecBase'))).toBe(true);
expect(state.visibleNames.some(n => n.includes('SpecComment'))).toBe(true);
expect(state.visibleNames.some(n => n.includes('SpecScratch'))).toBe(false);
expect(state.visibleNames.some(n => n.includes('SpecDraft'))).toBe(false);
});
test('parser module uses shared zddc helpers (not its own wrappers)', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#appContainer', { timeout: 15000 });
const probe = await page.evaluate(() => ({
// archive's parser module should NOT re-export wrappers we removed
hasParseFileNameWrapper: typeof window.app.modules.parser?.parseFileName === 'function',
hasParseRevisionWrapper: typeof window.app.modules.parser?.parseRevision === 'function',
// but should still expose archive-specific helpers
hasIsTransmittalFolder: typeof window.app.modules.parser?.isTransmittalFolder === 'function',
hasGroupFiles: typeof window.app.modules.parser?.groupFilesByTrackingNumber === 'function',
// and isTransmittalFolder should agree with shared zddc.parseFolder
validFolder: window.app.modules.parser.isTransmittalFolder('2025-10-31_123456-EM-SUB-0001 (IFR) - General Arrangement'),
invalidFolder: window.app.modules.parser.isTransmittalFolder('not-a-zddc-folder'),
}));
expect(probe.hasParseFileNameWrapper).toBe(false);
expect(probe.hasParseRevisionWrapper).toBe(false);
expect(probe.hasIsTransmittalFolder).toBe(true);
expect(probe.hasGroupFiles).toBe(true);
expect(probe.validFolder).toBe(true);
expect(probe.invalidFolder).toBe(false);
});
});