mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 15:15:18 +00:00
* feat(web-shell): add artifact downloads * fix(web-shell): scope review download state per file and cancel on unmount (#8234) * fix(web-shell): restore mount ref under StrictMode and test review MIME mapping (#8234) Reset mountedRef in the effect setup so React StrictMode's dev-time double-mount no longer leaves it false and silently cancels artifact and review downloads. Move getReviewDownloadMimeType into artifactUtils and cover the extension-to-MIME mapping with a unit test. * test(web-shell): cover ReviewChanges cancellation-on-unmount path (#8234) * test(web-shell): assert artifact download MIME type (#8234) --------- Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
1055 lines
32 KiB
TypeScript
1055 lines
32 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import type {
|
|
DaemonSessionMonitorTaskStatus,
|
|
DaemonSessionShellTaskStatus,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import type { DaemonSessionActions } from '@qwen-code/webui/daemon-react-sdk';
|
|
import { I18nProvider } from '../../i18n';
|
|
|
|
const { mockActions, mockWorkspaceActions } = vi.hoisted(() => ({
|
|
mockActions: {
|
|
cancelTask: vi.fn(),
|
|
getTasks: vi.fn(),
|
|
},
|
|
mockWorkspaceActions: {
|
|
readFileBytes: vi.fn(),
|
|
stat: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock(
|
|
'@qwen-code/webui/daemon-react-sdk',
|
|
async (importOriginal: () => Promise<Record<string, unknown>>) => ({
|
|
...(await importOriginal()),
|
|
useActions: () => mockActions,
|
|
useWorkspaceActions: () => mockWorkspaceActions,
|
|
}),
|
|
);
|
|
|
|
const { ArtifactPanel } = await import('./ArtifactPanel');
|
|
|
|
(
|
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
|
|
|
function monitorPanel(
|
|
task: DaemonSessionMonitorTaskStatus,
|
|
sessionActions?: DaemonSessionActions,
|
|
) {
|
|
return (
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[
|
|
{
|
|
id: 'monitor:monitor-1',
|
|
kind: 'monitor',
|
|
title: task.description,
|
|
task,
|
|
sessionActions,
|
|
},
|
|
]}
|
|
activeTabId="monitor:monitor-1"
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
function shellPanel(task: DaemonSessionShellTaskStatus) {
|
|
return (
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[
|
|
{
|
|
id: 'shell:shell-1',
|
|
kind: 'shell',
|
|
title: task.command,
|
|
task,
|
|
},
|
|
]}
|
|
activeTabId="shell:shell-1"
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const { root, container } of mounted) {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
}
|
|
mounted.length = 0;
|
|
mockActions.cancelTask.mockReset();
|
|
mockActions.getTasks.mockReset();
|
|
mockWorkspaceActions.readFileBytes.mockReset();
|
|
mockWorkspaceActions.stat.mockReset();
|
|
});
|
|
|
|
function openAddMenu(container: HTMLElement) {
|
|
const add = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Add panel"]',
|
|
);
|
|
act(() => {
|
|
add?.dispatchEvent(
|
|
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
|
|
);
|
|
});
|
|
return add;
|
|
}
|
|
|
|
describe('ArtifactPanel add menu', () => {
|
|
it('keeps the disabled review action on the empty page and hides the add button', () => {
|
|
const onClose = vi.fn();
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[]}
|
|
activeTabId={null}
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={onClose}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const review = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>(
|
|
'[data-testid="right-panel-empty-actions"] button',
|
|
),
|
|
).find((button) => button.textContent?.includes('Review'));
|
|
expect(review?.disabled).toBe(true);
|
|
expect(review?.textContent).toContain('View recent file changes');
|
|
expect(container.textContent).not.toContain('⌘');
|
|
expect(
|
|
container.querySelector('button[aria-label="Add panel"]'),
|
|
).toBeNull();
|
|
|
|
const panelToggle = container.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Toggle right panel"]',
|
|
);
|
|
expect(panelToggle).not.toBeNull();
|
|
act(() => panelToggle?.click());
|
|
expect(onClose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('filters empty-page actions through right-panel items', () => {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[]}
|
|
activeTabId={null}
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
items={['sideTask']}
|
|
sideTaskAvailable
|
|
onCreateSideTask={vi.fn()}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const emptyText = container.querySelector(
|
|
'[data-testid="right-panel-empty-actions"]',
|
|
)?.textContent;
|
|
expect(emptyText).toContain('Side task');
|
|
expect(emptyText).not.toContain('Review');
|
|
expect(
|
|
container.querySelector('button[aria-label="Add panel"]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('supports opening an existing side task or creating one', () => {
|
|
const onCreateSideTask = vi.fn();
|
|
const onOpenSideTask = vi.fn();
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[]}
|
|
activeTabId={null}
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
sideTaskAvailable
|
|
sideTasks={[
|
|
{
|
|
sessionId: 'side-1',
|
|
title: 'Investigate flaky tests',
|
|
workspaceCwd: '/work/project',
|
|
},
|
|
]}
|
|
onCreateSideTask={onCreateSideTask}
|
|
onOpenSideTask={onOpenSideTask}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const sideTask = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>(
|
|
'[data-testid="right-panel-empty-actions"] button',
|
|
),
|
|
).find((button) => button.textContent?.includes('Side task'));
|
|
act(() => {
|
|
sideTask?.dispatchEvent(
|
|
new MouseEvent('mouseover', { bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
const existing = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
).find((button) => button.textContent === 'Investigate flaky tests');
|
|
const create = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
).find((button) => button.textContent === 'New');
|
|
expect(existing).not.toBeUndefined();
|
|
expect(create).not.toBeUndefined();
|
|
|
|
act(() => existing?.click());
|
|
expect(onOpenSideTask).toHaveBeenCalledWith({
|
|
sessionId: 'side-1',
|
|
title: 'Investigate flaky tests',
|
|
workspaceCwd: '/work/project',
|
|
});
|
|
|
|
act(() => {
|
|
sideTask?.dispatchEvent(
|
|
new MouseEvent('mouseover', { bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
const reopenedCreate = Array.from(
|
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
|
).find((button) => button.textContent === 'New');
|
|
act(() => reopenedCreate?.click());
|
|
expect(onCreateSideTask).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('creates a side task directly from the empty page when there is no history', () => {
|
|
const onCreateSideTask = vi.fn();
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[]}
|
|
activeTabId={null}
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
sideTaskAvailable
|
|
onCreateSideTask={onCreateSideTask}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const sideTask = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>(
|
|
'[data-testid="right-panel-empty-actions"] button',
|
|
),
|
|
).find((button) => button.textContent?.includes('Side task'));
|
|
act(() => sideTask?.click());
|
|
expect(onCreateSideTask).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('does not create a side task before its history finishes loading', () => {
|
|
const onCreateSideTask = vi.fn();
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[]}
|
|
activeTabId={null}
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
sideTaskAvailable
|
|
sideTasksLoading
|
|
onCreateSideTask={onCreateSideTask}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const sideTask = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>(
|
|
'[data-testid="right-panel-empty-actions"] button',
|
|
),
|
|
).find((button) => button.textContent?.includes('Side task'));
|
|
expect(sideTask?.disabled).toBe(true);
|
|
act(() => sideTask?.click());
|
|
expect(onCreateSideTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('opens the latest review from the empty page', () => {
|
|
const onOpenLatestReview = vi.fn();
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[]}
|
|
activeTabId={null}
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
latestReviewAvailable
|
|
onOpenLatestReview={onOpenLatestReview}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const review = Array.from(
|
|
container.querySelectorAll<HTMLButtonElement>(
|
|
'[data-testid="right-panel-empty-actions"] button',
|
|
),
|
|
).find((button) => button.textContent?.includes('Review'));
|
|
expect(review?.disabled).toBe(false);
|
|
act(() => review?.click());
|
|
expect(onOpenLatestReview).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('hides review from the add menu when a review tab is already open', () => {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[{ id: 'review', kind: 'review', title: 'Review' }]}
|
|
activeTabId="review"
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
latestReviewAvailable
|
|
sideTaskAvailable
|
|
onOpenLatestReview={vi.fn()}
|
|
onCreateSideTask={vi.fn()}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
openAddMenu(container);
|
|
const menuText = document.body.querySelector('[role="menu"]')?.textContent;
|
|
expect(menuText).not.toContain('Review');
|
|
expect(menuText).toContain('New side task');
|
|
});
|
|
|
|
it('shows review and side-task actions in the add menu for a non-empty panel', () => {
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[
|
|
{
|
|
id: 'artifact',
|
|
kind: 'artifact',
|
|
title: 'Report',
|
|
artifactId: 'report',
|
|
},
|
|
]}
|
|
activeTabId="artifact"
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
latestReviewAvailable
|
|
sideTaskAvailable
|
|
sideTasks={[
|
|
{
|
|
sessionId: 'side-1',
|
|
title: 'Existing side task',
|
|
workspaceCwd: '/work/project',
|
|
},
|
|
]}
|
|
onOpenLatestReview={vi.fn()}
|
|
onCreateSideTask={vi.fn()}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
openAddMenu(container);
|
|
const menuText = document.body.querySelector('[role="menu"]')?.textContent;
|
|
expect(menuText).toContain('Review');
|
|
expect(menuText).toContain('New side task');
|
|
expect(menuText).not.toContain('Existing side task');
|
|
});
|
|
});
|
|
|
|
describe('ArtifactPanel review downloads', () => {
|
|
it('shows the requested actions and reports download failures through toast', async () => {
|
|
const changes = ['report.html', 'notes.md', 'image.png'].map((path) => ({
|
|
path,
|
|
status: 'modified' as const,
|
|
toolCallId: `tool-${path}`,
|
|
isArtifact: false,
|
|
diffs: [],
|
|
}));
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
const onError = vi.fn();
|
|
let rejectStat: ((error: Error) => void) | undefined;
|
|
mockWorkspaceActions.stat.mockReturnValue(
|
|
new Promise((_resolve, reject) => {
|
|
rejectStat = reject;
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[
|
|
{
|
|
id: 'review',
|
|
kind: 'review',
|
|
title: 'Review',
|
|
changes,
|
|
},
|
|
]}
|
|
activeTabId="review"
|
|
reviewChanges={changes}
|
|
selectedReviewPath={null}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onError={onError}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const actionLabels = Array.from(container.querySelectorAll('button')).map(
|
|
(button) => button.textContent?.trim(),
|
|
);
|
|
expect(actionLabels.filter((label) => label === 'Preview')).toHaveLength(3);
|
|
expect(actionLabels.filter((label) => label === 'Download')).toHaveLength(
|
|
2,
|
|
);
|
|
|
|
const download = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent?.trim() === 'Download',
|
|
);
|
|
act(() => download?.click());
|
|
expect(download?.disabled).toBe(true);
|
|
expect(download?.textContent).toContain('Downloading');
|
|
act(() => download?.click());
|
|
expect(mockWorkspaceActions.stat).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
rejectStat?.(new Error('read denied'));
|
|
await Promise.resolve();
|
|
});
|
|
expect(download?.disabled).toBe(false);
|
|
expect(onError).toHaveBeenCalledWith(
|
|
expect.objectContaining({ message: 'Download failed: read denied' }),
|
|
'Download failed: read denied',
|
|
);
|
|
});
|
|
|
|
it('keeps other rows downloadable while one review file downloads', () => {
|
|
const changes = ['a.html', 'b.md'].map((path) => ({
|
|
path,
|
|
status: 'modified' as const,
|
|
toolCallId: `tool-${path}`,
|
|
isArtifact: false,
|
|
diffs: [],
|
|
}));
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
mockWorkspaceActions.stat.mockReturnValue(new Promise(() => {}));
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[
|
|
{
|
|
id: 'review',
|
|
kind: 'review',
|
|
title: 'Review',
|
|
changes,
|
|
},
|
|
]}
|
|
activeTabId="review"
|
|
reviewChanges={changes}
|
|
selectedReviewPath={null}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const downloads = Array.from(container.querySelectorAll('button')).filter(
|
|
(button) => button.textContent?.trim() === 'Download',
|
|
);
|
|
expect(downloads).toHaveLength(2);
|
|
|
|
act(() => downloads[0]?.click());
|
|
expect(downloads[0]?.disabled).toBe(true);
|
|
expect(downloads[0]?.textContent).toContain('Downloading');
|
|
expect(downloads[1]?.disabled).toBe(false);
|
|
expect(downloads[1]?.textContent?.trim()).toBe('Download');
|
|
});
|
|
|
|
it('cancels the download and skips the error toast when the panel unmounts mid-download', async () => {
|
|
const changes = [
|
|
{
|
|
path: 'report.html',
|
|
status: 'modified' as const,
|
|
toolCallId: 'tool-report',
|
|
isArtifact: false,
|
|
diffs: [],
|
|
},
|
|
];
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
const onError = vi.fn();
|
|
let resolveStat: ((value: unknown) => void) | undefined;
|
|
mockWorkspaceActions.stat.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveStat = resolve;
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
root.render(
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[
|
|
{
|
|
id: 'review',
|
|
kind: 'review',
|
|
title: 'Review',
|
|
changes,
|
|
},
|
|
]}
|
|
activeTabId="review"
|
|
reviewChanges={changes}
|
|
selectedReviewPath={null}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onError={onError}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
|
|
const download = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent?.trim() === 'Download',
|
|
);
|
|
act(() => download?.click());
|
|
expect(mockWorkspaceActions.stat).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => root.unmount());
|
|
|
|
await act(async () => {
|
|
resolveStat?.({ sizeBytes: 3, modifiedMs: 1 });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('ArtifactPanel monitor tab', () => {
|
|
it('uses the source pane actions for monitor controls', async () => {
|
|
const task: DaemonSessionMonitorTaskStatus = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'monitor-label',
|
|
description: 'watch pane logs',
|
|
status: 'running',
|
|
startTime: 1,
|
|
runtimeMs: 10,
|
|
command: 'tail -f pane.log',
|
|
eventCount: 1,
|
|
droppedLines: 0,
|
|
};
|
|
const paneActions = {
|
|
cancelTask: vi.fn().mockResolvedValue({ cancelled: true }),
|
|
getTasks: vi.fn().mockResolvedValue({
|
|
v: 1,
|
|
sessionId: 'pane-session',
|
|
now: 11,
|
|
tasks: [{ ...task, status: 'cancelled' }],
|
|
}),
|
|
} as unknown as DaemonSessionActions;
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(monitorPanel(task, paneActions));
|
|
});
|
|
const stopButton = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Stop',
|
|
);
|
|
await act(async () => {
|
|
stopButton?.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(paneActions.cancelTask).toHaveBeenCalledWith('monitor-1', 'monitor');
|
|
expect(mockActions.cancelTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows the monitor snapshot in a dedicated right-panel tab', () => {
|
|
const task: DaemonSessionMonitorTaskStatus = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'monitor-label',
|
|
description: 'watch server log',
|
|
status: 'running',
|
|
startTime: 1_000,
|
|
runtimeMs: 5_000,
|
|
command: 'tail -f server.log',
|
|
pid: 42,
|
|
eventCount: 3,
|
|
lastEventTime: 5_000,
|
|
droppedLines: 2,
|
|
};
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(monitorPanel(task));
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('svg.lucide-square-activity'),
|
|
).not.toBeNull();
|
|
expect(container.textContent).toContain('watch server log');
|
|
expect(
|
|
container.querySelector('[data-status="running"]')?.textContent,
|
|
).toBe('Running');
|
|
expect(container.textContent).toContain('PID');
|
|
expect(container.textContent).toContain('42');
|
|
expect(container.textContent).toContain('Events');
|
|
expect(container.textContent).toContain('3');
|
|
expect(container.textContent).toContain('Dropped');
|
|
expect(container.textContent).toContain('2');
|
|
expect(container.textContent).toContain('tail -f server.log');
|
|
});
|
|
|
|
it('stops a running monitor from its detail tab', async () => {
|
|
const task: DaemonSessionMonitorTaskStatus = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'monitor-label',
|
|
description: 'watch server log',
|
|
status: 'running',
|
|
startTime: 1_000,
|
|
runtimeMs: 5_000,
|
|
command: 'tail -f server.log',
|
|
eventCount: 3,
|
|
lastEventTime: 5_000,
|
|
droppedLines: 0,
|
|
};
|
|
mockActions.cancelTask.mockResolvedValue({ cancelled: true });
|
|
mockActions.getTasks.mockResolvedValue({
|
|
v: 1,
|
|
sessionId: 'session-1',
|
|
now: 6_000,
|
|
tasks: [{ ...task }],
|
|
});
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(monitorPanel(task));
|
|
});
|
|
|
|
const stopButton = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Stop',
|
|
);
|
|
expect(stopButton).toBeDefined();
|
|
await act(async () => {
|
|
stopButton?.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockActions.cancelTask).toHaveBeenCalledWith('monitor-1', 'monitor');
|
|
expect(container.textContent).toContain('Stopped');
|
|
expect(
|
|
Array.from(container.querySelectorAll('button')).some(
|
|
(button) => button.textContent === 'Stop',
|
|
),
|
|
).toBe(false);
|
|
|
|
act(() => {
|
|
root.render(monitorPanel({ ...task }));
|
|
});
|
|
|
|
expect(container.textContent).toContain('Stopped');
|
|
expect(
|
|
Array.from(container.querySelectorAll('button')).some(
|
|
(button) => button.textContent === 'Stop',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('stays stopped when the post-cancel refresh fails', async () => {
|
|
const task: DaemonSessionMonitorTaskStatus = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'monitor-label',
|
|
description: 'watch server log',
|
|
status: 'running',
|
|
startTime: 1_000,
|
|
runtimeMs: 5_000,
|
|
command: 'tail -f server.log',
|
|
eventCount: 3,
|
|
lastEventTime: 5_000,
|
|
droppedLines: 0,
|
|
};
|
|
mockActions.cancelTask.mockResolvedValue({ cancelled: true });
|
|
mockActions.getTasks.mockRejectedValue(new Error('refresh failed'));
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(monitorPanel(task));
|
|
});
|
|
const stopButton = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Stop',
|
|
);
|
|
await act(async () => {
|
|
stopButton?.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('Stopped');
|
|
expect(container.textContent).not.toContain('Failed to cancel task');
|
|
});
|
|
|
|
it('keeps an in-flight stop response scoped to its monitor tab', async () => {
|
|
const firstTask: DaemonSessionMonitorTaskStatus = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'first-monitor',
|
|
description: 'watch first log',
|
|
status: 'running',
|
|
startTime: 1_000,
|
|
runtimeMs: 5_000,
|
|
command: 'tail -f first.log',
|
|
eventCount: 3,
|
|
lastEventTime: 5_000,
|
|
droppedLines: 0,
|
|
};
|
|
const secondTask: DaemonSessionMonitorTaskStatus = {
|
|
...firstTask,
|
|
id: 'monitor-2',
|
|
label: 'second-monitor',
|
|
description: 'watch second log',
|
|
status: 'running',
|
|
command: 'tail -f second.log',
|
|
};
|
|
let resolveCancel: ((value: { cancelled: boolean }) => void) | undefined;
|
|
mockActions.cancelTask.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveCancel = resolve;
|
|
}),
|
|
);
|
|
mockActions.getTasks.mockResolvedValue({
|
|
v: 1,
|
|
sessionId: 'session-1',
|
|
now: 6_000,
|
|
tasks: [{ ...firstTask, status: 'cancelled' }],
|
|
});
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
const renderPanel = (activeTabId: string) => (
|
|
<I18nProvider language="en">
|
|
<ArtifactPanel
|
|
artifacts={[]}
|
|
tabs={[
|
|
{
|
|
id: 'monitor:monitor-1',
|
|
kind: 'monitor',
|
|
title: firstTask.description,
|
|
task: firstTask,
|
|
},
|
|
{
|
|
id: 'monitor:monitor-2',
|
|
kind: 'monitor',
|
|
title: secondTask.description,
|
|
task: secondTask,
|
|
},
|
|
]}
|
|
activeTabId={activeTabId}
|
|
reviewChanges={[]}
|
|
selectedReviewPath={null}
|
|
onSelectTab={() => {}}
|
|
onCloseTab={() => {}}
|
|
onOpenFilePreview={() => {}}
|
|
onClose={() => {}}
|
|
/>
|
|
</I18nProvider>
|
|
);
|
|
|
|
act(() => {
|
|
root.render(renderPanel('monitor:monitor-1'));
|
|
});
|
|
const stopButton = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Stop',
|
|
);
|
|
act(() => {
|
|
stopButton?.click();
|
|
root.render(renderPanel('monitor:monitor-2'));
|
|
});
|
|
const secondStopButton = Array.from(
|
|
container.querySelectorAll('button'),
|
|
).find((button) => button.textContent === 'Stop');
|
|
expect(secondStopButton).toBeDefined();
|
|
expect(secondStopButton?.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
resolveCancel?.({ cancelled: true });
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.textContent).toContain('watch second log');
|
|
expect(container.textContent).toContain('tail -f second.log');
|
|
expect(container.textContent).not.toContain('tail -f first.log');
|
|
expect(
|
|
container.querySelector('[data-status="running"]')?.textContent,
|
|
).toBe('Running');
|
|
});
|
|
|
|
it('keeps a stop error across running snapshot refreshes', async () => {
|
|
const task: DaemonSessionMonitorTaskStatus = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'monitor-label',
|
|
description: 'watch server log',
|
|
status: 'running',
|
|
startTime: 1_000,
|
|
runtimeMs: 5_000,
|
|
command: 'tail -f server.log',
|
|
eventCount: 3,
|
|
lastEventTime: 5_000,
|
|
droppedLines: 0,
|
|
};
|
|
mockActions.cancelTask.mockResolvedValue({ cancelled: false });
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(monitorPanel(task));
|
|
});
|
|
const stopButton = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Stop',
|
|
);
|
|
await act(async () => {
|
|
stopButton?.click();
|
|
await Promise.resolve();
|
|
});
|
|
expect(container.textContent).toContain('Task already stopped');
|
|
|
|
act(() => {
|
|
root.render(monitorPanel({ ...task, runtimeMs: 8_000 }));
|
|
});
|
|
|
|
expect(container.textContent).toContain('Task already stopped');
|
|
expect(container.textContent).toContain('8s');
|
|
});
|
|
|
|
it('shows a cancel error when the stop request throws', async () => {
|
|
const task: DaemonSessionMonitorTaskStatus = {
|
|
kind: 'monitor',
|
|
id: 'monitor-1',
|
|
label: 'monitor-label',
|
|
description: 'watch server log',
|
|
status: 'running',
|
|
startTime: 1_000,
|
|
runtimeMs: 5_000,
|
|
command: 'tail -f server.log',
|
|
eventCount: 3,
|
|
lastEventTime: 5_000,
|
|
droppedLines: 0,
|
|
};
|
|
mockActions.cancelTask.mockRejectedValue(new Error('network'));
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(monitorPanel(task));
|
|
});
|
|
const stopButton = Array.from(container.querySelectorAll('button')).find(
|
|
(button) => button.textContent === 'Stop',
|
|
);
|
|
await act(async () => {
|
|
stopButton?.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockActions.cancelTask).toHaveBeenCalledWith('monitor-1', 'monitor');
|
|
expect(container.textContent).toContain('Failed to cancel task');
|
|
const stopButtonAfter = Array.from(
|
|
container.querySelectorAll('button'),
|
|
).find((button) => button.textContent === 'Stop');
|
|
expect(stopButtonAfter).toBeDefined();
|
|
expect(stopButtonAfter?.disabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('ArtifactPanel shell tab', () => {
|
|
it('shows shell task details in a dedicated right-panel tab', () => {
|
|
const task: DaemonSessionShellTaskStatus = {
|
|
kind: 'shell',
|
|
id: 'shell-1',
|
|
label: 'Development server',
|
|
description: 'Run the development server',
|
|
status: 'failed',
|
|
startTime: 1_000,
|
|
runtimeMs: 5_000,
|
|
command: 'npm run dev',
|
|
cwd: '/work/project',
|
|
pid: 42,
|
|
exitCode: 1,
|
|
error: 'Command failed',
|
|
};
|
|
const container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
mounted.push({ root, container });
|
|
|
|
act(() => {
|
|
root.render(shellPanel(task));
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('svg.lucide-square-terminal'),
|
|
).not.toBeNull();
|
|
expect(container.querySelector('[data-status="failed"]')?.textContent).toBe(
|
|
'Failed',
|
|
);
|
|
expect(container.querySelector('pre')?.textContent).toBe('npm run dev');
|
|
expect(container.textContent).toContain('npm run dev');
|
|
expect(container.textContent).toContain('/work/project');
|
|
expect(container.textContent).toContain('Exit code');
|
|
expect(container.textContent).toContain('Command failed');
|
|
});
|
|
});
|