Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
427 changes: 427 additions & 0 deletions apps/web/e2e/chat-context-sources.spec.ts

Large diffs are not rendered by default.

257 changes: 257 additions & 0 deletions apps/web/e2e/chat-node-mentions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { expect, test } from '@playwright/test';

import type * as InputModule from '../src/components/Panels/ChatPanel/ThreadChatInput';
import type * as SessionModule from '../src/hooks/useChatSession';
import type CanvasStore from '../src/store/canvasStore';
import type * as ChatStore from '../src/store/chatStore';
import type * as ReactModule from 'react';
import type * as ReactDOMModule from 'react-dom/client';

test.use({ hasTouch: false });

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => performance.setResourceTimingBufferSize(2000));
await page.goto('/playground/chat-performance?messages=0');
await expect(page.getByRole('textbox')).toBeVisible();
await page.evaluate(async () => {
const loaded = (suffix: string) => {
const resource = performance
.getEntriesByType('resource')
.find((entry) => new URL(entry.name).pathname.endsWith(suffix));
if (!resource) throw new Error(`Missing module: ${suffix}`);
return resource.name;
};
const [
{ default: React },
{ default: ReactDOM },
{ ThreadChatInput },
{ ChatSessionProvider },
{ default: canvas },
{ useChatStore: chat, selectThreadPendingAttachments },
] = await Promise.all([
import(loaded('/deps/react.js')) as Promise<{
default: typeof ReactModule;
}>,
import(loaded('/deps/react-dom_client.js')) as Promise<{
default: typeof ReactDOMModule;
}>,
import(loaded('/ThreadChatInput.tsx')) as Promise<typeof InputModule>,
import(loaded('/useChatSession.ts')) as Promise<typeof SessionModule>,
import(loaded('/src/store/canvasStore.ts')) as Promise<{
default: typeof CanvasStore;
}>,
import(loaded('/src/store/chatStore.ts')) as Promise<typeof ChatStore>,
]);
canvas.getState()._setStateNoAutosave({
canvasId: 'mentions-canvas',
nodes: [
{ id: 'frame', type: 'frame', data: { label: 'Herdr' } },
{
id: 'note',
type: 'note',
data: { label: 'Herdr research' },
parentId: 'frame',
},
{ id: 'chinese', type: 'note', data: { label: '研究笔记' } },
{
id: 'selected',
type: 'note',
data: { label: 'Selected note' },
selected: true,
},
].map((node) => ({ ...node, position: { x: 0, y: 0 } })),
edges: [],
});
const original = document.querySelector<HTMLElement>(
'[data-chat-performance-fixture]',
);
if (!original) throw new Error('Missing original fixture');
original.style.display = 'none';
const host = document.createElement('div');
host.id = 'mentions-fixture';
Object.assign(host.style, {
position: 'fixed',
top: '280px',
left: '24px',
width: '320px',
});
document.body.append(host);
ReactDOM.createRoot(host).render(
React.createElement(
ChatSessionProvider,
{
value: {
canvasId: 'mentions-canvas',
ownerCanvasId: 'mentions-canvas',
threadId: 'mentions-thread',
conversationView: null,
},
},
React.createElement(ThreadChatInput, {
mode: 'ask',
onStop: () => undefined,
onSubmit: (_event, _mode, draft) => {
host.dataset.submitted = JSON.stringify({
draft,
attachments: selectThreadPendingAttachments(
chat.getState(),
'mentions-thread',
),
});
},
}),
),
);
});
});

test('keyboard and mouse mentions preserve text and attach exact nodes to the submitted draft', async ({
page,
}) => {
const host = page.locator('#mentions-fixture');
const textbox = host.getByRole('textbox');
await textbox.fill('Compare @Her');
const listbox = host.getByRole('listbox', { name: 'Nodes in this Space' });
await expect(listbox.getByRole('option')).toHaveCount(2);
const activeId = await textbox.getAttribute('aria-activedescendant');
expect(activeId).toBeTruthy();
await expect(page.locator(`[id="${activeId}"]`)).toHaveAttribute(
'aria-selected',
'true',
);
const menuBounds = await host
.locator('[data-node-mention-menu]')
.boundingBox();
const surfaceBounds = await host
.locator('[data-chat-input-surface]')
.boundingBox();
expect((menuBounds?.y ?? 0) + (menuBounds?.height ?? 0)).toBeLessThan(
surfaceBounds?.y ?? 0,
);
expect(menuBounds?.width).toBeLessThanOrEqual(320);
await textbox.press('ArrowDown');
await textbox.press('Enter');
await expect(textbox).toHaveValue('Compare @Herdr research ');
await expect(listbox).toHaveCount(0);
await expect(host).not.toHaveAttribute('data-submitted');
const sources = host.locator('[data-chat-context-sources]');
await expect(sources).toHaveText('Herdr research1 selected node');
await expect(textbox).toBeFocused();

await textbox.press('End');
await textbox.pressSequentially('and @Her');
await host.getByRole('option', { name: 'Herdr Frame', exact: true }).click();
await expect(textbox).toHaveValue('Compare @Herdr research and @Herdr ');
await expect(sources).toHaveText('Herdr researchHerdr1 selected node');
await expect(textbox).toBeFocused();
await textbox.press('Enter');
await expect(host).toHaveAttribute(
'data-submitted',
JSON.stringify({
draft: 'Compare @Herdr research and @Herdr ',
attachments: [
{
type: 'text',
source: 'selection',
originNodeId: 'note',
label: 'Herdr research',
},
{
type: 'text',
source: 'selection',
originNodeId: 'frame',
label: 'Herdr',
},
],
}),
);
});

