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
142 lines
6.2 KiB
TypeScript
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);
|
|
});
|