// End-to-end cascade behaviour for the archive browser. // // Pins the contract for how the three filter layers compose: // 1. Folder-type bar (enabledFolderTypes ⊂ {issued, received, mdl, incoming}) // 2. Selected parties (selectedGroupingFolders, name-keyed) // 3. Selected projects (visibleProjects, in multi-project mode) // // Specifically targets the symptom "I select various third-party folders and // the Issued/Received/MDL/Incoming badges, and sometimes files that should be // there aren't shown". Each test asserts both the file table and the // transmittal folder list, since the user reported both can drop entries. 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'); // All four folder types enabled — used at the start of each test so default // (issued+received only) isn't quietly hiding things. async function enableAllFolderTypes(page) { await page.evaluate(() => { window.app.enabledFolderTypes = new Set(['issued', 'received', 'mdl', 'incoming']); }); } async function selectAllParties(page) { await page.evaluate(() => { const cb = document.getElementById('selectAllGroupingCheckbox'); if (cb && !cb.checked) cb.click(); }); await page.waitForTimeout(200); } // Returns the names of files currently visible in the table (parsed from the // row labels). Folder-type changes flow through applyFilters → filteredFiles // → updateFileTable, so reading filteredFiles directly is the closest signal. async function visibleFileNames(page) { return page.evaluate(() => window.app.filteredFiles.map(f => f.name)); } // Returns the visible-transmittal-folder paths (the ones that pass the // grouping/folder-type cascade — what the left panel actually renders). async function visibleTransmittalPaths(page) { return page.evaluate(() => { const items = document.querySelectorAll('#transmittalFoldersList .folder-item'); return Array.from(items).map(el => el.getAttribute('data-path')); }); } test.describe('Archive cascade: folder-type × parties × outstanding', () => { test.beforeEach(async ({ page }) => { await page.addInitScript(MOCK_FS_INIT_SCRIPT); }); test('all four folder-type badges hide their subtrees and only their subtrees', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); // Start with all four enabled so the scan picks up MDL/Incoming. await enableAllFolderTypes(page); await page.evaluate(() => { window.__setMockDirectoryTree('Archive', { 'BM': { 'Issued': { '2025-01-01_T-ISSUED (IFC) - I': { '100-EL-SPC-0001_A (IFC) - SpecIssued.pdf': '%PDF', }, }, 'Received': { '2025-01-02_T-RECEIVED (IFC) - R': { '100-EL-SPC-0002_A (IFC) - SpecReceived.pdf': '%PDF', }, }, 'MDL': { '2025-01-03_T-MDL (IFC) - M': { '100-EL-SPC-0003_A (IFC) - SpecMDL.pdf': '%PDF', }, }, 'Incoming': { '2025-01-04_T-INCOMING (IFC) - In': { '100-EL-SPC-0004_A (IFC) - SpecIncoming.pdf': '%PDF', }, }, }, }); }); await page.locator('#addDirectoryBtn').click(); await page.waitForTimeout(2000); // All four folder types should have surfaced files at scan time. const allFour = await visibleFileNames(page); for (const expected of ['SpecIssued', 'SpecReceived', 'SpecMDL', 'SpecIncoming']) { expect(allFour.some(n => n.includes(expected)), `expected ${expected} present with all four enabled; got ${allFour.join(', ')}`).toBe(true); } // Toggle each folder type off and verify its subtree's files disappear, // while the other three stay. This is the load-bearing claim. const types = [ { type: 'issued', expectGone: 'SpecIssued' }, { type: 'received', expectGone: 'SpecReceived' }, { type: 'mdl', expectGone: 'SpecMDL' }, { type: 'incoming', expectGone: 'SpecIncoming' }, ]; for (const { type, expectGone } of types) { // Disable the type via the canonical app entry point. await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type); await page.waitForTimeout(300); const visible = await visibleFileNames(page); expect( visible.some(n => n.includes(expectGone)), `disabling ${type} should hide ${expectGone}; saw: ${visible.join(', ')}` ).toBe(false); // The other three subtrees keep their files. for (const other of types) { if (other.type === type) continue; expect( visible.some(n => n.includes(other.expectGone)), `disabling ${type} must not hide ${other.expectGone}; saw: ${visible.join(', ')}` ).toBe(true); } // Re-enable for the next iteration. toggleFolderType triggers a // refreshDirectories() because the scan dropped that subtree — // we need to wait for the rescan to finish before continuing. await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type); await page.waitForTimeout(2000); } }); test('toggling a folder type off then on does not duplicate or lose files', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); await enableAllFolderTypes(page); await page.evaluate(() => { window.__setMockDirectoryTree('Archive', { 'BM': { 'Issued': { '2025-01-01_T-ISSUED (IFC) - I': { '100-EL-SPC-0001_A (IFC) - I1.pdf': '%PDF', '100-EL-SPC-0001_B (IFC) - I2.pdf': '%PDF', }, }, 'Received': { '2025-01-02_T-RECEIVED (IFC) - R': { '100-EL-SPC-0002_A (IFC) - R1.pdf': '%PDF', }, }, }, }); }); await page.locator('#addDirectoryBtn').click(); await page.waitForTimeout(2000); const baseline = await page.evaluate(() => ({ files: window.app.files.length, txn: window.app.transmittalFolders.length, grouping: window.app.groupingFolders.length, })); expect(baseline.files).toBe(3); // Toggle Issued off → on. await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); await page.waitForTimeout(300); await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); await page.waitForTimeout(2000); const after = await page.evaluate(() => ({ files: window.app.files.length, txn: window.app.transmittalFolders.length, grouping: window.app.groupingFolders.length, // Distinct-file-id counts to catch duplication via re-scan. distinctIds: new Set(window.app.files.map(f => f.id)).size, distinctPaths: new Set(window.app.files.map(f => f.path)).size, })); expect(after.files, 'no file duplication after toggle off→on').toBe(baseline.files); expect(after.distinctIds, 'each file should still have a unique id').toBe(after.files); expect(after.distinctPaths, 'no duplicate file paths').toBe(after.files); expect(after.grouping, 'grouping folders not duplicated').toBe(baseline.grouping); expect(after.txn, 'transmittal folders not duplicated').toBe(baseline.txn); }); test('outstanding files: visible under /, /Issued/, /MDL/ matches the folder-type cascade', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); await enableAllFolderTypes(page); await page.evaluate(() => { window.__setMockDirectoryTree('Archive', { 'BM': { // Loose file directly under party — not under any folder-type marker. '100-EL-SPC-LOOSE_A (IFC) - LooseAtParty.pdf': '%PDF', 'Issued': { // Loose file under Issued (no transmittal folder wrapper) '100-EL-SPC-LOOSE_A (IFC) - LooseAtIssued.pdf': '%PDF', }, 'MDL': { '100-EL-SPC-LOOSE_A (IFC) - LooseAtMDL.pdf': '%PDF', }, }, }); }); await page.locator('#addDirectoryBtn').click(); await page.waitForTimeout(2000); await selectAllParties(page); // With all folder types enabled, all three loose files should be visible // under the Outstanding virtual transmittal. const allEnabled = await visibleFileNames(page); expect(allEnabled.some(n => n.includes('LooseAtParty'))).toBe(true); expect(allEnabled.some(n => n.includes('LooseAtIssued'))).toBe(true); expect(allEnabled.some(n => n.includes('LooseAtMDL'))).toBe(true); // Disable MDL — only LooseAtMDL should drop. await page.evaluate(() => window.app.modules.app.toggleFolderType('mdl')); await page.waitForTimeout(2000); const noMDL = await visibleFileNames(page); expect(noMDL.some(n => n.includes('LooseAtParty'))).toBe(true); expect(noMDL.some(n => n.includes('LooseAtIssued'))).toBe(true); expect(noMDL.some(n => n.includes('LooseAtMDL'))).toBe(false); // Disable Issued too — LooseAtIssued drops, LooseAtParty stays // (LooseAtParty's path has no folder-type segment at all). await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); await page.waitForTimeout(2000); const noIssuedMDL = await visibleFileNames(page); expect(noIssuedMDL.some(n => n.includes('LooseAtParty'))).toBe(true); expect(noIssuedMDL.some(n => n.includes('LooseAtIssued'))).toBe(false); expect(noIssuedMDL.some(n => n.includes('LooseAtMDL'))).toBe(false); }); test('same-name party across two projects + folder-type cascade hides both projects symmetrically', async ({ page }) => { // BM in ProjectA AND ProjectB, each with Issued and Received subtrees. // Disabling Received must hide BOTH projects' Received files; // selecting BM (one row in the panel) must surface both projects' Issued. await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); await enableAllFolderTypes(page); await page.evaluate(() => { window.__setMockDirectoryTree('combined-root', { 'ProjectA': { 'Archive': { 'BM': { 'Issued': { '2025-01-01_T-A-I (IFC) - X': { '100-EL-SPC-0001_A (IFC) - A_Issued.pdf': '%PDF', }, }, 'Received': { '2025-01-02_T-A-R (IFC) - X': { '100-EL-SPC-0002_A (IFC) - A_Received.pdf': '%PDF', }, }, }, }, }, 'ProjectB': { 'Archive': { 'BM': { 'Issued': { '2025-02-01_T-B-I (IFC) - X': { '200-EL-SPC-0001_A (IFC) - B_Issued.pdf': '%PDF', }, }, 'Received': { '2025-02-02_T-B-R (IFC) - X': { '200-EL-SPC-0002_A (IFC) - B_Received.pdf': '%PDF', }, }, }, }, }, }); window.app.projectFilter = new Set(['ProjectA', 'ProjectB']); window.app.visibleProjects = new Set(['ProjectA', 'ProjectB']); window.app.isMultiProject = true; }); await page.locator('#addDirectoryBtn').click(); await page.waitForTimeout(2000); // BM should appear once in the parties panel. const partyRows = await page.locator('#groupingFoldersList .folder-item-name').allTextContents(); const bmCount = partyRows.filter(n => n.trim() === 'BM').length; expect(bmCount, 'BM merged to one party row across projects').toBe(1); // All four files visible with all four folder types enabled. const all = await visibleFileNames(page); expect(all.some(n => n.includes('A_Issued'))).toBe(true); expect(all.some(n => n.includes('B_Issued'))).toBe(true); expect(all.some(n => n.includes('A_Received'))).toBe(true); expect(all.some(n => n.includes('B_Received'))).toBe(true); // Disable Received — BOTH projects' Received files hide. await page.evaluate(() => window.app.modules.app.toggleFolderType('received')); await page.waitForTimeout(2000); const noReceived = await visibleFileNames(page); expect(noReceived.some(n => n.includes('A_Issued')), 'ProjectA Issued stays').toBe(true); expect(noReceived.some(n => n.includes('B_Issued')), 'ProjectB Issued stays').toBe(true); expect(noReceived.some(n => n.includes('A_Received')), 'ProjectA Received hidden').toBe(false); expect(noReceived.some(n => n.includes('B_Received')), 'ProjectB Received hidden').toBe(false); // Transmittal folder list also drops both projects' Received transmittals. const txnPaths = await visibleTransmittalPaths(page); expect(txnPaths.every(p => !p.includes('/Received/')), 'no Received transmittals: ' + txnPaths.join(', ')).toBe(true); }); test('toggling a party off then on with selectAllTransmittals re-syncs the transmittal selection', async ({ page }) => { // Default selectAllTransmittals=true. After deselecting BM, BM's // transmittal folders are gone from the list. Reselecting BM should // make them visible AND auto-selected (because select-all is still on). await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); await enableAllFolderTypes(page); await page.evaluate(() => { window.__setMockDirectoryTree('Archive', { 'BM': { 'Issued': { '2025-01-01_T-BM (IFC) - X': { '100-EL-SPC-0001_A (IFC) - BM1.pdf': '%PDF', }, }, }, 'OTHER': { 'Issued': { '2025-01-02_T-OTHER (IFC) - X': { '200-EL-SPC-0002_A (IFC) - OTHER1.pdf': '%PDF', }, }, }, }); }); await page.locator('#addDirectoryBtn').click(); await page.waitForTimeout(2000); // Both parties auto-selected, both transmittals auto-selected. const initial = await page.evaluate(() => ({ selectAllTxn: window.app.selectAllTransmittals, selectedGrouping: Array.from(window.app.selectedGroupingFolders).sort(), selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(), })); expect(initial.selectAllTxn).toBe(true); expect(initial.selectedGrouping).toEqual(['BM', 'OTHER']); expect(initial.selectedTxn.some(p => p.includes('T-BM'))).toBe(true); expect(initial.selectedTxn.some(p => p.includes('T-OTHER'))).toBe(true); // Deselect BM via the canonical event handler (mimics user click) await page.evaluate(() => { window.app.selectAllGroupingFolders = false; window.app.selectedGroupingFolders.delete('BM'); window.app.modules.app.renderGroupingFolders(); window.app.modules.app.renderTransmittalFolders(); window.app.modules.filtering.applyFilters(); }); await page.waitForTimeout(200); // BM's transmittal is no longer visible AND no longer in the selection set. const afterDeselect = await page.evaluate(() => ({ selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(), visiblePaths: Array.from(document.querySelectorAll('#transmittalFoldersList .folder-item')) .map(el => el.getAttribute('data-path')), })); expect(afterDeselect.selectedTxn.some(p => p.includes('T-BM'))).toBe(false); expect(afterDeselect.visiblePaths.some(p => p && p.includes('T-BM'))).toBe(false); // Re-add BM. Because selectAllTransmittals stays true, BM's // transmittal should be auto-selected on the re-render. await page.evaluate(() => { window.app.selectedGroupingFolders.add('BM'); window.app.modules.app.renderGroupingFolders(); window.app.modules.app.renderTransmittalFolders(); window.app.modules.filtering.applyFilters(); }); await page.waitForTimeout(200); const afterReselect = await page.evaluate(() => ({ selectAllTxn: window.app.selectAllTransmittals, selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(), visibleFiles: window.app.filteredFiles.map(f => f.name), })); expect(afterReselect.selectAllTxn).toBe(true); expect( afterReselect.selectedTxn.some(p => p.includes('T-BM')), 'BM transmittal should be re-selected via selectAll cascade' ).toBe(true); expect(afterReselect.visibleFiles.some(n => n.includes('BM1'))).toBe(true); }); }); test.describe('Archive cascade: nested-party path-segment contract', () => { test.beforeEach(async ({ page }) => { await page.addInitScript(MOCK_FS_INIT_SCRIPT); }); // Pin the contract for nested-party paths. With folder-type segment NOT // immediately after the party, the current rule (transmittalIsUnderVisibleParty // checks only the segment right after the first matched party) means the // folder-type cascade can leak. Either the test should fail and we fix // the cascade to walk all party matches; or the contract is "the folder // type only applies one level under the party". Recording the call here // forces us to face the question explicitly when a future change touches // the helper. test('nested party + deep folder-type marker: the deep folder-type filter applies to the file', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); await enableAllFolderTypes(page); await page.evaluate(() => { window.__setMockDirectoryTree('Archive', { 'BM': { 'sub': { 'Issued': { '2025-01-01_T-NESTED (IFC) - X': { '100-EL-SPC-NESTED_A (IFC) - SpecDeepIssued.pdf': '%PDF', }, }, }, }, }); }); await page.locator('#addDirectoryBtn').click(); await page.waitForTimeout(2000); await selectAllParties(page); // With Issued enabled, the file is visible. const visibleAll = await visibleFileNames(page); expect(visibleAll.some(n => n.includes('SpecDeepIssued'))).toBe(true); // Turn Issued OFF — the file lives under .../Issued/... so its scan-time // listing should be skipped. After the toggle-driven rescan, the file // must NOT be visible regardless of how transmittalIsUnderVisibleParty // walks segments. await page.evaluate(() => window.app.modules.app.toggleFolderType('issued')); await page.waitForTimeout(2000); const visibleNoIssued = await visibleFileNames(page); expect( visibleNoIssued.some(n => n.includes('SpecDeepIssued')), 'deep Issued subtree must hide when Issued is toggled off' ).toBe(false); }); }); test.describe('Archive cascade: URL state round-trip', () => { test.beforeEach(async ({ page }) => { await page.addInitScript(MOCK_FS_INIT_SCRIPT); }); test('non-default folder-type set round-trips through serialize → restore', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); const qs = await page.evaluate(() => { window.app.enabledFolderTypes = new Set(['issued', 'mdl']); // received off, incoming off, mdl on return window.app.modules.urlState.serialize(); }); // serialize() returns "?types=..." (leading "?" included) or "" when no // diffs from defaults exist. Strip the leading "?" before re-emitting. const cleanQs = qs.startsWith('?') ? qs.slice(1) : qs; expect(cleanQs).toContain('types='); const round = await page.evaluate((querystring) => { // Reset to defaults so restore has work to do. window.app.enabledFolderTypes = new Set(['issued', 'received']); history.replaceState(null, '', '?' + querystring); window.app.modules.urlState.restore(); return Array.from(window.app.enabledFolderTypes).sort(); }, cleanQs); expect(round).toEqual(['issued', 'mdl']); }); // Selected parties are deliberately NOT in URL state today — the natural // flow is: pick a directory locally, then narrow with party checkboxes. // Pin this in a test so accidentally adding party serialization later // doesn't break sharing semantics; remove the test (and the assertion) // when/if sharing-with-parties becomes a feature. test('selected parties are NOT serialized to URL state (current contract)', async ({ page }) => { await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); const qs = await page.evaluate(() => { window.app.selectAllGroupingFolders = false; window.app.selectedGroupingFolders = new Set(['BM', 'OTHER']); return window.app.modules.urlState.serialize(); }); expect(qs).not.toContain('parties='); expect(qs).not.toContain('groups='); expect(qs).not.toContain('selected='); }); });