test('dismissal, no matches, IME and chip removal do not corrupt the draft', async ({
page,
}) => {
const host = page.locator('#mentions-fixture');
const textbox = host.getByRole('textbox');
await textbox.fill('email@example.com');
await expect(host.getByRole('listbox')).toHaveCount(0);
await textbox.fill('@missing');
await expect(host.getByRole('status')).toHaveText('No matching nodes');
await textbox.press('Enter');
await expect(host).not.toHaveAttribute('data-submitted');
await textbox.press('Escape');
await expect(host.getByRole('listbox')).toHaveCount(0);
await textbox.fill('@研');
await textbox.dispatchEvent('compositionstart');
await expect(host.getByRole('listbox')).toHaveCount(0);
await textbox.dispatchEvent('keydown', {
key: 'Enter',
isComposing: true,
keyCode: 229,
});
await expect(host).not.toHaveAttribute('data-submitted');
await textbox.dispatchEvent('compositionend', { data: '研' });
await expect(host.getByRole('option')).toHaveCount(1);
await textbox.press('Tab');
await expect(textbox).toHaveValue('@研究笔记 ');
await host.getByRole('button', { name: 'Remove attachment' }).click();
await expect(textbox).toHaveValue('@研究笔记 ');
await expect(host.locator('[data-context-attachment]')).toHaveCount(0);
});

test('large mention lists keep DOM bounded and preserve scrolling, wrapping and search', async ({
page,
}) => {
await page.evaluate(async () => {
const path = '/src/store/canvasStore.ts';
const { default: canvas } = (await import(path)) as {
default: typeof CanvasStore;
};
canvas.getState()._setStateNoAutosave({
nodes: Array.from({ length: 2000 }, (_, index) => ({
id: `large-${index}`,
type: 'note',
position: { x: 0, y: 0 },
data: { label: `Entry ${index}` },
})),
});
});
const host = page.locator('#mentions-fixture');
const textbox = host.getByRole('textbox');
const listbox = host.getByRole('listbox');
const options = listbox.getByRole('option');
await textbox.fill('@');
await expect(listbox).toBeVisible();
expect(await options.count()).toBeLessThanOrEqual(16);
await expect(options.first()).toHaveAttribute('aria-setsize', '2000');
expect((await options.first().boundingBox())?.height).toBe(28);
await textbox.press('ArrowUp');
await expect(listbox.locator('[aria-selected="true"]')).toHaveText(
'Entry 1999Note',
);
await expect(listbox.locator('[aria-selected="true"]')).toBeInViewport();
expect(await options.count()).toBeLessThanOrEqual(16);
await textbox.press('ArrowDown');
await expect(listbox.locator('[aria-selected="true"]')).toHaveText(
'Entry 0Note',
);
await listbox.evaluate((element) => {
element.scrollTop = 1000 * 28;
});
await expect(
listbox.getByRole('option', { name: 'Entry 1000 Note', exact: true }),
).toBeInViewport();
expect(await options.count()).toBeLessThanOrEqual(16);
const activeId = await textbox.getAttribute('aria-activedescendant');
expect(await page.locator(`[id="${activeId}"]`).count()).toBe(1);
await listbox
.getByRole('option', { name: 'Entry 1000 Note', exact: true })
.click();
await expect(textbox).toHaveValue('@Entry 1000 ');
await textbox.fill('@1999');
await expect(options).toHaveCount(1);
await textbox.press('Enter');
await expect(textbox).toHaveValue('@Entry 1999 ');
});
90 changes: 90 additions & 0 deletions apps/web/e2e/interactive-node-drag.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import type {
} from '@huabu/shared';
import type { Node } from '@xyflow/react';

