import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import react from '@vitejs/plugin-react';
import { chromium } from 'playwright';
// The real File Manager. Opening a folder while the previous one was still
// loading let the slow answer land last and navigate the user back to the
// folder they had just left. Only HTTP, toasts and translation are stubbed.
const root = fileURLToPath(new URL('../', import.meta.url));
const fixture = `
import React from 'react';
import {createRoot} from 'react-dom/client';
import {MemoryRouter} from 'react-router-dom';
import FileManager from '/src/pages/FileManager.jsx';
import {ConfirmProvider} from '/src/contexts/ConfirmContext.jsx';
window.pending=[];
const listing=(p)=>({path:p,parent:p==='/'?null:p.replace(/\\/[^/]+$/,'')||'/',entries:p==='/home'?[{name:'projects',path:'/home/projects',is_dir:true,type:'directory',size:0,permissions:'drwxr-xr-x',permissions_octal:'750'}]:[{name:'inside-'+p.split('/').pop()+'.txt',path:p+'/x.txt',is_dir:false,type:'file',size:1}]});
window.release=(p)=>{const i=window.pending.findIndex(x=>x.path===p);const x=window.pending.splice(i,1)[0];x.resolve(listing(p));};
window.testApi={
browseFiles:(p)=>(window.calls=window.calls||[]).push(p)&&window.holdAll||p!=='/home'?new Promise(resolve=>window.pending.push({path:p,resolve})):Promise.resolve(listing(p)),
getDiskMounts:()=>Promise.resolve({mounts:[]}),
browseRemoteFiles:(sid,p)=>{(window.remoteCalls=window.remoteCalls||[]).push([sid,p]);return Promise.resolve({path:p,parent:null,entries:[{name:'on-agent.txt',path:p+'/on-agent.txt',is_dir:false,type:'file',size:1}]});},
createDirectory:(p)=>{(window.ops=window.ops||[]).push(['mkdir',p]);return Promise.resolve({});},
renameFile:(p,n)=>{(window.ops=window.ops||[]).push(['rename',p,n]);return Promise.resolve({});},
changeFilePermissions:(p,m)=>{(window.ops=window.ops||[]).push(['chmod',p,m]);return Promise.resolve({});},
};
createRoot(document.getElementById('root')).render();
`;
const server = await createServer({
root, configFile: false, logLevel: 'error', resolve: { alias: { '@': path.join(root, 'src') } },
cacheDir: path.join(root, 'node_modules/.vite-file-manager-regression'),
optimizeDeps: { entries: [] }, server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'file-manager-fixtures', enforce: 'pre',
resolveId(id) {
if (id === 'react-i18next') return '\0test-i18n';
if (id.endsWith('/fm-fixture.jsx')) return path.join(root, 'fm-fixture.jsx');
},
load(id) {
const normalized = id.replaceAll('\\', '/');
if (id === '\0test-i18n') return `const t=(key,f)=>typeof f==='string'?f:(f&&(f.defaultValue_other||f.defaultValue))||key;export const useTranslation=()=>({t,i18n:{language:'en'}});export const Trans=({children})=>children;export const initReactI18next={type:'3rdParty',init(){}};`;
if (normalized.endsWith('/src/contexts/useAuth.js')) return `import {createContext} from 'react';export const AuthContext=createContext(null);const auth={user:{id:1,role:'admin'},isAdmin:true,isAuthenticated:true,hasPermission:()=>true};export const useAuth=()=>auth;`;
if (normalized.endsWith('/src/contexts/useWorkspace.js')) return `const ws={activeWorkspaceId:null,workspaces:[],setActiveWorkspace(){}};export const useWorkspace=()=>ws;`;
if (normalized.endsWith('/src/contexts/useToast.js')) return `const toast={error(m){(window.toasts=window.toasts||[]).push(m)},success(){},info(){},warning(){}};export const useToast=()=>toast;`;
if (normalized.endsWith('/src/services/api.js') || normalized.endsWith('/src/services/api/index.js')) return `const api=new Proxy({}, {get:(_,key)=>(...args)=>(window.testApi[key]?window.testApi[key](...args):Promise.resolve({}))});export {api};export default api;`;
if (normalized.endsWith('/src/components/ServerPicker.jsx')) return `export default function ServerPicker({onChange}){return ;}`;
if (normalized.endsWith('/fm-fixture.jsx')) return fixture;
},
configureServer(vite) {
vite.middlewares.use('/fm-check', async (_req, res) => {
res.setHeader('Content-Type', 'text/html');
res.end(await vite.transformIndexHtml('/fm-check', '
'));
});
},
}, react()],
});
const executablePath = [process.env.CHROME_PATH, chromium.executablePath(), 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find(candidate => candidate && existsSync(candidate));
let browser;
try {
await server.listen();
browser = await chromium.launch({ ...(executablePath ? { executablePath } : {}), headless: true });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
// Open /home/projects (held), then go back Home before it answers.
await page.getByText('projects', { exact: true }).first().dblclick();
await page.waitForFunction(() => window.pending.some(x => x.path === '/home/projects'));
await page.evaluate(() => { window.holdAll = true; });
await page.getByTitle('Home', { exact: true }).first().click();
await page.waitForFunction(() => window.pending.some(x => x.path === '/home'));
await page.evaluate(() => window.release('/home'));
await page.getByText('projects', { exact: true }).first().waitFor();
// The answer for the folder the user left arrives last.
await page.evaluate(() => window.release('/home/projects'));
await page.waitForTimeout(400);
assert.deepEqual(await page.evaluate(() => window.calls.filter(c => c === '/home/projects').length), 1,
'a late answer for the previous folder navigated the user back into it');
assert.equal(await page.getByText('inside-projects.txt').count(), 0);
assert.equal(await page.getByText('projects', { exact: true }).count() > 0, true);
assert.deepEqual(errors, []);
console.log('PASS a slow listing for a folder the user left does not pull them back');
// Blocked site data: every localStorage access throws. The view prefs
// used to read it unguarded during render, which took the page down.
const blocked = await browser.newPage();
const blockedErrors = [];
blocked.on('pageerror', error => blockedErrors.push(error.message));
await blocked.addInitScript(() => {
Object.defineProperty(window, 'localStorage', {
configurable: true,
get() { throw new DOMException('The operation is insecure.', 'SecurityError'); },
});
});
await blocked.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await blocked.getByText('projects', { exact: true }).first().waitFor();
assert.deepEqual(blockedErrors, []);
console.log('PASS the File Manager renders and browses with storage blocked');
// The dialogs: each owns its input, prefilled from the entry, and hands
// the answer back to the page.
const dlg = await browser.newPage();
const dlgErrors = [];
dlg.on('pageerror', error => dlgErrors.push(error.message));
await dlg.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await dlg.getByText('projects', { exact: true }).first().waitFor();
const lastOp = () => dlg.evaluate(() => (window.ops || []).at(-1));
await dlg.getByRole('button', { name: 'New folder' }).first().click();
await dlg.getByPlaceholder('new-folder').fill('assets');
await dlg.getByRole('button', { name: 'Create folder' }).click();
await dlg.waitForFunction(() => (window.ops || []).length === 1);
assert.deepEqual(await lastOp(), ['mkdir', '/home/assets']);
assert.equal(await dlg.getByPlaceholder('new-folder').count(), 0, 'new-folder dialog stayed open');
await dlg.getByTitle('Rename').first().click();
const renameInput = dlg.getByRole('dialog').getByRole('textbox');
assert.equal(await renameInput.inputValue(), 'projects');
await renameInput.fill('work');
await renameInput.press('Enter');
await dlg.waitForFunction(() => (window.ops || []).length === 2);
assert.deepEqual(await lastOp(), ['rename', '/home/projects', 'work']);
await dlg.getByTitle('Permissions').first().click();
// Found by its label: the label is associated with the field.
const modeInput = dlg.getByLabel('Permissions (octal)');
assert.equal(await modeInput.inputValue(), '750');
await modeInput.fill('700');
await dlg.getByRole('button', { name: 'Apply' }).click();
await dlg.waitForFunction(() => (window.ops || []).length === 3);
assert.deepEqual(await lastOp(), ['chmod', '/home/projects', '700']);
assert.deepEqual(dlgErrors, []);
console.log('PASS new-folder, rename and permissions dialogs prefill and submit');
// Switching to a remote agent that advertises no allowed paths keeps the
// same path, so the listing has to reload because the source changed, or
// the panel host's /home stays on screen under a remote header.
const sw = await browser.newPage();
const swErrors = [];
sw.on('pageerror', error => swErrors.push(error.message));
await sw.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await sw.getByText('projects', { exact: true }).first().waitFor();
await sw.getByRole('button', { name: 'pick-agent' }).first().click();
await sw.getByText('on-agent.txt').first().waitFor({ timeout: 5000 });
assert.deepEqual(await sw.evaluate(() => window.remoteCalls), [['7', '/home']]);
assert.equal(await sw.getByText('projects', { exact: true }).count(), 0, 'the panel host listing is still shown');
assert.deepEqual(swErrors, []);
console.log('PASS switching to a remote agent reloads the listing from that agent');
// A local request still in flight must not replace the agent's listing.
const late = await browser.newPage();
await late.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await late.getByText('projects', { exact: true }).first().waitFor();
await late.getByText('projects', { exact: true }).first().dblclick();
await late.waitForFunction(() => window.pending.some(x => x.path === '/home/projects'));
await late.getByRole('button', { name: 'pick-agent' }).click();
await late.getByText('on-agent.txt').first().waitFor();
await late.evaluate(() => window.release('/home/projects'));
await late.waitForTimeout(100);
assert.equal(await late.getByText('inside-projects.txt').count(), 0);
assert.equal(await late.getByTitle('Back', { exact: true }).isDisabled(), true,
'navigation history must not cross storage targets');
console.log('PASS late local responses and history cannot cross a target switch');
// Search responses and canceled searches must not overwrite navigation.
const search = await browser.newPage();
await search.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await search.getByText('projects', { exact: true }).first().waitFor();
await search.evaluate(() => {
window.testApi.searchFiles = () => new Promise(resolve => { window.finishSearch = resolve; });
});
await search.getByPlaceholder('Search files…').fill('old');
await search.getByPlaceholder('Search files…').press('Enter');
await search.waitForFunction(() => Boolean(window.finishSearch));
await search.getByTitle('Home', { exact: true }).click();
// Home is already the current folder, so explicitly refresh it.
await search.locator('.file-manager-toolbar').getByTitle('Refresh', { exact: true }).click();
await search.getByText('projects', { exact: true }).first().waitFor();
await search.evaluate(() => window.finishSearch({ results: [{ name: 'stale-result.txt', path: '/old', is_dir: false }] }));
await search.waitForTimeout(100);
assert.equal(await search.getByText('stale-result.txt').count(), 0);
console.log('PASS a late search cannot overwrite a refreshed directory');
await search.getByPlaceholder('Search files…').fill('cancel me');
await search.getByPlaceholder('Search files…').press('Enter');
await search.locator('.search-field-clear').click();
await search.evaluate(() => window.finishSearch({ results: [{ name: 'canceled-result.txt', path: '/old', is_dir: false }] }));
await search.waitForTimeout(100);
assert.equal(await search.getByText('canceled-result.txt').count(), 0);
console.log('PASS clearing search invalidates its pending response');
// Read A, then B; A arriving last must not become B's editable contents.
const editor = await browser.newPage();
const editorErrors = [];
editor.on('pageerror', error => editorErrors.push(error.message));
await editor.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await editor.getByText('projects', { exact: true }).first().waitFor();
await editor.evaluate(() => {
window.reads = {};
window.testApi.browseFiles = p => Promise.resolve({ path: p, parent: '/', entries:
['a.txt', 'b.txt'].map(name => ({ name, path: p + '/' + name, is_dir: false, is_editable: true, size: 1 })) });
window.testApi.readFile = p => new Promise(resolve => { window.reads[p] = resolve; });
window.testApi.writeFile = () => Promise.reject(new Error('disk full'));
});
await editor.locator('.file-manager-toolbar').getByTitle('Refresh', { exact: true }).click();
await editor.getByText('a.txt', { exact: true }).first().dblclick();
await editor.waitForFunction(() => Boolean(window.reads['/home/a.txt']));
await editor.getByText('b.txt', { exact: true }).first().dblclick();
await editor.waitForFunction(() => Boolean(window.reads['/home/b.txt']));
await editor.evaluate(() => window.reads['/home/b.txt']({ content: 'new-content' }));
await editor.getByLabel('File contents').getByText('new-content', { exact: true }).waitFor();
await editor.evaluate(() => window.reads['/home/a.txt']({ content: 'old-content' }));
await editor.waitForTimeout(100);
assert.equal(await editor.getByText('old-content', { exact: true }).count(), 0);
const preview = editor.getByRole('complementary', { name: 'File preview' });
await preview.getByRole('button', { name: 'Edit', exact: true }).click();
await preview.locator('textarea').fill('unsaved draft');
await preview.getByRole('button', { name: 'Save', exact: true }).click();
await editor.waitForFunction(() => (window.toasts || []).length > 0);
assert.equal(await preview.locator('textarea').inputValue(), 'unsaved draft');
await editor.evaluate(() => {
const browse = window.testApi.browseRemoteFiles;
window.testApi.browseRemoteFiles = (...args) => new Promise(resolve => {
window.releaseEditorTarget = () => resolve(browse(...args));
});
});
await editor.getByRole('button', { name: 'pick-agent' }).click();
await editor.waitForFunction(() => Boolean(window.releaseEditorTarget));
await preview.waitFor({ state: 'detached' });
await editor.evaluate(() => window.releaseEditorTarget());
await editor.getByText('on-agent.txt').first().waitFor();
assert.equal(await preview.count(), 0, 'the previous target preview stayed open');
assert.deepEqual(editorErrors, []);
console.log('PASS preview read races, failed-save draft retention and target reset');
// Shared confirmation must preserve cancel/confirm semantics.
const deletion = await browser.newPage();
await deletion.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await deletion.getByText('projects', { exact: true }).first().waitFor();
await deletion.evaluate(() => {
window.deleted = [];
window.testApi.deleteFile = p => { window.deleted.push(p); return Promise.resolve({}); };
});
await deletion.getByTitle('Delete', { exact: true }).first().click();
await deletion.getByRole('alertdialog').getByRole('button', { name: 'Cancel', exact: true }).click();
assert.deepEqual(await deletion.evaluate(() => window.deleted), []);
await deletion.getByTitle('Delete', { exact: true }).first().click();
await deletion.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click();
await deletion.waitForFunction(() => window.deleted.length === 1);
assert.deepEqual(await deletion.evaluate(() => window.deleted), ['/home/projects']);
console.log('PASS shared delete confirmation cancels or executes the intended path');
// Back/forward still works after moving the toolbar into its own component.
const nav = await browser.newPage();
await nav.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await nav.getByText('projects', { exact: true }).first().waitFor();
await nav.getByText('projects', { exact: true }).first().dblclick();
await nav.waitForFunction(() => window.pending.some(x => x.path === '/home/projects'));
await nav.evaluate(() => window.release('/home/projects'));
await nav.getByText('inside-projects.txt').waitFor();
await nav.getByTitle('Back', { exact: true }).click();
await nav.getByText('projects', { exact: true }).first().waitFor();
assert.equal(await nav.getByTitle('Forward', { exact: true }).isDisabled(), false);
await nav.getByTitle('Forward', { exact: true }).click();
await nav.waitForFunction(() => window.pending.some(x => x.path === '/home/projects'));
await nav.evaluate(() => window.release('/home/projects'));
await nav.getByText('inside-projects.txt').waitFor();
console.log('PASS directory back and forward navigation');
const transfer = await browser.newPage();
await transfer.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await transfer.getByText('projects', { exact: true }).first().waitFor();
await transfer.evaluate(() => {
window.uploadPaths = [];
window.testApi.uploadFile = (p, file, progress) => {
window.uploadPaths.push([p, file.name]);
progress(50);
return new Promise(resolve => { window.finishUpload = resolve; });
};
});
await transfer.locator('input[type="file"]').setInputFiles({ name: 'upload.txt', mimeType: 'text/plain', buffer: Buffer.from('test') });
await transfer.waitForFunction(() => Boolean(window.finishUpload));
await transfer.getByText('projects', { exact: true }).first().dblclick();
await transfer.waitForFunction(() => window.pending.some(x => x.path === '/home/projects'));
await transfer.evaluate(() => window.release('/home/projects'));
await transfer.getByText('inside-projects.txt').waitFor();
const callsBeforeUpload = await transfer.evaluate(() => window.calls.length);
await transfer.evaluate(() => window.finishUpload({}));
await transfer.getByText('Uploads complete', { exact: true }).waitFor();
assert.deepEqual(await transfer.evaluate(() => window.uploadPaths), [['/home', 'upload.txt']]);
assert.equal(await transfer.evaluate(() => window.calls.length), callsBeforeUpload,
'finishing an upload must not reload or navigate to the previous directory');
console.log('PASS uploads retain their destination without disrupting later navigation');
const images = await browser.newPage();
await images.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await images.getByText('projects', { exact: true }).first().waitFor();
await images.evaluate(() => {
window.blobCalls = [];
window.testApi.getFileBlob = p => { window.blobCalls.push(p); return Promise.resolve(new Blob(['x'])); };
window.testApi.browseRemoteFiles = (_id, p) => Promise.resolve({ path: p, entries: [{ name: 'remote.png', path: p + '/remote.png', is_dir: false }] });
});
await images.getByRole('button', { name: 'pick-agent' }).click();
await images.getByText('remote.png', { exact: true }).first().dblclick();
await images.getByText('Image preview unavailable', { exact: true }).waitFor();
assert.deepEqual(await images.evaluate(() => window.blobCalls), []);
console.log('PASS agent image previews never fetch files from the panel host');
const tree = await browser.newPage();
await tree.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await tree.getByText('projects', { exact: true }).first().waitFor();
await tree.evaluate(() => {
const browse = window.testApi.browseFiles;
window.testApi.browseFiles = p => p === '/srv'
? new Promise(resolve => { window.finishTree = resolve; }) : browse(p);
});
const treeRow = tree.locator('.tree-row').filter({ has: tree.locator('.tree-name[title="/srv"]') });
await treeRow.getByRole('button', { name: 'Expand', exact: true }).click();
await tree.waitForFunction(() => Boolean(window.finishTree));
await tree.getByRole('button', { name: 'pick-agent' }).click();
await tree.getByText('on-agent.txt').first().waitFor();
await tree.evaluate(() => window.finishTree({ entries: [{ name: 'local-tree-child', path: '/srv/local-tree-child', is_dir: true }] }));
await tree.waitForTimeout(100);
assert.equal(await tree.getByText('local-tree-child', { exact: true }).count(), 0);
console.log('PASS late tree responses cannot populate another target cache');
const failed = await browser.newPage();
await failed.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await failed.getByText('projects', { exact: true }).first().waitFor();
await failed.evaluate(() => {
window.testApi.browseFiles = () => Promise.reject(new Error('permission denied'));
});
await failed.locator('.file-manager-toolbar').getByTitle('Refresh', { exact: true }).click();
await failed.getByText("Couldn't open this folder.", { exact: true }).waitFor();
assert.equal(await failed.getByText('This folder is empty', { exact: true }).count(), 0);
console.log('PASS listing failures remain errors rather than empty folders');
const deepLink = await browser.newPage();
await deepLink.addInitScript(() => { window.initialPath = '/files?path=/var/www'; });
await deepLink.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await deepLink.waitForFunction(() => window.pending.some(x => x.path === '/var/www'));
await deepLink.evaluate(() => window.release('/var/www'));
await deepLink.getByText('inside-www.txt', { exact: true }).waitFor();
assert.equal(await deepLink.getByText('projects', { exact: true }).count(), 0);
assert.equal(await deepLink.getByTitle('Back', { exact: true }).isDisabled(), true);
console.log('PASS initial deep links retain their directory and history');
const clearing = await browser.newPage();
await clearing.goto(`http://127.0.0.1:${server.httpServer.address().port}/fm-check`);
await clearing.getByText('projects', { exact: true }).first().waitFor();
await clearing.getByPlaceholder('Search files…').fill('draft query');
await clearing.getByText('projects', { exact: true }).first().dblclick();
await clearing.waitForFunction(() => window.pending.some(x => x.path === '/home/projects'));
await clearing.locator('.search-field-clear').click();
assert.equal(await clearing.locator('.file-manager-main').getByText('projects', { exact: true }).count(), 0,
'clearing a query must not expose the previous directory listing while browsing');
await clearing.evaluate(() => {
while (window.pending.some(x => x.path === '/home/projects')) window.release('/home/projects');
});
await clearing.getByText('inside-projects.txt', { exact: true }).waitFor();
console.log('PASS clearing a query during navigation preserves the pending directory load');
} finally {
await browser?.close();
await server.close();
}