mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(web-shell): polish files and trim preview resources * test(cli): observe pending child Hook promises * fix(web-shell): address Files browser review findings * fix(vscode): sync Pierre modification notices * chore: ignore the repo-local .pnpm-store fallback directory pnpm falls back to a project-local store at .pnpm-store/ when the global store directory is not writable (e.g. sandboxed CI runners). It is a regenerable cache that node_modules hard-links into; its thousands of untracked files otherwise dirty the working tree and fail deterministic workspace-clean verification. Co-authored-by: Qwen-Coder <[email protected]> * fix(web-shell): address Pierre preview and file display review findings - getPreviewLanguage folds case on the extension only, so README.MD and config.YML highlight while whole-name detector keys (Gemfile, BUILD) keep their case-sensitive behavior - formatFileName strips the full Default_Ignorable_Code_Point class so zero-advance Hangul fillers and variation selectors cannot desync the tree search projection from the rendered name - pin the previously unwitnessed pierre-build guards: redirected Shiki subpaths count toward the 4 MiB budget, getModuleInfo may return null for redirected ids, emitted assets are skipped, and entry-module resolveId calls carry no importer - pin the theme name->module mapping with exact token colors and give the formatFileName display sites discriminating coverage with repeated-space names --------- Co-authored-by: 钉萁 <[email protected]> Co-authored-by: qwen-code[bot] <qwen-code[bot]@users.noreply.github.com> Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]>
2845 lines
94 KiB
TypeScript
2845 lines
94 KiB
TypeScript
import { readFile } from 'node:fs/promises';
|
|
import ExcelJS from 'exceljs';
|
|
import { expect, test, type Page } from '@playwright/test';
|
|
import {
|
|
assistantTextEvent,
|
|
createWebShellDaemonScenario,
|
|
installMockDaemon,
|
|
turnCompleteEvent,
|
|
userTextEvent,
|
|
} from './utils/mockDaemon';
|
|
|
|
const controlledName =
|
|
'control-\u202e\u0001\u001b[31m\u00ad\u2060\u{e0041}file.ts';
|
|
const controlledDirectory = 'folder-\u202e\u0001';
|
|
|
|
// UI integration uses a simulated daemon; it does not verify filesystem or Git operations.
|
|
async function openFiles(
|
|
page: Page,
|
|
baseURL: string,
|
|
workspaceCwd = '/tmp/qwen-web-shell-e2e',
|
|
afterOpen?: () => Promise<void>,
|
|
primary = true,
|
|
creationEnabled = false,
|
|
deletionEnabled = false,
|
|
uploadEnabled = false,
|
|
fileDeletionEnabled = false,
|
|
) {
|
|
const scenario = createWebShellDaemonScenario({
|
|
workspaceCwd,
|
|
capabilities: {
|
|
workspaceCwd: primary ? workspaceCwd : '/tmp/primary',
|
|
workspaces: [
|
|
{
|
|
id: 'files-workspace',
|
|
cwd: workspaceCwd,
|
|
primary,
|
|
trusted: true,
|
|
},
|
|
],
|
|
},
|
|
events: [
|
|
userTextEvent('Browse the workspace.', { id: 1 }),
|
|
assistantTextEvent('Workspace ready.', { id: 2 }),
|
|
turnCompleteEvent('files-ready', { id: 3 }),
|
|
],
|
|
});
|
|
if (
|
|
creationEnabled ||
|
|
deletionEnabled ||
|
|
uploadEnabled ||
|
|
fileDeletionEnabled
|
|
)
|
|
scenario.capabilities.features.push(
|
|
...(creationEnabled
|
|
? ['workspace_file_write', 'workspace_directory_create']
|
|
: []),
|
|
...(deletionEnabled ? ['workspace_directory_delete'] : []),
|
|
...(fileDeletionEnabled ? ['workspace_file_delete'] : []),
|
|
...(uploadEnabled ? ['workspace_file_upload'] : []),
|
|
'workspace_qualified_rest_core',
|
|
);
|
|
await page.setViewportSize({ width: 1600, height: 1000 });
|
|
await installMockDaemon(page, scenario, { baseURL });
|
|
const contents: Record<string, string> = {
|
|
[controlledName]: 'export const originalPath = true;',
|
|
'control-file.ts': 'export const ordinaryPath = true;',
|
|
'notes draft.md': '# Two spaces',
|
|
[`${controlledDirectory}/child.ts`]: 'export const nestedOriginal = true;',
|
|
'unsupported.lean': 'def unsupported := 42',
|
|
'README.md':
|
|
'# Workspace guide\n\nA rendered **Markdown** document.\n\n- Browse files\n- Read source\n',
|
|
'src/app.ts':
|
|
'export const greeting: string = "Hello, files";\nconsole.log(greeting);\n',
|
|
'index.html': '<h1>Workspace HTML</h1>',
|
|
'broken/recovered.ts': 'export const recovered = true;',
|
|
'src/guide.md': '# Nested guide\n\nA nested document.\n',
|
|
'notes draft.md': '# Draft notes',
|
|
'src/large.ts': Array.from(
|
|
{ length: 1500 },
|
|
(_, index) => `export const large${index} = "${'x'.repeat(750)}";`,
|
|
).join('\n'),
|
|
'src/long.ts': Array.from(
|
|
{ length: 5000 },
|
|
(_, index) => `export const line${index} = ${index};`,
|
|
).join('\n'),
|
|
};
|
|
const deepFolders = Array.from(
|
|
{ length: 14 },
|
|
(_, index) => `level-${index}`,
|
|
);
|
|
const deepFile = 'a-long-filename-that-must-stay-readable-at-deep-levels.ts';
|
|
contents[`deep/${deepFolders.join('/')}/${deepFile}`] =
|
|
'export const deep = true;';
|
|
const buffers = Object.fromEntries(
|
|
Object.entries(contents).map(([path, content]) => [
|
|
path,
|
|
Buffer.from(content),
|
|
]),
|
|
);
|
|
buffers['image.png'] = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aSxkAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
const workbook = new ExcelJS.Workbook();
|
|
const sheet = workbook.addWorksheet('Summary');
|
|
sheet.addRow(['Files workbook', 42]);
|
|
buffers['report.xlsx'] = Buffer.from(await workbook.xlsx.writeBuffer());
|
|
const directoryReads: string[] = [];
|
|
const byteReads: string[] = [];
|
|
const contentReads: string[] = [];
|
|
let rootUpdated = false;
|
|
let failedReads = 0;
|
|
await page.route('**/*', async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const path = url.searchParams.get('path') ?? '';
|
|
if (/\/(?:stat|file(?:\/bytes)?)$/.test(url.pathname))
|
|
contentReads.push(url.pathname);
|
|
if (/^(?:\/workspaces\/[^/]+)?\/list$/.test(url.pathname)) {
|
|
directoryReads.push(`${url.pathname}?path=${path}`);
|
|
if (!path) {
|
|
await route.fulfill({
|
|
status: 400,
|
|
json: { error: 'path query parameter is required' },
|
|
});
|
|
return;
|
|
}
|
|
if (path === 'broken' && failedReads++ === 0) {
|
|
await route.fulfill({
|
|
status: 500,
|
|
json: { error: 'Directory temporarily unavailable' },
|
|
});
|
|
return;
|
|
}
|
|
const entries =
|
|
path === '.'
|
|
? [
|
|
{ name: 'src', kind: 'directory', ignored: false },
|
|
{ name: controlledDirectory, kind: 'directory', ignored: false },
|
|
{ name: 'deep', kind: 'directory', ignored: false },
|
|
{ name: 'empty', kind: 'directory', ignored: false },
|
|
{ name: 'broken', kind: 'directory', ignored: false },
|
|
{ name: 'linked', kind: 'symlink', ignored: false },
|
|
{ name: 'README.md', kind: 'file', ignored: false },
|
|
{ name: 'index.html', kind: 'file', ignored: false },
|
|
{ name: 'image.png', kind: 'file', ignored: false },
|
|
{ name: 'report.xlsx', kind: 'file', ignored: false },
|
|
{ name: 'legacy.xls', kind: 'file', ignored: false },
|
|
{ name: 'notes draft.md', kind: 'file', ignored: false },
|
|
{ name: 'notes draft.md', kind: 'file', ignored: false },
|
|
{ name: controlledName, kind: 'file', ignored: false },
|
|
{ name: 'control-file.ts', kind: 'file', ignored: false },
|
|
{ name: 'unsupported.lean', kind: 'file', ignored: false },
|
|
...(rootUpdated
|
|
? [{ name: 'new.txt', kind: 'file', ignored: false }]
|
|
: []),
|
|
]
|
|
: path === 'src'
|
|
? [
|
|
...Array.from({ length: 30 }, (_, index) => ({
|
|
name: `folder-${String(index).padStart(2, '0')}`,
|
|
kind: 'directory',
|
|
ignored: false,
|
|
})),
|
|
{ name: 'app.ts', kind: 'file', ignored: false },
|
|
{ name: 'guide.md', kind: 'file', ignored: false },
|
|
{ name: 'long.ts', kind: 'file', ignored: false },
|
|
{ name: 'large.ts', kind: 'file', ignored: false },
|
|
]
|
|
: path === 'broken'
|
|
? [{ name: 'recovered.ts', kind: 'file', ignored: false }]
|
|
: path === 'deep' || path.startsWith('deep/')
|
|
? [
|
|
{
|
|
name: deepFolders[path.split('/').length - 1] ?? deepFile,
|
|
kind:
|
|
path.split('/').length <= deepFolders.length
|
|
? 'directory'
|
|
: 'file',
|
|
ignored: false,
|
|
},
|
|
]
|
|
: path === controlledDirectory
|
|
? [{ name: 'child.ts', kind: 'file', ignored: false }]
|
|
: [];
|
|
await route.fulfill({
|
|
json: { kind: 'list', path, entries, truncated: path === '.' },
|
|
});
|
|
return;
|
|
}
|
|
if (/^(?:\/workspaces\/[^/]+)?\/stat$/.test(url.pathname)) {
|
|
await route.fulfill({
|
|
json: {
|
|
kind: 'stat',
|
|
path,
|
|
type: 'file',
|
|
sizeBytes: buffers[path]?.length ?? 0,
|
|
modifiedMs: 1,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (/^(?:\/workspaces\/[^/]+)?\/file\/bytes$/.test(url.pathname)) {
|
|
byteReads.push(`${url.pathname}?path=${path}`);
|
|
const buffer = buffers[path];
|
|
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
const chunk = buffer?.subarray(
|
|
offset,
|
|
offset + Number(url.searchParams.get('maxBytes') ?? buffer.length),
|
|
);
|
|
await route.fulfill(
|
|
buffer
|
|
? {
|
|
json: {
|
|
path,
|
|
contentBase64: chunk!.toString('base64'),
|
|
offset,
|
|
returnedBytes: chunk!.length,
|
|
sizeBytes: buffer.length,
|
|
},
|
|
}
|
|
: { status: 404, json: { error: 'No such file' } },
|
|
);
|
|
return;
|
|
}
|
|
if (/^(?:\/workspaces\/[^/]+)?\/file$/.test(url.pathname)) {
|
|
const content = contents[path];
|
|
await route.fulfill(
|
|
content === undefined
|
|
? { status: 404, json: { error: 'No such file' } }
|
|
: {
|
|
json: {
|
|
kind: 'file',
|
|
path,
|
|
content: buffers[path]!.subarray(
|
|
0,
|
|
Number(
|
|
url.searchParams.get('maxBytes') ?? buffers[path]!.length,
|
|
),
|
|
).toString('utf8'),
|
|
encoding: 'utf-8',
|
|
truncated:
|
|
buffers[path]!.length >
|
|
Number(
|
|
url.searchParams.get('maxBytes') ?? buffers[path]!.length,
|
|
),
|
|
sizeBytes: buffers[path]!.length,
|
|
},
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
await route.fallback();
|
|
});
|
|
await page.goto(`/session/${scenario.sessionId}?language=en`);
|
|
await expect(
|
|
page.getByText('Workspace ready.', { exact: true }),
|
|
).toBeVisible();
|
|
await page.getByTestId('chat-header-workspace').click();
|
|
await afterOpen?.();
|
|
const browser = page.getByTestId('workspace-file-browser');
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeVisible();
|
|
await expect(browser.getByTestId('workspace-file-content')).toBeHidden();
|
|
await expect(browser.getByRole('separator')).toBeHidden();
|
|
const initialTreeBox = (await browser
|
|
.getByTestId('workspace-file-sidebar')
|
|
.boundingBox())!;
|
|
const initialBrowserBox = (await browser.boundingBox())!;
|
|
expect(initialTreeBox.width).toBeCloseTo(initialBrowserBox.width, 0);
|
|
return {
|
|
browser,
|
|
directoryReads,
|
|
byteReads,
|
|
contentReads,
|
|
scenario,
|
|
addRootFile: () => {
|
|
rootUpdated = true;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('@smoke reads a secondary workspace through qualified content endpoints', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, contentReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
'/tmp/secondary',
|
|
undefined,
|
|
false,
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click();
|
|
await expect(
|
|
browser.getByRole('heading', { name: 'Workspace guide' }),
|
|
).toBeVisible();
|
|
await browser
|
|
.getByRole('treeitem', { name: 'image.png', exact: true })
|
|
.click();
|
|
await expect(
|
|
browser.getByRole('img', { name: 'image.png', exact: true }),
|
|
).toBeVisible();
|
|
for (const suffix of ['/stat', '/file', '/file/bytes'])
|
|
expect(contentReads).toContain('/workspaces/%2Ftmp%2Fsecondary' + suffix);
|
|
expect(
|
|
contentReads.every((path) =>
|
|
path.startsWith('/workspaces/%2Ftmp%2Fsecondary/'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('@smoke opens an Excel preview in a separate workspace file tab', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, contentReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
'/tmp/secondary',
|
|
undefined,
|
|
false,
|
|
);
|
|
const workbook = browser.getByRole('treeitem', {
|
|
name: 'report.xlsx',
|
|
exact: true,
|
|
});
|
|
await workbook.click();
|
|
await expect(browser.locator('[data-web-shell-excel-preview]')).toContainText(
|
|
'Files workbook',
|
|
);
|
|
const initialReadCount = contentReads.length;
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
await workbook.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click();
|
|
await expect(
|
|
page.getByRole('tab', { name: 'report.xlsx', exact: true }),
|
|
).toHaveCount(1);
|
|
await expect(browser).toBeHidden();
|
|
const preview = page
|
|
.locator('[data-web-shell-excel-preview]')
|
|
.filter({ visible: true });
|
|
await expect(preview).toContainText('Files workbook');
|
|
await expect(preview.getByRole('tab', { name: 'Summary' })).toBeVisible();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await expect(browser).toBeVisible();
|
|
await expect(
|
|
browser.locator('[data-web-shell-excel-preview]'),
|
|
).toContainText('Files workbook');
|
|
}
|
|
expect(contentReads.length).toBeGreaterThan(initialReadCount);
|
|
expect(
|
|
contentReads.every((path) =>
|
|
path.startsWith('/workspaces/%2Ftmp%2Fsecondary/'),
|
|
),
|
|
).toBe(true);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'legacy.xls', exact: true })
|
|
.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Preview', exact: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
for (const module of ['WorkspaceFileBrowser', 'PierreFilePreview']) {
|
|
test(`@smoke contains ${module} import failures and reloads successfully`, async ({
|
|
page,
|
|
}, testInfo) => {
|
|
let blocked = true;
|
|
let attempts = 0;
|
|
await page.route(`**/${module}.tsx*`, async (route) => {
|
|
attempts++;
|
|
if (blocked) await route.abort('failed');
|
|
else await route.continue();
|
|
});
|
|
const recover = async () => {
|
|
const panel = page.locator('aside[aria-label="Right panel"]');
|
|
await expect(panel.getByRole('alert')).toBeVisible();
|
|
await expect(
|
|
panel.getByRole('button', { name: 'Close Files', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('.cm-content[contenteditable="true"]'),
|
|
).toHaveCount(1);
|
|
if (module === 'PierreFilePreview') {
|
|
await expect(
|
|
panel.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeVisible();
|
|
}
|
|
blocked = false;
|
|
await Promise.all([
|
|
page.waitForEvent('load'),
|
|
panel.getByRole('button', { name: 'Reload page', exact: true }).click(),
|
|
]);
|
|
await expect(page.getByTestId('workspace-file-browser')).toBeVisible();
|
|
};
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
'/tmp/qwen-web-shell-e2e',
|
|
module === 'WorkspaceFileBrowser' ? recover : undefined,
|
|
);
|
|
const openSource = async () => {
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click();
|
|
await browser
|
|
.getByRole('button', { name: 'Source', exact: true })
|
|
.click();
|
|
};
|
|
await openSource();
|
|
if (module === 'PierreFilePreview') {
|
|
await recover();
|
|
await openSource();
|
|
}
|
|
await expect(browser.getByTestId('pierre-file-preview')).toContainText(
|
|
'# Workspace guide',
|
|
);
|
|
expect(attempts).toBeGreaterThan(1);
|
|
});
|
|
}
|
|
|
|
for (const fullscreen of [false, true]) {
|
|
test(`@smoke tabs through the file tree in a ${fullscreen ? 'fullscreen ' : ''}drawer`, async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
await page.setViewportSize({ width: 430, height: 900 });
|
|
await expect(page.locator('[data-slot="drawer-content"]')).toBeVisible();
|
|
if (fullscreen)
|
|
await page
|
|
.getByRole('button', { name: 'Fullscreen', exact: true })
|
|
.click();
|
|
if (fullscreen)
|
|
await expect(
|
|
page.getByRole('button', { name: 'Exit fullscreen', exact: true }),
|
|
).toBeVisible();
|
|
const filter = browser.getByRole('textbox', { name: 'Filter files…' });
|
|
await page
|
|
.locator('[data-slot="drawer-content"]')
|
|
.evaluate(async (node) => {
|
|
await Promise.all(
|
|
node.getAnimations().map((animation) => animation.finished),
|
|
);
|
|
});
|
|
const row = browser.getByRole('treeitem', { name: 'broken', exact: true });
|
|
await filter.focus();
|
|
await page.keyboard.press('Tab');
|
|
await expect(row).toBeFocused();
|
|
await page.keyboard.press('Shift+Tab');
|
|
await expect(filter).toBeFocused();
|
|
});
|
|
}
|
|
|
|
test('@smoke filters loaded paths without changing input or fetching directories', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, directoryReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
const tree = browser.getByTestId('workspace-file-sidebar');
|
|
await expect(
|
|
tree.getByRole('tree', { name: 'Files', exact: true }),
|
|
).toBeVisible();
|
|
const filter = tree.getByRole('textbox', { name: 'Filter files…' });
|
|
const initialReadCount = directoryReads.length;
|
|
await filter.fill('Read ');
|
|
await expect(filter).toHaveValue('Read ');
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeVisible();
|
|
await filter.fill('not-a-loaded-path');
|
|
await expect(
|
|
tree.getByRole('status').filter({ hasText: 'No matches in loaded paths' }),
|
|
).toBeVisible();
|
|
await expect(tree.getByRole('treeitem')).toHaveCount(0);
|
|
await filter.fill('src');
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'src', exact: true }),
|
|
).toBeVisible();
|
|
await page.waitForTimeout(300);
|
|
expect(
|
|
directoryReads.every(
|
|
(read) => read === '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/list?path=.',
|
|
),
|
|
).toBe(true);
|
|
expect(directoryReads.length).toBe(initialReadCount);
|
|
await filter.fill('');
|
|
await tree.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'app.ts', exact: true }),
|
|
).toBeVisible();
|
|
await filter.fill('APP');
|
|
await expect(filter).toHaveValue('APP');
|
|
await filter.fill('src\\APP');
|
|
await expect(filter).toHaveValue('src\\APP');
|
|
await tree.getByRole('treeitem', { name: 'app.ts', exact: true }).click();
|
|
await expect(filter).toHaveValue('');
|
|
for (const mode of ['dock', 'fullscreen', 'drawer']) {
|
|
if (mode === 'fullscreen') {
|
|
await page
|
|
.getByRole('button', { name: 'Fullscreen', exact: true })
|
|
.click();
|
|
} else if (mode === 'drawer') {
|
|
await page.setViewportSize({ width: 430, height: 900 });
|
|
await expect(
|
|
page.getByRole('dialog', { name: 'Right panel', exact: true }),
|
|
).toBeVisible();
|
|
}
|
|
await filter.fill('not-a-loaded-path');
|
|
await expect(tree.getByRole('treeitem')).toHaveCount(0);
|
|
for (const isComposing of [true, false]) {
|
|
await filter.dispatchEvent('keydown', {
|
|
key: 'Escape',
|
|
isComposing,
|
|
keyCode: 229,
|
|
});
|
|
await expect(filter).toHaveValue('not-a-loaded-path');
|
|
}
|
|
await expect(filter).toHaveValue('not-a-loaded-path');
|
|
await expect(browser).toBeVisible();
|
|
await filter.press('Escape');
|
|
await expect(filter).toHaveValue('');
|
|
await expect(browser).toBeVisible();
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'src', exact: true }),
|
|
).toBeVisible();
|
|
if (mode === 'fullscreen') {
|
|
await expect(
|
|
page.getByRole('button', { name: 'Exit fullscreen', exact: true }),
|
|
).toBeVisible();
|
|
await filter.press('Escape');
|
|
await expect(
|
|
page.getByRole('button', { name: 'Fullscreen', exact: true }),
|
|
).toBeVisible();
|
|
} else if (mode === 'drawer') {
|
|
await filter.press('Escape');
|
|
await expect(browser).toBeHidden();
|
|
}
|
|
}
|
|
});
|
|
|
|
test('@smoke browses files with Pierre, previews Markdown and source, and restores the workspace tab', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, directoryReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
const tree = browser.getByTestId('workspace-file-sidebar');
|
|
const content = browser.getByTestId('workspace-file-content');
|
|
await tree.getByRole('treeitem', { name: 'README.md', exact: true }).click();
|
|
await expect(
|
|
content.getByRole('heading', { name: 'Workspace guide' }),
|
|
).toBeVisible();
|
|
const contentBox = await content.boundingBox();
|
|
const treeBox = await tree.boundingBox();
|
|
expect(contentBox!.x + contentBox!.width).toBeLessThanOrEqual(treeBox!.x + 1);
|
|
|
|
await browser.getByRole('button', { name: 'Source', exact: true }).click();
|
|
await expect(content.getByTestId('pierre-file-preview')).toContainText(
|
|
'# Workspace guide',
|
|
);
|
|
await browser.getByRole('button', { name: 'Preview', exact: true }).click();
|
|
await expect(
|
|
content.getByRole('heading', { name: 'Workspace guide' }),
|
|
).toBeVisible();
|
|
await tree.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
await tree.getByRole('treeitem', { name: 'app.ts', exact: true }).click();
|
|
await expect(content.getByTestId('pierre-file-preview')).toContainText(
|
|
'Hello, files',
|
|
);
|
|
await expect(content.locator('[contenteditable="true"]')).toHaveCount(0);
|
|
await expect(content.locator('[data-line] span[style]')).not.toHaveCount(0);
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'app.ts', exact: true }),
|
|
).toBeFocused();
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'folder-29', exact: true }),
|
|
).toBeFocused();
|
|
await page.keyboard.press('ArrowDown');
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'app.ts', exact: true }),
|
|
).toBeFocused();
|
|
|
|
await tree.locator('[data-file-tree-virtualized-scroll]').evaluate((node) => {
|
|
node.scrollTop = 0;
|
|
});
|
|
await tree.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
await tree.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
expect(
|
|
directoryReads.filter((read) => read.endsWith('path=src')),
|
|
).toHaveLength(1);
|
|
expect(directoryReads.every((read) => read.startsWith('/workspaces/'))).toBe(
|
|
true,
|
|
);
|
|
await tree.getByRole('textbox', { name: 'Filter files…' }).fill('app');
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'app.ts', exact: true }),
|
|
).toBeVisible();
|
|
await tree.getByRole('treeitem', { name: 'app.ts', exact: true }).click();
|
|
await expect(
|
|
tree.getByRole('textbox', { name: 'Filter files…' }),
|
|
).toHaveValue('');
|
|
await browser
|
|
.getByRole('button', { name: 'Toggle file tree', exact: true })
|
|
.click();
|
|
await expect(tree).toBeHidden();
|
|
await expect(content.getByTestId('pierre-file-preview')).toBeVisible();
|
|
await browser
|
|
.getByRole('button', { name: 'Toggle file tree', exact: true })
|
|
.click();
|
|
await expect(tree).toBeVisible();
|
|
await page.reload();
|
|
await expect(page.getByTestId('workspace-file-browser')).toBeVisible();
|
|
await expect(
|
|
page
|
|
.getByTestId('workspace-file-browser')
|
|
.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('tab', { name: 'Files', exact: true }),
|
|
).toHaveCount(1);
|
|
});
|
|
|
|
test('retains the expanded tree, selected source and scroll position across tab switches', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, directoryReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
const tree = browser.getByTestId('workspace-file-sidebar');
|
|
const content = browser.getByTestId('workspace-file-content');
|
|
await tree.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'app.ts', exact: true }),
|
|
).toBeVisible();
|
|
await tree.getByRole('treeitem', { name: 'guide.md', exact: true }).click();
|
|
await browser.getByRole('button', { name: 'Source', exact: true }).click();
|
|
const source = content.getByTestId('pierre-file-preview');
|
|
await expect(source).toContainText('# Nested guide');
|
|
const separator = browser.getByRole('separator', {
|
|
name: 'Resize file preview and tree',
|
|
});
|
|
await expect
|
|
.poll(async () => (await tree.boundingBox())!.width)
|
|
.toBeLessThan((await browser.boundingBox())!.width * 0.5);
|
|
const beforeWidth = (await tree.boundingBox())!.width;
|
|
const handleBox = (await separator.boundingBox())!;
|
|
await page.mouse.move(handleBox.x, handleBox.y + 50);
|
|
await page.mouse.down();
|
|
await page.mouse.move(handleBox.x - 80, handleBox.y + 50, { steps: 8 });
|
|
await page.mouse.up();
|
|
await expect
|
|
.poll(async () => (await tree.boundingBox())!.width)
|
|
.toBeGreaterThan(beforeWidth + 50);
|
|
const draggedWidth = (await tree.boundingBox())!.width;
|
|
await separator.focus();
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect
|
|
.poll(async () => (await tree.boundingBox())!.width)
|
|
.toBeLessThan(draggedWidth - 1);
|
|
const resizedWidth = (await tree.boundingBox())!.width;
|
|
expect(resizedWidth).toBeGreaterThan(150);
|
|
const scroll = tree.locator('[data-file-tree-virtualized-scroll]');
|
|
const scrollTop = await scroll.evaluate((node) => node.scrollTop);
|
|
expect(scrollTop).toBeGreaterThan(0);
|
|
const readsBefore = directoryReads.length;
|
|
await tree
|
|
.getByRole('treeitem', { name: 'guide.md', exact: true })
|
|
.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click();
|
|
await expect(browser).toBeHidden();
|
|
await expect(
|
|
page.getByRole('heading', { name: 'Nested guide' }),
|
|
).toBeVisible();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await expect(source).toContainText('# Nested guide');
|
|
await expect(
|
|
browser.getByRole('button', { name: 'Preview', exact: true }),
|
|
).toBeVisible();
|
|
expect(await scroll.evaluate((node) => node.scrollTop)).toBe(scrollTop);
|
|
expect(directoryReads).toHaveLength(readsBefore);
|
|
expect((await tree.boundingBox())!.width).toBeCloseTo(resizedWidth, 0);
|
|
expect(
|
|
await tree.evaluate((node) => getComputedStyle(node).paddingBottom),
|
|
).toBe('12px');
|
|
await scroll.evaluate((node) => {
|
|
node.scrollTop = 0;
|
|
});
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'src', exact: true }),
|
|
).toHaveAttribute('aria-expanded', 'true');
|
|
await tree.getByRole('textbox', { name: 'Filter files…' }).fill('app');
|
|
await page.getByRole('tab', { name: 'src/guide.md', exact: true }).click();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await expect(
|
|
tree.getByRole('textbox', { name: 'Filter files…' }),
|
|
).toHaveValue('app');
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'app.ts', exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('@smoke handles empty folders, directory errors, truncation, refresh, and narrow panels', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, directoryReads, addRootFile } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
const tree = browser.getByTestId('workspace-file-sidebar');
|
|
await expect(
|
|
tree.getByText('Only the first 2,000 entries are shown'),
|
|
).toBeVisible();
|
|
await tree.getByRole('treeitem', { name: 'empty', exact: true }).click();
|
|
await expect(tree.getByText('Empty folder', { exact: true })).toBeVisible();
|
|
await tree.getByRole('treeitem', { name: 'broken', exact: true }).click();
|
|
await expect(tree.getByRole('alert')).toContainText(
|
|
'Directory temporarily unavailable',
|
|
);
|
|
const failedReadCount = directoryReads.filter((read) =>
|
|
read.endsWith('path=broken'),
|
|
).length;
|
|
await tree.getByRole('button', { name: 'Try again', exact: true }).click();
|
|
await expect(tree.getByRole('alert')).toHaveCount(0);
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'recovered.ts', exact: true }),
|
|
).toBeVisible();
|
|
expect(
|
|
directoryReads.filter((read) => read.endsWith('path=broken')),
|
|
).toHaveLength(failedReadCount + 1);
|
|
await tree.getByRole('treeitem', { name: 'linked', exact: true }).click();
|
|
await expect(browser.getByTestId('workspace-file-content')).toContainText(
|
|
'No such file',
|
|
);
|
|
expect(directoryReads.some((read) => read.endsWith('path=linked'))).toBe(
|
|
false,
|
|
);
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'new.txt', exact: true }),
|
|
).toHaveCount(0);
|
|
addRootFile();
|
|
await browser.getByRole('button', { name: 'Refresh', exact: true }).click();
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'new.txt', exact: true }),
|
|
).toBeVisible();
|
|
await page.setViewportSize({ width: 430, height: 900 });
|
|
await expect(
|
|
page.getByRole('dialog', { name: 'Right panel', exact: true }),
|
|
).toBeVisible();
|
|
await tree.getByRole('treeitem', { name: 'README.md', exact: true }).click();
|
|
await expect(
|
|
browser.getByRole('button', { name: 'Toggle file tree', exact: true }),
|
|
).toBeVisible();
|
|
await browser
|
|
.getByRole('button', { name: 'Toggle file tree', exact: true })
|
|
.click();
|
|
await expect(tree).toBeHidden();
|
|
await expect(
|
|
browser.getByRole('button', { name: 'Toggle file tree', exact: true }),
|
|
).toBeInViewport();
|
|
});
|
|
|
|
test('virtualizes a long source file and keeps its final lines reachable', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await browser.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
await browser
|
|
.getByRole('textbox', { name: 'Filter files…', exact: true })
|
|
.fill('src/long.ts');
|
|
await browser.getByRole('treeitem', { name: 'long.ts', exact: true }).click();
|
|
const source = browser.getByTestId('pierre-file-preview');
|
|
await expect(source).toContainText('export const line0');
|
|
expect(await source.locator('[data-line]').count()).toBeGreaterThan(0);
|
|
expect(await source.locator('[data-line]').count()).toBeLessThan(500);
|
|
await source.evaluate((element) => {
|
|
element.scrollTop = element.scrollHeight;
|
|
});
|
|
await expect(source).toContainText('export const line4999');
|
|
const scrollTop = await source.evaluate((node) => node.scrollTop);
|
|
await page.getByRole('tab', { name: 'README.md', exact: true }).click();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await expect(source).toContainText('export const line4999');
|
|
expect(await source.evaluate((node) => node.scrollTop)).toBe(scrollTop);
|
|
});
|
|
|
|
test('@smoke keeps large source files readable without tokenizing the entire text', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, byteReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
await browser.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
await browser
|
|
.getByRole('treeitem', { name: 'large.ts', exact: true })
|
|
.click();
|
|
const source = browser.getByTestId('pierre-file-preview');
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
byteReads.filter((path) => path.endsWith('path=src/large.ts')).length,
|
|
)
|
|
.toBeGreaterThan(1);
|
|
await expect(source).toContainText('export const large0');
|
|
await expect(source.locator('[data-line] span[style]')).toHaveCount(0);
|
|
expect(await source.locator('[data-line]').count()).toBeGreaterThan(0);
|
|
expect(await source.locator('[data-line]').count()).toBeLessThan(500);
|
|
});
|
|
|
|
for (const [cwd, absolutePath] of [
|
|
['/tmp/work\\space\\', '/tmp/work\\space\\/README.md'],
|
|
['C:\\work\\space\\', 'C:\\work\\space\\README.md'],
|
|
['\\\\server\\share\\work', '\\\\server\\share\\work\\README.md'],
|
|
]) {
|
|
test(`copies absolute paths using the workspace platform (${cwd})`, async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
writeText: async (text: string) => {
|
|
(window as unknown as { copied: string }).copied = text;
|
|
},
|
|
},
|
|
});
|
|
});
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
cwd,
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click({ button: 'right' });
|
|
await page
|
|
.getByRole('menuitem', { name: 'Copy absolute path', exact: true })
|
|
.click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => (window as unknown as { copied: string }).copied),
|
|
)
|
|
.toBe(absolutePath);
|
|
});
|
|
}
|
|
|
|
test('offers file context actions, previews images and HTML, and references a file in the composer', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const pageErrors: string[] = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
writeText: async (text: string) => {
|
|
(window as unknown as { copied: string }).copied = text;
|
|
},
|
|
},
|
|
});
|
|
});
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
const content = browser.getByTestId('workspace-file-content');
|
|
const rightClick = async (name: string) => {
|
|
await browser
|
|
.getByRole('treeitem', { name, exact: true })
|
|
.and(browser.locator(`[data-item-path="${name}"]`))
|
|
.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Copy relative path', exact: true }),
|
|
).toBeVisible();
|
|
};
|
|
await rightClick('README.md');
|
|
await page
|
|
.getByRole('menuitem', { name: 'Copy relative path', exact: true })
|
|
.click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => (window as unknown as { copied: string }).copied),
|
|
)
|
|
.toBe('README.md');
|
|
await rightClick('README.md');
|
|
await expect(
|
|
page.getByRole('menu', { name: 'Files: README.md', exact: true }),
|
|
).toBeVisible();
|
|
await page
|
|
.getByRole('menuitem', { name: 'Copy absolute path', exact: true })
|
|
.click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => (window as unknown as { copied: string }).copied),
|
|
)
|
|
.toBe('/tmp/qwen-web-shell-e2e/README.md');
|
|
await rightClick('README.md');
|
|
const downloadPromise = page.waitForEvent('download');
|
|
await page.getByRole('menuitem', { name: 'Download', exact: true }).click();
|
|
const download = await downloadPromise;
|
|
expect(download.suggestedFilename()).toBe('README.md');
|
|
expect(await readFile((await download.path())!, 'utf8')).toContain(
|
|
'# Workspace guide',
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'image.png', exact: true })
|
|
.click();
|
|
const image = content.getByRole('img', { name: 'image.png', exact: true });
|
|
await expect(image).toBeVisible();
|
|
await expect
|
|
.poll(() => image.evaluate((node: HTMLImageElement) => node.naturalWidth))
|
|
.toBe(1);
|
|
await rightClick('image.png');
|
|
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click();
|
|
await expect(
|
|
page.getByRole('tab', { name: 'image.png', exact: true }),
|
|
).toHaveAttribute('aria-selected', 'true');
|
|
await expect(
|
|
page.getByRole('img', { name: 'image.png', exact: true }),
|
|
).toBeVisible();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await rightClick('index.html');
|
|
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click();
|
|
await expect(
|
|
page
|
|
.frameLocator('iframe[title="Preview index.html"]')
|
|
.frameLocator('iframe')
|
|
.getByRole('heading', { name: 'Workspace HTML' }),
|
|
).toBeVisible();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await rightClick('README.md');
|
|
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click();
|
|
await expect(
|
|
page.getByRole('heading', { name: 'Workspace guide' }),
|
|
).toBeVisible();
|
|
await page.getByRole('tab', { name: 'Files', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Fullscreen', exact: true }).click();
|
|
const treeFocusRow = browser.locator('[role="treeitem"][tabindex="0"]');
|
|
await browser.getByRole('textbox', { name: 'Filter files…' }).click();
|
|
await page.keyboard.press('Tab');
|
|
await expect(treeFocusRow).toBeFocused();
|
|
await page.keyboard.press('Tab');
|
|
await expect(
|
|
page.getByRole('tab', { name: 'Files', exact: true }),
|
|
).toBeFocused();
|
|
await page.keyboard.press('Shift+Tab');
|
|
await expect(treeFocusRow).toBeFocused();
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeFocused();
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(
|
|
browser.locator('[role="treeitem"][data-item-path="notes draft.md"]'),
|
|
).toBeFocused();
|
|
await rightClick('notes draft.md');
|
|
await page.getByRole('menuitem', { name: 'Reference', exact: true }).click();
|
|
const editor = page.locator('.cm-content[contenteditable="true"]');
|
|
await expect(editor).toContainText('notes draft.md');
|
|
await expect(editor).toBeFocused();
|
|
await rightClick('README.md');
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.getByRole('menu')).toHaveCount(0);
|
|
await expect
|
|
.poll(() =>
|
|
browser
|
|
.getByRole('tree')
|
|
.evaluate((node) => node.matches(':focus-within')),
|
|
)
|
|
.toBe(true);
|
|
expect(pageErrors).toEqual([]);
|
|
});
|
|
|
|
for (const fullscreen of [false, true]) {
|
|
test(`returns to the composer after referencing a file from a ${fullscreen ? 'fullscreen ' : ''}drawer @smoke`, async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
await page.setViewportSize({ width: 430, height: 1000 });
|
|
const drawer = page.getByRole('dialog', {
|
|
name: 'Right panel',
|
|
exact: true,
|
|
});
|
|
await expect(drawer).toBeVisible();
|
|
if (fullscreen) {
|
|
await drawer
|
|
.getByRole('button', { name: 'Fullscreen', exact: true })
|
|
.click();
|
|
}
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click({ button: 'right' });
|
|
await page
|
|
.getByRole('menuitem', { name: 'Reference', exact: true })
|
|
.click();
|
|
await expect(drawer).toHaveCount(0);
|
|
const editor = page.locator('.cm-content[contenteditable="true"]');
|
|
await expect(editor).toContainText('README.md');
|
|
await expect(editor).toBeFocused();
|
|
await editor.click({ trial: true });
|
|
await page.keyboard.type('continue typing', { delay: 50 });
|
|
await expect(editor).toContainText('continue typing');
|
|
await expect(editor).toBeFocused();
|
|
});
|
|
}
|
|
|
|
test('scrolls the entire deep tree horizontally without squeezing names', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
const tree = browser.getByTestId('workspace-file-sidebar');
|
|
await tree.getByRole('treeitem', { name: 'README.md', exact: true }).click();
|
|
await tree.getByRole('treeitem', { name: 'deep', exact: true }).click();
|
|
for (let index = 0; index < 14; index++) {
|
|
await tree
|
|
.getByRole('treeitem', { name: `level-${index}`, exact: true })
|
|
.click();
|
|
}
|
|
const leaf = tree.getByRole('treeitem', {
|
|
name: 'a-long-filename-that-must-stay-readable-at-deep-levels.ts',
|
|
exact: true,
|
|
});
|
|
await expect(leaf).toBeAttached();
|
|
const scroll = tree.locator('[data-file-tree-virtualized-scroll]');
|
|
await expect
|
|
.poll(() => scroll.evaluate((node) => node.scrollWidth - node.clientWidth))
|
|
.toBeGreaterThan(200);
|
|
await scroll.evaluate((node) => {
|
|
node.scrollTop = node.scrollHeight;
|
|
node.scrollLeft = node.scrollWidth;
|
|
});
|
|
await expect
|
|
.poll(() => scroll.evaluate((node) => node.scrollLeft))
|
|
.toBeGreaterThan(200);
|
|
await expect(leaf).toBeInViewport();
|
|
const name = leaf.locator('[data-item-section="content"]');
|
|
expect(
|
|
await name.evaluate((node) => node.scrollWidth <= node.clientWidth + 1),
|
|
).toBe(true);
|
|
const leafBox = (await name.boundingBox())!;
|
|
const treeBox = (await tree.boundingBox())!;
|
|
expect(leafBox.x + leafBox.width).toBeLessThanOrEqual(
|
|
treeBox.x + treeBox.width,
|
|
);
|
|
|
|
await leaf.click();
|
|
await expect(browser.getByTestId('workspace-file-content')).toContainText(
|
|
'export const deep = true',
|
|
);
|
|
await leaf.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Copy relative path', exact: true }),
|
|
).toBeVisible();
|
|
await page.keyboard.press('Escape');
|
|
await expect(leaf).toBeFocused();
|
|
await page.keyboard.press('ArrowUp');
|
|
await expect(
|
|
tree.getByRole('treeitem', { name: 'level-13', exact: true }),
|
|
).toBeFocused();
|
|
await page.keyboard.press('ArrowDown');
|
|
await expect(leaf).toBeFocused();
|
|
});
|
|
|
|
test('@smoke cleans tree labels while keeping distinct raw file and directory paths', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
writeText: async (text: string) => {
|
|
(window as unknown as { copied: string }).copied = text;
|
|
},
|
|
},
|
|
});
|
|
});
|
|
const { browser, directoryReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
const rows = browser.getByRole('treeitem', {
|
|
name: 'control-file.ts',
|
|
exact: true,
|
|
});
|
|
await expect(rows).toHaveCount(2);
|
|
const identities = await rows.evaluateAll((nodes) =>
|
|
nodes.map((node) => node.getAttribute('data-item-path')),
|
|
);
|
|
expect(new Set(identities)).toEqual(
|
|
new Set([controlledName, 'control-file.ts']),
|
|
);
|
|
for (let index = 0; index < 2; index++) {
|
|
const row = rows.nth(index);
|
|
const path = identities[index]!;
|
|
await expect(row).toHaveAttribute('aria-label', 'control-file.ts');
|
|
for (const character of [
|
|
'\u0001',
|
|
'\u001b',
|
|
'\u202e',
|
|
'\u00ad',
|
|
'\u2060',
|
|
'\u{e0041}',
|
|
])
|
|
expect(await row.textContent()).not.toContain(character);
|
|
await row.click();
|
|
await expect(browser.getByTestId('pierre-file-preview')).toContainText(
|
|
path === controlledName ? 'originalPath' : 'ordinaryPath',
|
|
);
|
|
await row.click({ button: 'right' });
|
|
await page
|
|
.getByRole('menuitem', { name: 'Copy relative path', exact: true })
|
|
.click();
|
|
expect(
|
|
await page.evaluate(
|
|
() => (window as unknown as { copied: string }).copied,
|
|
),
|
|
).toBe(path);
|
|
await row.click({ button: 'right' });
|
|
await page
|
|
.getByRole('menuitem', { name: 'Copy absolute path', exact: true })
|
|
.click();
|
|
expect(
|
|
await page.evaluate(
|
|
() => (window as unknown as { copied: string }).copied,
|
|
),
|
|
).toBe(`/tmp/qwen-web-shell-e2e/${path}`);
|
|
}
|
|
const filter = browser.getByRole('textbox', {
|
|
name: 'Filter files…',
|
|
exact: true,
|
|
});
|
|
await filter.fill('control-file.ts');
|
|
await expect(rows).toHaveCount(2);
|
|
await expect(browser.getByText('No matches in loaded paths')).toBeHidden();
|
|
await filter.fill('notes draft.md');
|
|
const spaced = browser.locator(
|
|
'[role="treeitem"][data-item-path="notes draft.md"]',
|
|
);
|
|
await expect(spaced).toHaveAttribute('aria-label', 'notes draft.md');
|
|
expect(
|
|
await spaced
|
|
.locator('[data-truncate-content="visible"]')
|
|
.evaluateAll((nodes) => nodes.map((node) => node.textContent).join('')),
|
|
).toBe('notes draft.md');
|
|
expect(
|
|
await spaced
|
|
.locator('[data-truncate-content="visible"]')
|
|
.first()
|
|
.evaluate((node) => getComputedStyle(node).whiteSpace),
|
|
).toBe('pre');
|
|
await filter.fill('');
|
|
await browser.getByRole('treeitem', { name: 'folder-', exact: true }).click();
|
|
await browser
|
|
.getByRole('treeitem', { name: 'child.ts', exact: true })
|
|
.click();
|
|
await expect(browser.getByTestId('pierre-file-preview')).toContainText(
|
|
'nestedOriginal',
|
|
);
|
|
expect(
|
|
directoryReads.some((read) => read.endsWith(`path=${controlledDirectory}`)),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('@smoke closes inline content to a full-width tree and reopens the same file', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const { browser, directoryReads } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
await browser.getByRole('treeitem', { name: 'folder-', exact: true }).click();
|
|
const file = browser.getByRole('treeitem', { name: 'child.ts', exact: true });
|
|
const tree = browser.getByTestId('workspace-file-sidebar');
|
|
const content = browser.getByTestId('workspace-file-content');
|
|
for (const hideTree of [false, true]) {
|
|
await file.click();
|
|
await expect(content).toBeVisible();
|
|
if (hideTree)
|
|
await browser
|
|
.getByRole('button', { name: 'Toggle file tree', exact: true })
|
|
.click();
|
|
await browser
|
|
.getByRole('button', { name: 'Close file preview', exact: true })
|
|
.click();
|
|
await expect(content).toBeHidden();
|
|
await expect(browser.getByRole('separator')).toBeHidden();
|
|
await expect(file).toBeVisible();
|
|
await expect(
|
|
browser.getByRole('textbox', { name: 'Filter files…', exact: true }),
|
|
).toBeFocused();
|
|
await expect
|
|
.poll(async () => (await tree.boundingBox())!.width)
|
|
.toBeCloseTo((await browser.boundingBox())!.width, 0);
|
|
}
|
|
expect(
|
|
directoryReads.filter((read) =>
|
|
read.endsWith(`path=${controlledDirectory}`),
|
|
),
|
|
).toHaveLength(1);
|
|
await file.click();
|
|
await expect(browser.getByTestId('pierre-file-preview')).toContainText(
|
|
'nestedOriginal',
|
|
);
|
|
});
|
|
|
|
test('@smoke renders unsupported source as plain text on a cold load', async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const errors: string[] = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
const { browser } = await openFiles(
|
|
page,
|
|
String(testInfo.project.use.baseURL),
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'unsupported.lean', exact: true })
|
|
.click();
|
|
const source = browser.getByTestId('pierre-file-preview');
|
|
await expect(source).toContainText('def unsupported := 42');
|
|
await expect(source.locator('[data-line] span[style]')).toHaveCount(0);
|
|
expect(await source.locator('[data-line]').count()).toBeGreaterThan(0);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test('@smoke creates root files and folders with toolbar actions', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
true,
|
|
);
|
|
await expect(
|
|
browser
|
|
.getByRole('button', { name: 'Refresh', exact: true })
|
|
.locator('svg'),
|
|
).toHaveClass(/lucide-rotate-cw/);
|
|
await expect(
|
|
browser
|
|
.getByRole('button', { name: 'New file', exact: true })
|
|
.locator('svg'),
|
|
).toHaveClass(/lucide-sticky-note-plus/);
|
|
await browser.getByRole('treeitem', { name: 'deep', exact: true }).click();
|
|
const calls: { url: string; body: unknown }[] = [];
|
|
for (const endpoint of ['file/write', 'directory/create'])
|
|
await page.route(`**/${endpoint}`, async (route) => {
|
|
calls.push({
|
|
url: new URL(route.request().url()).pathname,
|
|
body: route.request().postDataJSON(),
|
|
});
|
|
await route.fulfill({
|
|
status: 201,
|
|
json: { path: route.request().postDataJSON().path },
|
|
});
|
|
});
|
|
for (const [label, name] of [
|
|
['New file', 'root.txt'],
|
|
['New folder', 'root-folder'],
|
|
]) {
|
|
await browser.getByRole('button', { name: label, exact: true }).click();
|
|
const dialog = page.getByRole('dialog', { name: label, exact: true });
|
|
await expect(
|
|
dialog.getByRole('textbox', { name: 'Name', exact: true }),
|
|
).toBeFocused();
|
|
await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill(name);
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name, exact: true }),
|
|
).toBeVisible();
|
|
}
|
|
expect(calls).toEqual([
|
|
{
|
|
url: '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/file/write',
|
|
body: { path: 'root.txt', content: '', mode: 'create' },
|
|
},
|
|
{
|
|
url: '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/directory/create',
|
|
body: { path: 'root-folder' },
|
|
},
|
|
]);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
|
|
).toBeVisible();
|
|
await browser.screenshot({
|
|
path: test.info().outputPath('create-toolbar-completed.png'),
|
|
});
|
|
});
|
|
|
|
test('@smoke folder trailing menu creates inside that folder', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
);
|
|
const folder = browser.getByRole('treeitem', { name: 'src', exact: true });
|
|
const calls: string[] = [];
|
|
for (const endpoint of ['file/write', 'directory/create'])
|
|
await page.route(`**/${endpoint}`, async (route) => {
|
|
calls.push(route.request().postDataJSON().path);
|
|
await route.fulfill({ status: 201, json: { path: calls.at(-1) } });
|
|
});
|
|
for (const [label, name] of [
|
|
['New file', 'nested.txt'],
|
|
['New folder', 'nested-folder'],
|
|
]) {
|
|
await folder.hover();
|
|
await browser.getByRole('button', { name: 'Options', exact: true }).click();
|
|
await expect(
|
|
page.getByRole('menuitem', { name: label, exact: true }),
|
|
).toBeVisible();
|
|
await page
|
|
.getByRole('menu')
|
|
.screenshot({ path: test.info().outputPath('create-folder-menu.png') });
|
|
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
|
const dialog = page.getByRole('dialog', { name: label, exact: true });
|
|
await expect(dialog).toContainText('/tmp/qwen-web-shell-e2e/src');
|
|
await dialog.screenshot({
|
|
path: test.info().outputPath('create-dialog.png'),
|
|
});
|
|
await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill(name);
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
const filter = browser.getByRole('textbox', { name: 'Filter files…' });
|
|
await filter.fill(name);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name, exact: true }),
|
|
).toBeVisible();
|
|
await filter.fill('');
|
|
}
|
|
expect(calls).toEqual(['src/nested.txt', 'src/nested-folder']);
|
|
const filter = browser.getByRole('textbox', { name: 'Filter files…' });
|
|
await filter.fill('app.ts');
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'app.ts', exact: true }),
|
|
).toBeVisible();
|
|
await filter.fill('');
|
|
await folder.focus();
|
|
await page.keyboard.press('Shift+F10');
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'New file', exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('@smoke validates names, cancels and keeps creation errors retryable', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
);
|
|
let calls = 0;
|
|
let finish!: () => void;
|
|
await page.route('**/file/write', async (route) => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
await new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
await route.fulfill({
|
|
status: 409,
|
|
json: { error: 'File already exists' },
|
|
});
|
|
} else
|
|
await route.fulfill({
|
|
status: 201,
|
|
json: { path: route.request().postDataJSON().path },
|
|
});
|
|
});
|
|
await browser.getByRole('button', { name: 'New file', exact: true }).click();
|
|
const dialog = page.getByRole('dialog', { name: 'New file', exact: true });
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('../escape.txt');
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog.getByRole('alert')).toContainText('single name');
|
|
expect(calls).toBe(0);
|
|
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
|
await expect(dialog).toBeHidden();
|
|
expect(calls).toBe(0);
|
|
await browser.getByRole('button', { name: 'New file', exact: true }).click();
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('conflict.txt');
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect.poll(() => calls).toBe(1);
|
|
await expect(
|
|
dialog.getByRole('button', { name: 'Creating…', exact: true }),
|
|
).toBeDisabled();
|
|
await expect(
|
|
dialog.getByRole('button', { name: /^cancel$/i }),
|
|
).toBeDisabled();
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toBeVisible();
|
|
finish();
|
|
await expect(dialog.getByRole('alert')).toContainText('File already exists');
|
|
await expect(
|
|
browser.getByRole('treeitem', {
|
|
name: 'conflict.txt',
|
|
exact: true,
|
|
includeHidden: true,
|
|
}),
|
|
).toHaveCount(0);
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('retry.txt');
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
expect(calls).toBe(2);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'retry.txt', exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('@smoke does not offer creation without capabilities', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(page, baseURL!);
|
|
await expect(
|
|
browser.getByRole('button', { name: 'New file', exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
browser.getByRole('button', { name: 'New folder', exact: true }),
|
|
).toHaveCount(0);
|
|
const folder = browser.getByRole('treeitem', { name: 'src', exact: true });
|
|
await folder.hover();
|
|
await expect(
|
|
browser.getByRole('button', { name: 'Options', exact: true }),
|
|
).toHaveCount(0);
|
|
await folder.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'New file', exact: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test('@smoke merges a late empty listing after creating in a folder', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
);
|
|
let release!: () => void;
|
|
let listingPending = false;
|
|
await page.route('**/list?path=empty', async (route) => {
|
|
listingPending = true;
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await route.fulfill({
|
|
json: { kind: 'list', path: 'empty', entries: [], truncated: false },
|
|
});
|
|
});
|
|
await page.route('**/file/write', (route) =>
|
|
route.fulfill({ status: 201, json: { path: 'empty/new.txt' } }),
|
|
);
|
|
const folder = browser.getByRole('treeitem', { name: 'empty', exact: true });
|
|
await folder.click();
|
|
await expect.poll(() => listingPending).toBe(true);
|
|
await folder.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'New file', exact: true }).click();
|
|
const dialog = page.getByRole('dialog', { name: 'New file', exact: true });
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('new.txt');
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
const listingResponse = page.waitForResponse(
|
|
(response) => new URL(response.url()).searchParams.get('path') === 'empty',
|
|
);
|
|
release();
|
|
await (await listingResponse).finished();
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
|
);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'new.txt', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
browser.getByText('empty: Empty folder', { exact: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test('@smoke restores folder focus after cancelling or completing creation', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
);
|
|
const folder = browser.getByRole('treeitem', { name: 'empty', exact: true });
|
|
const open = async () => {
|
|
await folder.hover();
|
|
await browser.getByRole('button', { name: 'Options', exact: true }).click();
|
|
await page.getByRole('menuitem', { name: 'New file', exact: true }).click();
|
|
};
|
|
await open();
|
|
const dialog = page.getByRole('dialog', { name: 'New file', exact: true });
|
|
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(folder).toBeFocused();
|
|
await page.route('**/file/write', (route) =>
|
|
route.fulfill({ status: 201, json: { path: 'empty/focus.txt' } }),
|
|
);
|
|
await open();
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('focus.txt');
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(folder).toBeFocused();
|
|
await page.keyboard.press('ArrowRight');
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'focus.txt', exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
async function openFolderDelete(
|
|
page: Page,
|
|
browser: ReturnType<Page['getByTestId']>,
|
|
name: string,
|
|
) {
|
|
const folder = browser.getByRole('treeitem', { name, exact: true });
|
|
await folder.hover();
|
|
await browser.getByRole('button', { name: 'Options', exact: true }).click();
|
|
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
|
return page.getByRole('alertdialog', { name: 'Delete folder?', exact: true });
|
|
}
|
|
|
|
test('@smoke confirms recursive folder deletion and preserves other expansion', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
true,
|
|
true,
|
|
);
|
|
const calls: { url: string; body: unknown }[] = [];
|
|
await page.route('**/directory/delete', async (route) => {
|
|
calls.push({
|
|
url: new URL(route.request().url()).pathname,
|
|
body: route.request().postDataJSON(),
|
|
});
|
|
await route.fulfill({ json: { kind: 'directory_delete', path: 'deep' } });
|
|
});
|
|
const src = browser.getByRole('treeitem', { name: 'src', exact: true });
|
|
await src.click();
|
|
await expect(src).toHaveAttribute('aria-expanded', 'true');
|
|
const deep = browser.getByRole('treeitem', { name: 'deep', exact: true });
|
|
await deep.click();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
|
|
).toBeVisible();
|
|
const dialog = await openFolderDelete(page, browser, 'deep');
|
|
await expect(dialog).toContainText(
|
|
'all its contents will be permanently deleted',
|
|
);
|
|
await expect(dialog).toContainText('This action cannot be undone');
|
|
await expect(dialog.getByText('deep', { exact: true })).toBeVisible();
|
|
await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeFocused();
|
|
await expect(dialog).toHaveCSS('opacity', '1');
|
|
await page.screenshot({
|
|
path: test.info().outputPath('folder-delete-confirmation.png'),
|
|
});
|
|
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
|
await expect(deep).toBeFocused();
|
|
expect(calls).toHaveLength(0);
|
|
await openFolderDelete(page, browser, 'deep');
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toBeHidden();
|
|
expect(calls).toHaveLength(0);
|
|
await openFolderDelete(page, browser, 'deep');
|
|
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(deep).toHaveCount(0);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(src).toHaveAttribute('aria-expanded', 'true');
|
|
await expect(
|
|
browser.locator('[role="treeitem"][tabindex="0"]'),
|
|
).toBeFocused();
|
|
expect(calls).toEqual([
|
|
{
|
|
url: '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/directory/delete',
|
|
body: { path: 'deep' },
|
|
},
|
|
]);
|
|
await page.screenshot({
|
|
path: test.info().outputPath('folder-delete-completed.png'),
|
|
});
|
|
});
|
|
|
|
test('@smoke keeps folder deletion busy and failures retryable', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
true,
|
|
);
|
|
let release!: () => void;
|
|
let calls = 0;
|
|
await page.route('**/directory/delete', async (route) => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await route.fulfill({
|
|
status: 500,
|
|
json: { error: 'Folder could not be removed' },
|
|
});
|
|
} else await route.fulfill({ json: { path: 'empty' } });
|
|
});
|
|
const dialog = await openFolderDelete(page, browser, 'empty');
|
|
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await expect(
|
|
dialog.getByRole('button', { name: 'Deleting…', exact: true }),
|
|
).toBeDisabled();
|
|
await expect(
|
|
dialog.getByRole('button', { name: /^cancel$/i }),
|
|
).toBeDisabled();
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toBeVisible();
|
|
expect(calls).toBe(1);
|
|
release();
|
|
await expect(dialog.getByRole('alert')).toContainText(
|
|
'Folder could not be removed',
|
|
);
|
|
await expect(
|
|
browser.getByRole('treeitem', {
|
|
name: 'empty',
|
|
exact: true,
|
|
includeHidden: true,
|
|
}),
|
|
).toHaveCount(1);
|
|
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'empty', exact: true }),
|
|
).toHaveCount(0);
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
test('@smoke drops a late descendant listing after folder deletion', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
true,
|
|
);
|
|
let release!: () => void;
|
|
let pending = false;
|
|
await page.route('**/list?path=empty', async (route) => {
|
|
pending = true;
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await route.fulfill({
|
|
json: {
|
|
kind: 'list',
|
|
path: 'empty',
|
|
entries: [{ name: 'ghost.txt', kind: 'file', ignored: false }],
|
|
truncated: false,
|
|
},
|
|
});
|
|
});
|
|
await page.route('**/directory/delete', (route) =>
|
|
route.fulfill({ json: { path: 'empty' } }),
|
|
);
|
|
await browser.getByRole('treeitem', { name: 'empty', exact: true }).click();
|
|
await expect.poll(() => pending).toBe(true);
|
|
const dialog = await openFolderDelete(page, browser, 'empty');
|
|
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
const response = page.waitForResponse(
|
|
(r) => new URL(r.url()).searchParams.get('path') === 'empty',
|
|
);
|
|
release();
|
|
await (await response).finished();
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
|
);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'empty', exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'ghost.txt', exact: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test('@smoke only offers folder deletion with its capability', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'empty', exact: true })
|
|
.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'New file', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'New folder', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Delete', exact: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test('@smoke centers the initial Files loading label horizontally', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
let release!: () => void;
|
|
await page.route('**/WorkspaceFileBrowser.tsx*', async (route) => {
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await route.continue();
|
|
});
|
|
await openFiles(page, baseURL!, undefined, async () => {
|
|
const loading = page
|
|
.getByRole('status')
|
|
.filter({ hasText: /^Loading(?:\.\.\.|…)$/ });
|
|
await expect(loading).toBeVisible();
|
|
expect(
|
|
await loading.evaluate((node) => getComputedStyle(node).textAlign),
|
|
).toBe('center');
|
|
await page.screenshot({
|
|
path: test.info().outputPath('files-loading-centered.png'),
|
|
});
|
|
release();
|
|
});
|
|
});
|
|
|
|
test('@smoke closes the preview of a deleted folder descendant', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
true,
|
|
);
|
|
await page.route('**/directory/delete', (route) =>
|
|
route.fulfill({ json: { path: 'src' } }),
|
|
);
|
|
const src = browser.getByRole('treeitem', { name: 'src', exact: true });
|
|
await src.click();
|
|
const filter = browser.getByRole('textbox', {
|
|
name: 'Filter files…',
|
|
exact: true,
|
|
});
|
|
await filter.fill('app.ts');
|
|
await browser.getByRole('treeitem', { name: 'app.ts', exact: true }).click();
|
|
await expect(browser.getByTestId('workspace-file-content')).toBeVisible();
|
|
await filter.clear();
|
|
const dialog = await openFolderDelete(page, browser, 'src');
|
|
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(src).toHaveCount(0);
|
|
await expect(browser.getByTestId('workspace-file-content')).toBeHidden();
|
|
await expect(browser.getByRole('separator')).toBeHidden();
|
|
});
|
|
|
|
test('@smoke preserves an unrelated preview after folder deletion', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
true,
|
|
);
|
|
await page.route('**/directory/delete', (route) =>
|
|
route.fulfill({ json: { path: 'src' } }),
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click();
|
|
const content = browser.getByTestId('workspace-file-content');
|
|
await expect(content).toBeVisible();
|
|
const dialog = await openFolderDelete(page, browser, 'src');
|
|
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'src', exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(content).toBeVisible();
|
|
await expect(
|
|
content.getByText('Workspace guide', { exact: true }),
|
|
).toBeVisible();
|
|
await expect(browser.getByRole('separator')).toBeVisible();
|
|
});
|
|
|
|
test('@smoke loads a newly created folder on its first expansion', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
);
|
|
await page.route('**/directory/create', (route) =>
|
|
route.fulfill({ status: 201, json: { path: 'fresh' } }),
|
|
);
|
|
let reads = 0;
|
|
await page.route('**/list?path=fresh', (route) => {
|
|
reads++;
|
|
return route.fulfill({
|
|
json: {
|
|
kind: 'list',
|
|
path: 'fresh',
|
|
entries: [{ name: 'external.txt', kind: 'file', ignored: false }],
|
|
truncated: false,
|
|
},
|
|
});
|
|
});
|
|
await browser
|
|
.getByRole('button', { name: 'New folder', exact: true })
|
|
.click();
|
|
const dialog = page.getByRole('dialog', { name: 'New folder', exact: true });
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('fresh');
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
expect(reads).toBe(0);
|
|
await browser.getByRole('treeitem', { name: 'fresh', exact: true }).click();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'external.txt', exact: true }),
|
|
).toBeVisible();
|
|
expect(reads).toBe(1);
|
|
});
|
|
|
|
test('@smoke filters a deleted child out of a late ancestor listing', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
true,
|
|
);
|
|
let release!: () => void;
|
|
let pending = false;
|
|
await page.route('**/list?path=empty', async (route) => {
|
|
pending = true;
|
|
await new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await route.fulfill({
|
|
json: {
|
|
kind: 'list',
|
|
path: 'empty',
|
|
entries: [{ name: 'child', kind: 'directory', ignored: false }],
|
|
truncated: false,
|
|
},
|
|
});
|
|
});
|
|
await page.route('**/directory/create', (route) =>
|
|
route.fulfill({ status: 201, json: { path: 'empty/child' } }),
|
|
);
|
|
await page.route('**/directory/delete', (route) =>
|
|
route.fulfill({ json: { path: 'empty/child' } }),
|
|
);
|
|
const parent = browser.getByRole('treeitem', { name: 'empty', exact: true });
|
|
await parent.click();
|
|
await expect.poll(() => pending).toBe(true);
|
|
await parent.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'New folder', exact: true }).click();
|
|
const create = page.getByRole('dialog', { name: 'New folder', exact: true });
|
|
await create
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('child');
|
|
await create.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(create).toBeHidden();
|
|
const child = browser.getByRole('treeitem', { name: 'child', exact: true });
|
|
await child.click({ button: 'right' });
|
|
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
|
const remove = page.getByRole('alertdialog', {
|
|
name: 'Delete folder?',
|
|
exact: true,
|
|
});
|
|
await remove.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await expect(remove).toBeHidden();
|
|
await expect(child).toHaveCount(0);
|
|
const response = page.waitForResponse(
|
|
(r) => new URL(r.url()).searchParams.get('path') === 'empty',
|
|
);
|
|
release();
|
|
await (await response).finished();
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
|
);
|
|
await expect(child).toHaveCount(0);
|
|
await expect(parent).toHaveAttribute('aria-expanded', 'true');
|
|
});
|
|
|
|
test('@smoke discards a stale subtree after exclusive folder recreation', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
);
|
|
let reads = 0;
|
|
await page.route('**/list?path=empty', (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
kind: 'list',
|
|
path: 'empty',
|
|
entries: [
|
|
{
|
|
name: ++reads === 1 ? 'old.txt' : 'new.txt',
|
|
kind: 'file',
|
|
ignored: false,
|
|
},
|
|
],
|
|
truncated: false,
|
|
},
|
|
}),
|
|
);
|
|
await page.route('**/directory/create', (route) =>
|
|
route.fulfill({ status: 201, json: { path: 'empty' } }),
|
|
);
|
|
const folder = browser.getByRole('treeitem', { name: 'empty', exact: true });
|
|
await folder.click();
|
|
const stale = browser.getByRole('treeitem', { name: 'old.txt', exact: true });
|
|
await expect(stale).toBeVisible();
|
|
await stale.click();
|
|
await expect(browser.getByTestId('workspace-file-content')).toBeVisible();
|
|
await browser
|
|
.getByRole('button', { name: 'New folder', exact: true })
|
|
.click();
|
|
const dialog = page.getByRole('dialog', { name: 'New folder', exact: true });
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('empty');
|
|
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(stale).toHaveCount(0);
|
|
await expect(browser.getByTestId('workspace-file-content')).toBeHidden();
|
|
await folder.click();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'new.txt', exact: true }),
|
|
).toBeVisible();
|
|
expect(reads).toBe(2);
|
|
});
|
|
|
|
async function dropLocalFiles(
|
|
page: Page,
|
|
target: import('@playwright/test').Locator,
|
|
names: string[],
|
|
directory = false,
|
|
) {
|
|
const dataTransfer = await page.evaluateHandle(
|
|
({ names, directory }) => {
|
|
const transfer = new DataTransfer();
|
|
for (const name of names) {
|
|
transfer.items.add(new File([new Uint8Array([0, 255, 128, 10])], name));
|
|
}
|
|
if (directory)
|
|
Object.defineProperty(transfer, 'items', {
|
|
value: Array.from(transfer.items).map((item) => {
|
|
item.webkitGetAsEntry = () =>
|
|
({ isDirectory: true }) as FileSystemEntry;
|
|
return item;
|
|
}),
|
|
});
|
|
return transfer;
|
|
},
|
|
{ names, directory },
|
|
);
|
|
const canceled = await target.evaluate((element, transfer) => {
|
|
const dispatch = (type: string) => {
|
|
const event = new DragEvent(type, {
|
|
dataTransfer: transfer,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
});
|
|
element.dispatchEvent(event);
|
|
return event.defaultPrevented;
|
|
};
|
|
return { dragover: dispatch('dragover'), drop: dispatch('drop') };
|
|
}, dataTransfer);
|
|
await dataTransfer.dispose();
|
|
return canceled;
|
|
}
|
|
|
|
test('@smoke uploads dropped files into root and directory rows with bottom progress', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
);
|
|
const sidebar = browser.getByTestId('workspace-file-sidebar');
|
|
const uploads: { url: string; path: string; bytes: number[] }[] = [];
|
|
let finish: (() => void) | undefined;
|
|
await page.route('**/file/upload?*', async (route) => {
|
|
const request = route.request();
|
|
uploads.push({
|
|
url: new URL(request.url()).pathname,
|
|
path: new URL(request.url()).searchParams.get('path')!,
|
|
bytes: [...request.postDataBuffer()!],
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
await route.fulfill({
|
|
json: {
|
|
kind: 'file_upload',
|
|
path: uploads.at(-1)!.path,
|
|
sizeBytes: 4,
|
|
hash: 'sha256:test',
|
|
},
|
|
});
|
|
});
|
|
await browser.screenshot({ path: test.info().outputPath('before.png') });
|
|
await dropLocalFiles(page, sidebar, ['root.bin', 'second.bin']);
|
|
await expect.poll(() => uploads.length).toBe(1);
|
|
expect(uploads[0]).toEqual({
|
|
url: '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/file/upload',
|
|
path: 'root.bin',
|
|
bytes: [0, 255, 128, 10],
|
|
});
|
|
const progress = sidebar.getByRole('progressbar', { name: 'Uploading' });
|
|
await expect(progress).toBeVisible();
|
|
const barBox = (await progress.boundingBox())!;
|
|
const sidebarBox = (await sidebar.boundingBox())!;
|
|
expect(barBox.height).toBe(3);
|
|
expect(barBox.y + barBox.height).toBeCloseTo(
|
|
sidebarBox.y + sidebarBox.height,
|
|
0,
|
|
);
|
|
finish!();
|
|
await expect.poll(() => uploads.length).toBe(2);
|
|
expect(uploads[1].path).toBe('second.bin');
|
|
await expect(progress).toHaveAttribute('aria-valuenow', '50');
|
|
await browser.screenshot({ path: test.info().outputPath('progress.png') });
|
|
// Wait for the hook's success row dismissal while the second upload stays pending.
|
|
await expect(
|
|
browser.getByTestId('workspace-file-upload-status'),
|
|
).not.toContainText('root.bin');
|
|
await expect(progress).toHaveAttribute('aria-valuenow', '50');
|
|
finish!();
|
|
await expect(progress).toBeHidden();
|
|
await expect(
|
|
sidebar.getByRole('treeitem', { name: 'root.bin', exact: true }),
|
|
).toBeVisible();
|
|
|
|
const folder = sidebar.getByRole('treeitem', { name: 'empty', exact: true });
|
|
await expect(
|
|
browser.getByTestId('workspace-file-upload-status'),
|
|
).toContainText('second.bin');
|
|
await dropLocalFiles(page, folder, ['nested.bin']);
|
|
await expect.poll(() => uploads.length).toBe(3);
|
|
expect(uploads[2].path).toBe('empty/nested.bin');
|
|
await expect(progress).toHaveAttribute('aria-valuenow', '0');
|
|
finish!();
|
|
await expect(progress).toBeHidden();
|
|
await page.route('**/list?path=empty', (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
kind: 'list',
|
|
path: 'empty',
|
|
entries: [{ name: 'nested.bin', kind: 'file', ignored: false }],
|
|
truncated: false,
|
|
},
|
|
}),
|
|
);
|
|
await folder.click();
|
|
await expect(
|
|
sidebar.getByRole('treeitem', { name: 'nested.bin', exact: true }),
|
|
).toBeVisible();
|
|
await expect(sidebar).not.toContainText('empty: Empty folder');
|
|
await expect(sidebar.getByRole('alert')).toBeHidden();
|
|
await browser.screenshot({ path: test.info().outputPath('completed.png') });
|
|
});
|
|
|
|
test('@smoke file-row drops preserve preview and use server-confirmed collision names', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
);
|
|
const sidebar = browser.getByTestId('workspace-file-sidebar');
|
|
await sidebar.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
const row = sidebar.getByRole('treeitem', { name: 'guide.md', exact: true });
|
|
await row.click();
|
|
await expect(browser.getByTestId('workspace-file-content')).toContainText(
|
|
'Nested guide',
|
|
);
|
|
let uploadPath: string | null = null;
|
|
await page.route('**/file/upload?*', async (route) => {
|
|
uploadPath = new URL(route.request().url()).searchParams.get('path');
|
|
await route.fulfill({
|
|
json: {
|
|
kind: 'file_upload',
|
|
path: 'src/guide (1).md',
|
|
sizeBytes: 4,
|
|
hash: 'sha256:test',
|
|
},
|
|
});
|
|
});
|
|
await dropLocalFiles(page, row, ['guide.md']);
|
|
await expect.poll(() => uploadPath).toBe('src/guide.md');
|
|
await expect(
|
|
sidebar.getByRole('treeitem', { name: 'guide (1).md', exact: true }),
|
|
).toBeVisible();
|
|
await expect(browser.getByTestId('workspace-file-content')).toContainText(
|
|
'Nested guide',
|
|
);
|
|
await expect(sidebar.getByRole('progressbar')).toBeHidden();
|
|
});
|
|
|
|
test('@smoke retains upload errors and ignores folders and text drags', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
);
|
|
const sidebar = browser.getByTestId('workspace-file-sidebar');
|
|
let requests = 0;
|
|
await page.route('**/file/upload?*', async (route) => {
|
|
requests++;
|
|
await route.fulfill({ status: 403, json: { error: 'Upload denied' } });
|
|
});
|
|
await dropLocalFiles(page, sidebar, ['local-folder'], true);
|
|
const textTransfer = await page.evaluateHandle(() => {
|
|
const transfer = new DataTransfer();
|
|
transfer.setData('text/plain', 'hello');
|
|
return transfer;
|
|
});
|
|
const dragoverCanceled = await sidebar.evaluate((element, transfer) => {
|
|
const event = new DragEvent('dragover', {
|
|
dataTransfer: transfer,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
});
|
|
element.dispatchEvent(event);
|
|
return event.defaultPrevented;
|
|
}, textTransfer);
|
|
expect(dragoverCanceled).toBe(false);
|
|
await expect(sidebar).not.toContainText('Drop files');
|
|
const dropCanceled = await sidebar.evaluate((element, transfer) => {
|
|
const event = new DragEvent('drop', {
|
|
dataTransfer: transfer,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: true,
|
|
});
|
|
element.dispatchEvent(event);
|
|
return event.defaultPrevented;
|
|
}, textTransfer);
|
|
expect(dropCanceled).toBe(false);
|
|
await textTransfer.dispose();
|
|
await expect(sidebar.getByRole('progressbar')).toBeHidden();
|
|
expect(requests).toBe(0);
|
|
await dropLocalFiles(page, sidebar, ['denied.bin']);
|
|
await expect(
|
|
browser.getByTestId('workspace-file-upload-status'),
|
|
).toContainText('Upload denied');
|
|
await expect(sidebar.getByRole('progressbar')).toBeHidden();
|
|
await browser.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
|
await expect(
|
|
browser.getByTestId('workspace-file-upload-status'),
|
|
).not.toContainText('Upload denied');
|
|
});
|
|
|
|
test('@smoke prevents navigation and upload when tree upload capability is unavailable', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(page, baseURL!);
|
|
const url = page.url();
|
|
let requests = 0;
|
|
await page.route('**/file/upload?*', async (route) => {
|
|
requests++;
|
|
await route.abort();
|
|
});
|
|
expect(
|
|
await dropLocalFiles(page, browser.getByTestId('workspace-file-sidebar'), [
|
|
'disabled.bin',
|
|
]),
|
|
).toEqual({ dragover: true, drop: true });
|
|
await expect(browser).toBeVisible();
|
|
expect(page.url()).toBe(url);
|
|
expect(requests).toBe(0);
|
|
await expect(browser.getByRole('progressbar')).toBeHidden();
|
|
});
|
|
|
|
test('@smoke targets restored directories while refresh and upload overlap', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
);
|
|
const sidebar = browser.getByTestId('workspace-file-sidebar');
|
|
const paths: string[] = [];
|
|
let finishUpload!: () => void;
|
|
await page.route('**/file/upload?*', async (route) => {
|
|
const path = new URL(route.request().url()).searchParams.get('path')!;
|
|
paths.push(path);
|
|
await new Promise<void>((resolve) => {
|
|
finishUpload = resolve;
|
|
});
|
|
await route.fulfill({
|
|
json: { kind: 'file_upload', path, sizeBytes: 4, hash: 'sha256:test' },
|
|
});
|
|
});
|
|
await dropLocalFiles(
|
|
page,
|
|
sidebar.getByRole('treeitem', { name: 'empty', exact: true }),
|
|
['first.bin'],
|
|
);
|
|
await expect.poll(() => paths.length).toBe(1);
|
|
let finishListing!: () => void;
|
|
let listingPending = false;
|
|
await page.route('**/list?path=.', async (route) => {
|
|
listingPending = true;
|
|
await new Promise<void>((resolve) => {
|
|
finishListing = resolve;
|
|
});
|
|
await route.fulfill({
|
|
json: {
|
|
kind: 'list',
|
|
path: '.',
|
|
entries: [{ name: 'empty', kind: 'directory', ignored: false }],
|
|
truncated: false,
|
|
},
|
|
});
|
|
});
|
|
await browser.getByRole('button', { name: 'Refresh', exact: true }).click();
|
|
await expect.poll(() => listingPending).toBe(true);
|
|
finishUpload();
|
|
const restored = sidebar.getByRole('treeitem', {
|
|
name: 'empty',
|
|
exact: true,
|
|
});
|
|
await expect(restored).toBeVisible();
|
|
await dropLocalFiles(page, restored, ['second.bin']);
|
|
await expect.poll(() => paths.length).toBe(2);
|
|
expect(paths).toEqual(['empty/first.bin', 'empty/second.bin']);
|
|
finishUpload();
|
|
finishListing();
|
|
await expect(sidebar.getByRole('progressbar')).toBeHidden();
|
|
await restored.click();
|
|
await expect(
|
|
sidebar.getByRole('treeitem', { name: 'first.bin', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
sidebar.getByRole('treeitem', { name: 'second.bin', exact: true }),
|
|
).toBeVisible();
|
|
await expect(sidebar.getByRole('alert')).toBeHidden();
|
|
await expect(sidebar).not.toContainText('empty: Empty folder');
|
|
});
|
|
|
|
test('@smoke guards preview drops and keeps progress and errors visible with the tree collapsed', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
);
|
|
const sidebar = browser.getByTestId('workspace-file-sidebar');
|
|
await sidebar
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click();
|
|
const content = browser.getByTestId('workspace-file-content');
|
|
await expect(content).toContainText('Workspace guide');
|
|
let requests = 0;
|
|
let finish!: () => void;
|
|
await page.route('**/file/upload?*', async (route) => {
|
|
requests++;
|
|
await new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
await route.fulfill({ status: 403, json: { error: 'Upload denied' } });
|
|
});
|
|
for (const target of [content, browser.locator('header')]) {
|
|
expect(await dropLocalFiles(page, target, ['ignored.bin'])).toEqual({
|
|
dragover: true,
|
|
drop: true,
|
|
});
|
|
}
|
|
expect(requests).toBe(0);
|
|
await dropLocalFiles(page, sidebar, ['denied.bin']);
|
|
await expect.poll(() => requests).toBe(1);
|
|
await browser
|
|
.getByRole('button', { name: 'Toggle file tree', exact: true })
|
|
.click();
|
|
await expect(sidebar).toBeHidden();
|
|
await expect(browser.getByRole('progressbar')).toBeVisible();
|
|
await browser.screenshot({
|
|
path: test.info().outputPath('collapsed-progress.png'),
|
|
});
|
|
finish();
|
|
await expect(browser.getByRole('progressbar')).toBeHidden();
|
|
await expect(
|
|
browser.getByTestId('workspace-file-upload-status'),
|
|
).toContainText('denied.bin');
|
|
await expect(
|
|
browser.getByRole('button', { name: 'Dismiss', exact: true }).locator('..'),
|
|
).toBeVisible();
|
|
await browser.screenshot({
|
|
path: test.info().outputPath('collapsed-error.png'),
|
|
});
|
|
await browser.getByRole('button', { name: 'Dismiss', exact: true }).click();
|
|
await expect(
|
|
browser.getByTestId('workspace-file-upload-status'),
|
|
).not.toContainText('denied.bin');
|
|
});
|
|
|
|
test('@smoke shows readable root, directory and file-parent drop hints and clears on leave', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
);
|
|
const sidebar = browser.getByTestId('workspace-file-sidebar');
|
|
await sidebar.getByRole('treeitem', { name: 'src', exact: true }).click();
|
|
const transfer = await page.evaluateHandle(() => {
|
|
const data = new DataTransfer();
|
|
data.items.add(new File(['x'], 'hint.bin'));
|
|
return data;
|
|
});
|
|
for (const [target, destination] of [
|
|
[sidebar, 'qwen-web-shell-e2e'],
|
|
[sidebar.getByRole('treeitem', { name: 'empty', exact: true }), 'empty'],
|
|
[sidebar.getByRole('treeitem', { name: 'guide.md', exact: true }), 'src'],
|
|
] as const) {
|
|
await target.dispatchEvent('dragover', {
|
|
dataTransfer: transfer,
|
|
composed: true,
|
|
});
|
|
await expect(sidebar).toContainText(
|
|
`Drop files to upload into: ${destination}`,
|
|
);
|
|
await sidebar.dispatchEvent('dragleave', {
|
|
dataTransfer: transfer,
|
|
relatedTarget: null,
|
|
composed: true,
|
|
});
|
|
await expect(sidebar).not.toContainText('Drop files to upload into:');
|
|
}
|
|
await transfer.dispose();
|
|
});
|
|
|
|
test('@smoke coordinates new-folder uploads with folder deletion', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
true,
|
|
true,
|
|
true,
|
|
);
|
|
await page.route('**/directory/create', (route) =>
|
|
route.fulfill({ status: 201, json: { path: 'fresh' } }),
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click();
|
|
await browser
|
|
.getByRole('button', { name: 'New folder', exact: true })
|
|
.click();
|
|
const create = page.getByRole('dialog', { name: 'New folder', exact: true });
|
|
await create
|
|
.getByRole('textbox', { name: 'Name', exact: true })
|
|
.fill('fresh');
|
|
await create.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(create).toBeHidden();
|
|
let uploadRequests = 0;
|
|
let finishUpload!: () => void;
|
|
await page.route('**/file/upload?*', async (route) => {
|
|
uploadRequests++;
|
|
const path = new URL(route.request().url()).searchParams.get('path');
|
|
expect(path).toBe('fresh/keep.bin');
|
|
await new Promise<void>((resolve) => {
|
|
finishUpload = resolve;
|
|
});
|
|
await route.fulfill({
|
|
json: { kind: 'file_upload', path, sizeBytes: 4, hash: 'sha256:test' },
|
|
});
|
|
});
|
|
let deleteRequests = 0;
|
|
let finishDelete!: () => void;
|
|
await page.route('**/directory/delete', async (route) => {
|
|
deleteRequests++;
|
|
await new Promise<void>((resolve) => {
|
|
finishDelete = resolve;
|
|
});
|
|
await route.fulfill({ json: { kind: 'directory_delete', path: 'fresh' } });
|
|
});
|
|
const folder = browser.getByRole('treeitem', {
|
|
name: 'fresh',
|
|
exact: true,
|
|
includeHidden: true,
|
|
});
|
|
await dropLocalFiles(page, folder, ['keep.bin']);
|
|
await expect.poll(() => uploadRequests).toBe(1);
|
|
const remove = await openFolderDelete(page, browser, 'fresh');
|
|
const confirm = remove.getByRole('button', { name: 'Delete', exact: true });
|
|
await expect(confirm).toBeDisabled();
|
|
expect(deleteRequests).toBe(0);
|
|
await expect(
|
|
browser.getByRole('progressbar', { includeHidden: true }),
|
|
).toHaveCount(1);
|
|
await expect(remove).toHaveCSS('opacity', '1');
|
|
await page.screenshot({
|
|
path: test.info().outputPath('create-upload-delete.png'),
|
|
});
|
|
finishUpload();
|
|
await expect(confirm).toBeEnabled();
|
|
await confirm.click();
|
|
await expect.poll(() => deleteRequests).toBe(1);
|
|
expect(await dropLocalFiles(page, folder, ['blocked.bin'])).toEqual({
|
|
dragover: true,
|
|
drop: true,
|
|
});
|
|
expect(uploadRequests).toBe(1);
|
|
await expect(
|
|
remove.getByRole('button', { name: 'Deleting…', exact: true }),
|
|
).toBeDisabled();
|
|
finishDelete();
|
|
await expect(remove).toBeHidden();
|
|
await expect(folder).toHaveCount(0);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'keep.bin', exact: true }),
|
|
).toHaveCount(0);
|
|
await expect(browser.getByTestId('workspace-file-content')).toContainText(
|
|
'Workspace guide',
|
|
);
|
|
await expect(browser.getByRole('progressbar')).toBeHidden();
|
|
});
|
|
|
|
async function openDeleteMenu(
|
|
page: Page,
|
|
browser: import('@playwright/test').Locator,
|
|
name = 'README.md',
|
|
) {
|
|
await browser
|
|
.getByRole('treeitem', { name, exact: true })
|
|
.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Delete', exact: true }),
|
|
).toBeVisible();
|
|
}
|
|
|
|
test('@smoke confirms irreversible file deletion and preserves other tree state', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
false,
|
|
true,
|
|
true,
|
|
true,
|
|
true,
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click();
|
|
await expect(browser.getByTestId('workspace-file-content')).toContainText(
|
|
'Workspace guide',
|
|
);
|
|
await browser.getByRole('treeitem', { name: 'deep', exact: true }).click();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
|
|
).toBeVisible();
|
|
const calls: { url: string; path: string }[] = [];
|
|
let finish!: () => void;
|
|
await page.route('**/file/delete', async (route) => {
|
|
calls.push({
|
|
url: new URL(route.request().url()).pathname,
|
|
path: route.request().postDataJSON().path,
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
finish = resolve;
|
|
});
|
|
await route.fulfill({ json: { kind: 'file_delete', path: 'README.md' } });
|
|
});
|
|
await browser.screenshot({
|
|
path: test.info().outputPath('delete-before.png'),
|
|
});
|
|
await openDeleteMenu(page, browser);
|
|
await page
|
|
.getByRole('menu')
|
|
.screenshot({ path: test.info().outputPath('delete-menu.png') });
|
|
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
|
const dialog = page.getByRole('alertdialog', { name: 'Delete file?' });
|
|
await expect(dialog).toContainText('/tmp/qwen-web-shell-e2e/README.md');
|
|
await expect(dialog).toContainText('This cannot be undone.');
|
|
await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeFocused();
|
|
expect(calls).toHaveLength(0);
|
|
await dialog.screenshot({
|
|
path: test.info().outputPath('delete-confirmation.png'),
|
|
});
|
|
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
browser.locator('[role="treeitem"][tabindex="0"]'),
|
|
).toBeFocused();
|
|
expect(calls).toHaveLength(0);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeVisible();
|
|
await openDeleteMenu(page, browser);
|
|
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
browser.locator('[role="treeitem"][tabindex="0"]'),
|
|
).toBeFocused();
|
|
expect(calls).toHaveLength(0);
|
|
await openDeleteMenu(page, browser);
|
|
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
|
await dialog
|
|
.getByRole('button', { name: 'Delete file', exact: true })
|
|
.click();
|
|
await expect.poll(() => calls.length).toBe(1);
|
|
expect(calls[0]).toEqual({
|
|
url: '/workspaces/%2Ftmp%2Fqwen-web-shell-e2e/file/delete',
|
|
path: 'README.md',
|
|
});
|
|
await expect(
|
|
dialog.getByRole('button', { name: /^cancel$/i }),
|
|
).toBeDisabled();
|
|
await expect(
|
|
dialog.getByRole('button', { name: 'Deleting…', exact: true }),
|
|
).toBeDisabled();
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toBeVisible();
|
|
finish();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
browser.locator('[role="treeitem"][tabindex="0"]'),
|
|
).toBeFocused();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeHidden();
|
|
await expect(browser.getByTestId('workspace-file-content')).toBeHidden();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
|
|
).toBeVisible();
|
|
await browser.screenshot({
|
|
path: test.info().outputPath('delete-completed.png'),
|
|
});
|
|
|
|
await page.route('**/file/upload?*', (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
kind: 'file_upload',
|
|
path: 'README.md',
|
|
sizeBytes: 4,
|
|
hash: 'sha256:test',
|
|
},
|
|
}),
|
|
);
|
|
await dropLocalFiles(page, browser.getByTestId('workspace-file-sidebar'), [
|
|
'README.md',
|
|
]);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeVisible();
|
|
await expect(browser.getByRole('progressbar')).toBeHidden();
|
|
await page.route('**/file/delete', (route) =>
|
|
route.fulfill({ json: { kind: 'file_delete', path: 'README.md' } }),
|
|
);
|
|
await openDeleteMenu(page, browser);
|
|
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
|
await dialog
|
|
.getByRole('button', { name: 'Delete file', exact: true })
|
|
.click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeHidden();
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'level-0', exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('@smoke keeps failed deletions visible for retry', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
true,
|
|
);
|
|
let calls = 0;
|
|
await page.route('**/file/delete', async (route) => {
|
|
calls++;
|
|
await route.fulfill(
|
|
calls === 1
|
|
? { status: 403, json: { error: 'Deletion denied' } }
|
|
: { json: { kind: 'file_delete', path: 'README.md' } },
|
|
);
|
|
});
|
|
await openDeleteMenu(page, browser);
|
|
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click();
|
|
const dialog = page.getByRole('alertdialog');
|
|
await dialog
|
|
.getByRole('button', { name: 'Delete file', exact: true })
|
|
.click();
|
|
await expect(dialog.getByRole('alert')).toContainText('Deletion denied');
|
|
await expect(
|
|
browser.locator('[role="treeitem"][data-item-path="README.md"]'),
|
|
).toBeVisible();
|
|
await dialog
|
|
.getByRole('button', { name: 'Delete file', exact: true })
|
|
.click();
|
|
await expect(dialog).toBeHidden();
|
|
expect(calls).toBe(2);
|
|
await expect(
|
|
browser.getByRole('treeitem', { name: 'README.md', exact: true }),
|
|
).toBeHidden();
|
|
});
|
|
|
|
test('@smoke hides file deletion without capability and for symlinks', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(page, baseURL!);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'README.md', exact: true })
|
|
.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Download', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Delete', exact: true }),
|
|
).toHaveCount(0);
|
|
await page.keyboard.press('Escape');
|
|
await browser
|
|
.getByRole('treeitem', { name: 'linked', exact: true })
|
|
.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Download', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Delete', exact: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test('@smoke never offers deletion for a symlink even with capability', async ({
|
|
page,
|
|
baseURL,
|
|
}) => {
|
|
const { browser } = await openFiles(
|
|
page,
|
|
baseURL!,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
false,
|
|
false,
|
|
true,
|
|
true,
|
|
);
|
|
await browser
|
|
.getByRole('treeitem', { name: 'linked', exact: true })
|
|
.click({ button: 'right' });
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Download', exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Delete', exact: true }),
|
|
).toHaveCount(0);
|
|
});
|