// Headless scrollbar hiding would mask selection-dependent document width changes.
test.use({ launchOptions: { ignoreDefaultArgs: ['--hide-scrollbars'] } });

// Real Canvas, storage, drag callbacks and pointer router; only remote documents
// are served locally. Never inject a store or replace production components.
function tallPdf() {
Expand Down Expand Up @@ -425,6 +428,93 @@ for (const type of ['web', 'pdf', 'question'] as const) {
}
}

test.describe('desktop hover and selection layout', () => {
test.use({
hasTouch: false,
});
for (const zoom of [0.5, 1]) {
test(`mouse hover uses a layout-free outline instead of elevation at ${zoom} zoom`, async ({
page,
}) => {
const { node } = await seed(page, 'image', zoom, { inputMode: 'mouse' });
const id = await node.getAttribute('data-id');
const surface = node.locator('[data-node-surface]');
const before = await box(surface);
const hover = page.locator(`[data-node-hover-outline="${id}"]`);
const selected = page.locator(`[data-node-selection-outline="${id}"]`);
await node.hover();
await expect(hover).toBeVisible();
await expect(hover).toHaveCSS('opacity', '0.5');
await expect(surface).toHaveCSS('box-shadow', 'none');
expect(await box(surface)).toEqual(before);
expect(await box(hover)).toEqual(before);
await expect(node).not.toHaveClass(/selected/);
await expect(selected).toHaveCount(0);
await expect(page.locator('[data-node-resize-grip]')).toHaveCount(0);
await page.mouse.move(1100, 650);
await expect(hover).toHaveCount(0);
await node.click();
await expect(selected).toBeVisible();
await expect(selected).toHaveCSS('opacity', '1');
await expect(hover).toHaveCount(0);
expect(await box(surface)).toEqual(before);
await node.dispatchEvent('pointerover', {
pointerType: 'touch',
buttons: 0,
});
await expect(hover).toHaveCount(0);
});

test(`PDF selection keeps viewport width and page scale stable at ${zoom} zoom`, async ({
page,
}) => {
const { node } = await seed(page, 'pdf', zoom, { inputMode: 'mouse' });
const reader = node.locator('[data-pdf-reader="embedded"]');
const viewport = node.locator('[data-pdf-scroll-viewport]');
const bitmap = node.locator('.react-pdf__Page canvas').first();
await expect(bitmap).toBeVisible();
await expect(reader).toHaveAttribute('inert');
await expect
.poll(() =>
viewport.evaluate(
(element) => element.scrollHeight > element.clientHeight,
),
)
.toBe(true);
const originalBitmap = await bitmap.elementHandle();
const layout = () =>
viewport.evaluate((element) => {
const canvas = element.querySelector('canvas');
if (!canvas) throw new Error('Missing PDF bitmap');
return {
viewportWidth: element.clientWidth,
pageWidth: canvas.getBoundingClientRect().width,
bitmapWidth: canvas.width,
bitmapHeight: canvas.height,
};
});
const before = await layout();
await node.hover();
await expect(node.locator('[data-node-surface]')).toHaveCSS(
'box-shadow',
'none',
);
expect(await layout()).toEqual(before);
await node.click({ position: { x: 25, y: 25 } });
await expect(node).toHaveClass(/selected/);
await expect(reader).not.toHaveAttribute('inert');
await expect.poll(layout).toEqual(before);
await page.mouse.click(1100, 650);
await expect(node).not.toHaveClass(/selected/);
await expect(reader).toHaveAttribute('inert');
await expect.poll(layout).toEqual(before);
expect(
await originalBitmap?.evaluate((element) => element.isConnected),
).toBe(true);
});
}
});

for (const type of ['web', 'pdf'] as const) {
for (const zoom of [0.5, 1]) {
test(`${type} native-content grip moves the real Canvas node at ${zoom} zoom`, async ({
Expand Down
8 changes: 4 additions & 4 deletions apps/web/e2e/layers-activation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1803,12 +1803,12 @@ test.describe('mouse header controls', () => {
};
}),
).toEqual({
height: collapsed ? 40 : 48,
height: collapsed ? 40 : 44,
paddingLeft: collapsed ? '8px' : '12px',
paddingRight: collapsed ? '8px' : '12px',
paddingTop: collapsed ? '0px' : '4px',
paddingBottom: collapsed ? '0px' : '4px',
titleTopInset: collapsed ? 6 : 10,
paddingTop: '0px',
paddingBottom: '0px',
titleTopInset: collapsed ? 6 : 8,
gap: '2px',
logoWidth: 24,
logoHeight: 24,
Expand Down
Loading
Loading