feat: 文件浏览器支持图片预览并更新发布包
This commit is contained in:
@@ -1641,6 +1641,58 @@ function assertFrontendMarkdownLinkContract() {
|
||||
assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style');
|
||||
}
|
||||
|
||||
function assertFrontendFileBrowserImageContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const styles = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
const browser = source.slice(source.indexOf('const FILE_BROWSER_IMAGE_EXTENSIONS'), source.indexOf('function syncAttachmentActions'));
|
||||
assert(browser.includes("new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif'])"), 'File browser should recognize common image extensions');
|
||||
assert(browser.includes('entry.imagePreviewableHint ? \'图片\''), 'Directory listing should prioritize the image hint, including SVG');
|
||||
assert(browser.includes("imagePreviewable: button.dataset.imagePreviewable === 'true'"), 'Directory click should forward the server image hint');
|
||||
assert(browser.includes('isFileBrowserImagePath(normalizedPath)'), 'Local file links should recognize image paths without directory hints');
|
||||
assert(browser.includes('/api/fs/image?sessionId='), 'Image previews should use the authenticated image endpoint');
|
||||
assert(browser.includes('/api/fs/read?sessionId='), 'Text previews should retain the text endpoint');
|
||||
assert(source.includes('async function fetchAuthImage(url, signal)') && source.includes('Authorization: `Bearer ${authToken}`'), 'Image fetch should use Bearer authentication');
|
||||
assert(browser.includes("mimeType === 'image/svg+xml'") && browser.includes('reader.readAsDataURL(blob);'), 'SVG previews should use isolated data URLs');
|
||||
assert(browser.includes('URL.createObjectURL(blob)') && browser.includes('URL.revokeObjectURL(state.previewImageUrl)'), 'Raster previews should manage object URL lifetime');
|
||||
assert(browser.includes('state.previewAbortController?.abort()') && browser.includes('requestId !== state.previewRequestId'), 'Switching previews should abort or reject stale image requests');
|
||||
assert(browser.includes('cancelFileBrowserPreview(state)') && browser.includes('clearFileBrowserImagePreview(fileBrowserState)'), 'Closing and switching views should release image resources');
|
||||
assert(browser.includes('state.previewImageNode.onerror = () =>'), 'Image decode failures should enter an error state');
|
||||
assert(browser.includes('data-browser-preview-image-node') && styles.includes('.file-browser-preview-image[hidden]'), 'Image container should preserve preview layout on desktop and mobile');
|
||||
|
||||
const lifecycle = source.slice(source.indexOf('function clearFileBrowserImagePreview(state)'), source.indexOf('function updateFileBrowserPathBar()'));
|
||||
const released = [];
|
||||
let aborted = 0;
|
||||
const state = {
|
||||
previewRequestId: 3,
|
||||
previewAbortController: { abort() { aborted += 1; } },
|
||||
previewImageUrl: 'blob:first',
|
||||
previewImageEl: { hidden: false },
|
||||
previewImageNode: {
|
||||
src: 'blob:first',
|
||||
onload() {},
|
||||
onerror() {},
|
||||
removeAttribute(name) { if (name === 'src') this.src = ''; },
|
||||
},
|
||||
previewTitleEl: { textContent: '' },
|
||||
previewMetaEl: { textContent: '' },
|
||||
previewEmptyEl: { textContent: '', hidden: true },
|
||||
previewCodeEl: { textContent: 'old text', hidden: false },
|
||||
overlay: { parentNode: { removeChild() {} } },
|
||||
escapeHandler() {},
|
||||
};
|
||||
const createLifecycle = new Function('state', 'URL', 'document', `let fileBrowserState = state;\n${lifecycle}\nreturn { cancelFileBrowserPreview, renderFileBrowserPreviewLoading, closeFileBrowser };`);
|
||||
const actions = createLifecycle(state, { revokeObjectURL(url) { released.push(url); } }, { removeEventListener() {} });
|
||||
actions.cancelFileBrowserPreview(state);
|
||||
assert(aborted === 1 && state.previewRequestId === 4 && state.previewImageUrl === '', 'Switching previews should abort the old request and invalidate its response');
|
||||
assert(released[0] === 'blob:first' && state.previewImageNode.src === '' && state.previewImageEl.hidden, 'Switching previews should clear and revoke the old image');
|
||||
state.previewImageUrl = 'blob:second';
|
||||
actions.renderFileBrowserPreviewLoading('notes.txt');
|
||||
assert(released[1] === 'blob:second' && state.previewCodeEl.textContent === '' && state.previewCodeEl.hidden, 'Switching to text should remove the prior image and old text');
|
||||
state.previewImageUrl = 'blob:third';
|
||||
actions.closeFileBrowser();
|
||||
assert(released[2] === 'blob:third' && state.previewRequestId === 5, 'Closing the browser should release its image and invalidate pending responses');
|
||||
}
|
||||
|
||||
function assertMarkdownRenderingContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
@@ -7128,6 +7180,96 @@ async function runFilePreviewEncodingRegression() {
|
||||
});
|
||||
}
|
||||
|
||||
async function runFileBrowserImageRegression() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-file-browser-image-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||||
const logsDir = path.join(tempRoot, 'logs');
|
||||
const workspace = path.join(tempRoot, 'workspace');
|
||||
mkdirp(configDir); mkdirp(sessionsDir); mkdirp(logsDir); mkdirp(workspace);
|
||||
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><script>alert(1)</script></svg>';
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nAf8AAAAASUVORK5CYII=', 'base64');
|
||||
const fixtures = [
|
||||
['tiny.PNG', 'image/png', png],
|
||||
['portrait.JpEg', 'image/jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xd9])],
|
||||
['photo.jpg', 'image/jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xd9])],
|
||||
['motion.gif', 'image/gif', Buffer.from('GIF89a', 'ascii')],
|
||||
['image.webp', 'image/webp', Buffer.from('RIFF0000WEBP', 'ascii')],
|
||||
['image.bmp', 'image/bmp', Buffer.from('BM', 'ascii')],
|
||||
['shape.SvG', 'image/svg+xml', Buffer.from(svg, 'utf8')],
|
||||
['image.avif', 'image/avif', Buffer.from('ftypavif', 'ascii')],
|
||||
['空 格&图?.png', 'image/png', png],
|
||||
];
|
||||
fixtures.forEach(([filename, , bytes]) => fs.writeFileSync(path.join(workspace, filename), bytes));
|
||||
fs.writeFileSync(path.join(workspace, 'notes.txt'), '文本预览仍然可用', 'utf8');
|
||||
fs.writeFileSync(path.join(workspace, 'binary.bin'), Buffer.from([0, 0xff, 1]));
|
||||
fs.symlinkSync('tiny.PNG', path.join(workspace, 'linked-image.data'));
|
||||
fs.symlinkSync('notes.txt', path.join(workspace, 'linked-text.png'));
|
||||
const outsideImage = path.join(tempRoot, 'outside.png');
|
||||
fs.writeFileSync(outsideImage, fixtures[0][2]);
|
||||
fs.symlinkSync(outsideImage, path.join(workspace, 'outside-link.png'));
|
||||
const maxImageBytes = 20 * 1024 * 1024;
|
||||
fs.writeFileSync(path.join(workspace, 'limit.png'), Buffer.from([0x89]));
|
||||
fs.truncateSync(path.join(workspace, 'limit.png'), maxImageBytes);
|
||||
fs.writeFileSync(path.join(workspace, 'oversized.png'), Buffer.from([0x89]));
|
||||
fs.truncateSync(path.join(workspace, 'oversized.png'), maxImageBytes + 1);
|
||||
|
||||
const sessionId = 'file-browser-image-session';
|
||||
fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({ id: sessionId, cwd: workspace, title: '图片预览回归', messages: [] }));
|
||||
const port = await getFreePort();
|
||||
const password = 'FileBrowserImage!234';
|
||||
await withServer({ PORT: String(port), CC_WEB_PASSWORD: password, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, HOME: tempRoot, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER }, async () => {
|
||||
const { ws, messages, token } = await connectWs(port, password);
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||
const imageUrl = (targetPath) => `http://127.0.0.1:${port}/api/fs/image?sessionId=${sessionId}&path=${encodeURIComponent(targetPath)}`;
|
||||
const authed = { Authorization: `Bearer ${token}` };
|
||||
const list = await fetchAuthedJson(port, token, `/api/fs/list?sessionId=${sessionId}`);
|
||||
for (const [filename, mimeType, bytes] of fixtures) {
|
||||
const entry = list.entries.find((item) => item.name === filename);
|
||||
assert(entry?.imagePreviewableHint === true, `${filename} should be listed as a previewable image`);
|
||||
const response = await fetch(imageUrl(filename), { headers: authed });
|
||||
assert(response.status === 200, `${filename} should be previewable, got ${response.status}`);
|
||||
assert(response.headers.get('content-type') === mimeType, `${filename} should use ${mimeType}`);
|
||||
assert(response.headers.get('x-content-type-options') === 'nosniff', `${filename} should disable MIME sniffing`);
|
||||
assert(Buffer.from(await response.arrayBuffer()).equals(bytes), `${filename} should return original image bytes`);
|
||||
}
|
||||
assert(list.entries.find((item) => item.name === 'shape.SvG')?.previewableHint, 'SVG may also have a text hint; image must take precedence');
|
||||
assert(list.entries.find((item) => item.name === 'linked-image.data')?.imagePreviewableHint, 'Image symlink should use the target format');
|
||||
assert(list.entries.find((item) => item.name === 'linked-text.png')?.imagePreviewableHint === false, 'Text symlink should not be presented as an image');
|
||||
assert(!list.entries.some((item) => item.name === 'outside-link.png'), 'Outside symlink should be hidden in directory listing');
|
||||
const linkedResponse = await fetch(imageUrl('linked-image.data'), { headers: authed });
|
||||
assert(linkedResponse.status === 200 && linkedResponse.headers.get('content-type') === 'image/png', 'Image symlink should use the target MIME type');
|
||||
await linkedResponse.body.cancel();
|
||||
|
||||
const exactLimit = await fetch(imageUrl('limit.png'), { headers: authed });
|
||||
assert(exactLimit.status === 200, 'Exactly 20 MiB should remain previewable');
|
||||
await exactLimit.body.cancel();
|
||||
const rejectionCases = [
|
||||
['oversized.png', 413],
|
||||
['notes.txt', 415],
|
||||
['linked-text.png', 415],
|
||||
['binary.bin', 415],
|
||||
['.', 400],
|
||||
['../outside.png', 403],
|
||||
['outside-link.png', 403],
|
||||
];
|
||||
for (const [targetPath, status] of rejectionCases) {
|
||||
const response = await fetch(imageUrl(targetPath), { headers: authed });
|
||||
const payload = await response.json();
|
||||
assert(response.status === status && payload.ok === false, `${targetPath} should reject with ${status}, got ${response.status}`);
|
||||
if (targetPath === 'oversized.png') assert(/20 MiB/.test(payload.message), 'Oversized image should report the 20 MiB limit');
|
||||
}
|
||||
const anonymous = await fetch(imageUrl('tiny.PNG'));
|
||||
assert(anonymous.status === 401, 'Unauthenticated image request should return 401');
|
||||
const text = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=notes.txt`);
|
||||
assert(text.content === '文本预览仍然可用', 'Text preview should remain usable');
|
||||
const binary = await fetch(`http://127.0.0.1:${port}/api/fs/read?sessionId=${sessionId}&path=binary.bin`, { headers: authed });
|
||||
assert(binary.status === 415, 'Existing binary text preview rejection should remain intact');
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
function assertWindowsStartupContract() {
|
||||
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
@@ -7363,6 +7505,12 @@ async function main() {
|
||||
console.log('File preview encoding regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'file-browser-image') {
|
||||
assertFrontendFileBrowserImageContract();
|
||||
await runFileBrowserImageRegression();
|
||||
console.log('File browser image regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'usage-statistics') {
|
||||
assertUsageStatisticsUnitChecks();
|
||||
assertUsageStatisticsContract();
|
||||
@@ -7419,6 +7567,7 @@ async function main() {
|
||||
assertComposerSlashRoutingContract();
|
||||
assertFrontendCcwebPromptContract();
|
||||
assertFrontendMarkdownLinkContract();
|
||||
assertFrontendFileBrowserImageContract();
|
||||
assertMarkdownRenderingContract();
|
||||
assertMockCodexAppPromptUserNotTextTriggered();
|
||||
assertFrontendMcpReloadContract();
|
||||
@@ -7451,6 +7600,7 @@ async function main() {
|
||||
assertCcwebDisplayImageContract();
|
||||
assertCcwebListConversationsScopeContract();
|
||||
await runSessionPreviewMetadataRegression();
|
||||
await runFileBrowserImageRegression();
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
|
||||
Reference in New Issue
Block a user