Files
ServerKit/frontend/scripts/file-manager-browser.mjs
T
Juan Denis 0adb6bf752 Tie form labels to their fields
156 labels named no field: no htmlFor and no control inside, so
clicking one did not focus the input and screen readers announced the
input without a name. Each now points at its field through a useId()
id. The change is additive only (ids and htmlFor), checked by comparing
tokens with the previous version.
2026-10-10 22:25:37 -04:00

157 lines
10 KiB
JavaScript

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';
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(<MemoryRouter initialEntries={['/files']}><FileManager/></MemoryRouter>);
`;
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 <button type="button" onClick={()=>onChange('7',{name:'edge',allowed_paths:[]})}>pick-agent</button>;}`;
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', '<div id="root"></div><script type="module" src="/fm-fixture.jsx"></script>'));
});
},
}, 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');
} finally {
await browser?.close();
await server.close();
}