blob: 3909db9f44eaae3b38589522a8f9ac6beec56da2 [file]
import { test, expect, COMPOSER_INPUT } from './fixtures';
test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();
const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};
input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();
return { duringComposition, afterComposition };
});
expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('attach-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: attach-test/)).toBeVisible();
// Drop a file onto the main composer
const composer = page.locator('.maka-composer');
await expect(composer).toBeVisible();
// Assistant text becomes visible before the turn's terminal event can clear
// the streaming state. The composer intentionally rejects attachments until
// that happens. Match the ready target and dispatch from the same renderer
// task so a state transition cannot land between readiness and the drop.
const dropTarget = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
await dropTarget.evaluate((element) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(
new File(['hello attachment content'], 'note.txt', { type: 'text/plain' }),
);
const png = Uint8Array.from(
atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='),
(character) => character.charCodeAt(0),
);
dataTransfer.items.add(new File([png], 'pixel.png', { type: 'image/png' }));
element.dispatchEvent(
new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }),
);
});
// Both attachment kinds appear in the composer before send.
await expect(page.getByText('note.txt')).toBeVisible();
await expect(page.getByText('pixel.png')).toBeVisible();
// Send the message carrying the attachment — the fake backend echoes the
// attachment name, proving the ingest-on-send path delivered it (not just
// that the UI rendered a card).
const composerInput = page.locator(COMPOSER_INPUT);
await composerInput.fill('sending mixed attachments');
await composerInput.press('Enter');
await expect(page.getByText(/Attachments received: note\.txt, pixel\.png/)).toBeVisible();
const sentMessage = page.getByLabel('你发送的消息').last();
const fileToken = sentMessage.locator('.maka-user-attachment-tokens .astryx-token');
const fileIcon = fileToken.locator('.astryx-icon');
const image = sentMessage.locator('.maka-user-attachments .astryx-thumbnail');
const bubble = sentMessage.locator('.maka-chat-message-bubble-user');
await expect(fileToken).toContainText('note.txt');
await expect(fileIcon).toHaveCSS('width', '16px');
await expect(fileIcon).toHaveCSS('height', '16px');
await expect(image).toBeVisible();
await expect(image).toHaveCSS('width', '64px');
await expect(bubble).toContainText('sending mixed attachments');
const [fileBox, imageBox, bubbleBox] = await Promise.all([
fileToken.boundingBox(),
image.boundingBox(),
bubble.boundingBox(),
]);
expect(fileBox).not.toBeNull();
expect(imageBox).not.toBeNull();
expect(bubbleBox).not.toBeNull();
// The token declares display:inline-flex + align-items:center; as a flex
// item of the attachment row it computes display:flex (blockification) on
// every platform. Assert the centering contract, not geometry: token height
// resolves from line-height, which varies with font metrics (Linux CI can
// drop below the 16px icon, overflowing a correctly centered icon by 2px).
// A tolerance-based centerline check was rejected — a baseline regression
// drifts exactly 2.00px, the tolerance boundary. display:flex is load-
// bearing: align-items computes as declared even on non-flex boxes.
await expect(fileToken).toHaveCSS('display', 'flex');
await expect(fileToken).toHaveCSS('align-items', 'center');
expect(fileBox!.y + fileBox!.height).toBeLessThanOrEqual(bubbleBox!.y);
expect(imageBox!.y + imageBox!.height).toBeLessThanOrEqual(bubbleBox!.y);
await image.getByRole('button').click();
const lightbox = page.locator('.astryx-lightbox');
await expect(lightbox).toBeVisible();
// The lightbox close button lands inside the titlebar's drag rect, where the
// OS eats clicks unless the modal is `no-drag`. The overlap is asserted first —
// without it the app-region check would guard nothing.
const titlebar = page.locator('.maka-window-titlebar');
const [titlebarBox, lightboxBox] = await Promise.all([titlebar.boundingBox(), lightbox.boundingBox()]);
expect(titlebarBox).not.toBeNull();
expect(lightboxBox).not.toBeNull();
expect(lightboxBox!.y).toBeLessThan(titlebarBox!.y + titlebarBox!.height);
await expect(lightbox).toHaveCSS('-webkit-app-region', 'no-drag');
await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();
});