diff --git a/apps/desktop/e2e/send-message.spec.ts b/apps/desktop/e2e/send-message.spec.ts index 4220c79791..10f483a161 100644 --- a/apps/desktop/e2e/send-message.spec.ts +++ b/apps/desktop/e2e/send-message.spec.ts @@ -83,22 +83,33 @@ test('renders a settled Mermaid fence as a diagram', async ({ window: page }) => await expect(diagram.locator('.maka-mermaid-source')).toContainText('flowchart TB'); await viewSource.click(); - const toolbar = diagram.locator('.maka-mermaid-toolbar'); - const toolbarBeforeZoom = await toolbar.boundingBox(); - const diagramBeforeZoom = await diagram.boundingBox(); - const viewportHeightBeforeZoom = await viewport.evaluate((element) => element.getBoundingClientRect().height); + // Zoom moves the diagram's content, never its chrome. Read the toolbar + // offset and the viewport height inside one evaluate: the transcript is a + // bottom-pinned scroller that re-pins on every ResizeObserver update, so two + // separate boundingBox() round-trips sample the same element at two scroll + // positions and turn that drift into a phantom offset change (#2000). + const readChrome = () => diagram.evaluate((element) => { + const diagramTop = element.getBoundingClientRect().top; + const toolbarTop = element.querySelector('.maka-mermaid-toolbar')?.getBoundingClientRect().top; + const viewportHeight = element.querySelector('.maka-mermaid-viewport')?.getBoundingClientRect().height; + return { toolbarOffset: (toolbarTop ?? 0) - diagramTop, viewportHeight: viewportHeight ?? 0 }; + }); + const chromeBeforeZoom = await readChrome(); const zoomIn = diagram.getByRole('button', { name: '放大图表' }); await zoomIn.click(); await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.25'); await zoomIn.click(); await expect(diagram).toHaveAttribute('data-maka-mermaid-zoom', '1.50'); - const toolbarAfterZoom = await toolbar.boundingBox(); - const diagramAfterZoom = await diagram.boundingBox(); - const viewportHeightAfterZoom = await viewport.evaluate((element) => element.getBoundingClientRect().height); - const toolbarOffsetBeforeZoom = (toolbarBeforeZoom?.y ?? 0) - (diagramBeforeZoom?.y ?? 0); - const toolbarOffsetAfterZoom = (toolbarAfterZoom?.y ?? 0) - (diagramAfterZoom?.y ?? 0); - expect(Math.abs(toolbarOffsetAfterZoom - toolbarOffsetBeforeZoom)).toBeLessThanOrEqual(1); - expect(Math.abs(viewportHeightAfterZoom - viewportHeightBeforeZoom)).toBeLessThanOrEqual(1); + // Poll for the steady state: the zoomed layout settles over a rAF, a + // ResizeObserver pass, and the scroller's re-pin, so a single instantaneous + // read asserts a frame the user never sees. + await expect.poll(async () => { + const chrome = await readChrome(); + return Math.max( + Math.abs(chrome.toolbarOffset - chromeBeforeZoom.toolbarOffset), + Math.abs(chrome.viewportHeight - chromeBeforeZoom.viewportHeight), + ); + }).toBeLessThanOrEqual(1); await expect.poll(() => viewport.evaluate((element) => element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight)).toBe(true); const zoomedBounds = await diagram.evaluate((element) => { diff --git a/packages/headless/src/__tests__/harbor-cell.test.ts b/packages/headless/src/__tests__/harbor-cell.test.ts index a51f76b8b3..c23166a0a0 100644 --- a/packages/headless/src/__tests__/harbor-cell.test.ts +++ b/packages/headless/src/__tests__/harbor-cell.test.ts @@ -1216,9 +1216,19 @@ describe('runHarborCell', () => { const fallback = new Promise((resolve) => { releaseFallback = () => resolve({ exitCode: 0, stdout: '', stderr: '' }); }); - const fallbackTimer = setTimeout(releaseFallback, 5_000); + // The deadline is a wall-clock timer started when the cell is set up, so + // it races the cell's own storage/session/first-send cost before the turn + // ever reaches the tool. Lose that race and the run is still cancelled by + // `benchmark.deadline` while the backend is never stopped — the empty + // `stopModes` seen in CI. Budget the setup generously and name the race, + // so a future loss reports itself instead of a bare deepEqual mismatch. + const settleAfterMs = 3_000; + const startedAt = Date.now(); + let toolActiveAfterMs: number | undefined; + const fallbackTimer = setTimeout(releaseFallback, settleAfterMs + 5_000); const executor: IsolatedToolExecutor = { exec: async (_input, control) => { + toolActiveAfterMs ??= Date.now() - startedAt; const signal = control?.abortSignal; if (!signal) return await fallback; return await new Promise((_resolve, reject) => { @@ -1233,7 +1243,7 @@ describe('runHarborCell', () => { cwd: workspaceDir, outputDir, storageRoot, - settleAfterMs: 1_000, + settleAfterMs, realBackendIsolation: { kind: 'external', label: 'cancellable test executor', @@ -1247,11 +1257,17 @@ describe('runHarborCell', () => { }); }, }), - 3_000, + settleAfterMs + 10_000, 'Harbor cell did not cancel its active isolated tool', ); clearTimeout(fallbackTimer); + assert.ok( + toolActiveAfterMs !== undefined && toolActiveAfterMs < settleAfterMs, + toolActiveAfterMs === undefined + ? `the isolated tool must be active when the deadline fires, but it never started within the ${settleAfterMs}ms budget` + : `the isolated tool must be active when the deadline fires, but it started ${toolActiveAfterMs}ms in, past the ${settleAfterMs}ms budget`, + ); assert.equal(result.settledByDeadline, true); assert.deepEqual(backend?.stopModes, ['immediate']); assert.equal(result.output.tokenSummary?.total, 18); diff --git a/packages/ui/src/plan-reminder-form-dialog.tsx b/packages/ui/src/plan-reminder-form-dialog.tsx index 7aa0dfedf1..0e81acdcfc 100644 --- a/packages/ui/src/plan-reminder-form-dialog.tsx +++ b/packages/ui/src/plan-reminder-form-dialog.tsx @@ -252,7 +252,12 @@ export function PlanReminderFormDialog(props: { id="maka-plan-title" className="maka-plan-title-input" value={title} - autoFocus + // Not React's `autoFocus`: it runs during commit, before the + // dialog's showModal() makes the field visible, so the focus + // silently fails. Astryx's Dialog focuses `[data-autofocus]` + // after opening — the same seam TextInput's `hasAutoFocus` + // emits, which this bare input replaced. + data-autofocus onChange={(event) => { setTitleTouched(true); setTitle(event.target.value.slice(0, 120));