From 36eeac1b676fbb31e24195bf9d60c7ca95349d0b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 3 Aug 2026 19:34:23 +0800 Subject: [PATCH 1/3] test(e2e): sample Mermaid toolbar geometry atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toolbar.top - figure.top` is structurally the figure's 1px top border: the Toolbar is the figure's first flex child, so zoom cannot move it. The assertion still read 2-3px of change in CI because it derived that offset from two independent boundingBox() round-trips, and the transcript is a bottom-pinned scroller that re-pins on every ResizeObserver update — the zoom-induced viewport reflow lands between the two samples and the scroll drift shows up as a phantom offset. Read both the toolbar offset and the viewport height inside one evaluate, so the pair is sampled in a single frame, and poll for the steady state instead of the frame right after the click: the zoomed layout settles over a rAF, a ResizeObserver pass, and the scroller's re-pin. The 1px tolerance is unchanged; only the sampling is fixed. Closes #2000 --- apps/desktop/e2e/send-message.spec.ts | 33 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) 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) => { From da9232579cee55ae4b17e66e9cf79c5dca95b059 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 3 Aug 2026 19:47:55 +0800 Subject: [PATCH 2/3] fix(ui): restore autofocus on the plan-reminder title field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2002 rebuilt the dialog's title row as a bare inside a Field, replacing a TextInput with `hasAutoFocus`. Astryx's Dialog picks its initial focus target by querying `[data-autofocus]` after showModal(), and its own source documents why React's autoFocus cannot work here: it calls .focus() during commit, while the dialog is still invisible, so the focus silently fails. TextInput emits `data-autofocus`; the bare input did not, so opening 编辑提醒 left the caret on the close button instead of the title. Move the field onto the same seam. Caught by the E2E that asserts the title box is focused when the edit dialog opens. --- packages/ui/src/plan-reminder-form-dialog.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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)); From b1be7c62e4c4a21e400a7a9ffd7376b6991d772d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 3 Aug 2026 19:54:13 +0800 Subject: [PATCH 3/3] test(headless): stop racing the deadline against harbor-cell setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The force-stop test asserts that the deadline stops an *active* isolated tool, but established that precondition by giving the cell 1000ms of wall clock to finish setup, create the session, and drive the first send before the timer fired. Lose that race on a loaded runner and the run is still cancelled by `benchmark.deadline` — settledByDeadline stays true — while the backend is never stopped, so `stopModes` is empty. That is the CI failure: actual [] vs expected ['immediate']. Reproduced by forcing the ordering with a 1ms deadline, which yields the same signature. Budget the setup at 3000ms instead of 1000ms and assert the precondition directly, so losing the race reports 'the isolated tool never started within the budget' rather than a bare deepEqual mismatch. --- .../src/__tests__/harbor-cell.test.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) 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);