Files
maka/apps/desktop/e2e/quote-selection.spec.ts
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

142 lines
6.2 KiB
TypeScript

/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { expect, test, COMPOSER_INPUT } from './fixtures';
test('a transcript drag releases outside the window through its owning Turn', async ({
window: page,
}) => {
await page.setViewportSize({ width: 1200, height: 800 });
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('pointer capture source');
await composer.press('Enter');
const reply = page.getByText(/Fake backend received: pointer capture source/);
await expect(reply).toBeVisible();
// Select from a settled answer. Selecting from a streaming one is broken for
// an unrelated reason — see the fixme below — and this test exists to pin the
// pointer-capture contract, not Selection survival across a stream close.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
const turn = reply.locator('xpath=ancestor::*[@data-turn-id][1]');
const quoteLayer = page.locator('.maka-quote-actions');
await turn.evaluate((element) => {
const owner = element as HTMLElement;
owner.addEventListener('gotpointercapture', (event) => {
owner.dataset.e2eCapturedPointer = String((event as PointerEvent).pointerId);
});
owner.addEventListener('pointerup', () => {
owner.dataset.e2eCapturedPointerUp = 'true';
});
document.addEventListener('selectionchange', () => {
owner.dataset.e2eSelectionChanged = 'true';
});
});
const bounds = await reply.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const rect = range.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
if (bounds.width < 8 || bounds.height < 1) {
throw new Error('quote selection source has no visible text bounds');
}
const y = bounds.y + bounds.height / 2;
const startX = bounds.x + 2;
const selectedX = bounds.x + bounds.width - 2;
// Ignore any collapsed selectionchange left by the Composer focus change;
// the marker below must come from this drag while capture is active.
await turn.evaluate((element) => {
const owner = element as HTMLElement;
delete owner.dataset.e2eSelectionChanged;
});
// Pointer capture must route the physical release back to the owning Turn
// after the mouse leaves the renderer viewport.
await page.mouse.move(startX, y);
await page.mouse.down();
await page.mouse.move(selectedX, y, { steps: 5 });
await expect(turn).toHaveAttribute('data-e2e-captured-pointer', /\d+/);
await expect
.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false))
.toBe(true);
await expect(turn).toHaveAttribute('data-e2e-selection-changed', 'true');
// Leave through the transcript side of the viewport. Exiting through the
// left would drag across the navigation rail and correctly turn this into a
// cross-scope Selection, which the quote resolver must reject.
await page.mouse.move(1220, y, { steps: 5 });
await page.mouse.up();
await expect(turn).toHaveAttribute('data-e2e-captured-pointer-up', 'true');
await expect(quoteLayer).toBeVisible();
});
// Known broken, kept visible rather than described in a PR nobody re-reads.
// Selecting inside a still-streaming answer loses the Selection when the
// stream closes: the markdown renderer rebuilds the paragraph's inline
// fragments, the browser discards the Selection those nodes held, and it does
// so without a `selectionchange` — so the quote hook, which re-reads the
// Selection 350ms after pointer release, finds nothing to offer.
//
// Not fixable from this repo as it stands: the rebuild is inside
// @astryxdesign/core, and pinning the `isStreaming` / `settledText` props that
// drive it still reproduces. Closing this needs an upstream fix, or a decision
// to snapshot the quote at pointerup — which would show a quote bar over text
// whose highlight the browser has already erased.
test.fixme('a drag begun while the answer streams still offers a quote', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('pointer capture source');
await composer.press('Enter');
const reply = page.getByText(/Fake backend received: pointer capture source/);
await expect(reply).toBeVisible();
const bounds = await reply.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
const rect = range.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
const y = bounds.y + bounds.height / 2;
await page.mouse.move(bounds.x + 2, y);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width - 2, y, { steps: 5 });
await page.mouse.up();
// Assert the contract that is actually broken, not the quote bar: the bar is
// also reachable while the gap is open, because the hook's 350ms read can win
// the race against the stream close and leave a bar standing over a Selection
// the browser has already erased. Wait for the close, then require the
// Selection to still be there — that is the state the quote hook needs and
// the one the fragment rebuild destroys.
await expect
.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false))
.toBe(true);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, {
timeout: 20_000,
});
await expect
.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed === false))
.toBe(true);
});