diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3367e20fe..180eed6a76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,11 +32,13 @@ permissions: contents: read jobs: - # Planning and every selected Linux surface share one runner, so the core - # workflow consumes one automatic job without dropping affected coverage. - test: + # Planning and every selected Linux surface share one runner. The stable + # `test` check below joins this job with the platform-specific macOS lane. + test_linux: runs-on: ubuntu-latest timeout-minutes: 120 + outputs: + e2e: ${{ steps.plan.outputs.e2e }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -245,3 +247,69 @@ jobs: - name: Validate installed CLI release candidate if: steps.plan.outputs.cli_package == 'true' run: npm run release:cli:smoke + + # macOS compositor, overlay scrollbars, and native titlebar hit testing do + # not exist under xvfb. Run the same Desktop suite in a shown macOS window + # whenever the shared planner selects the E2E surface. + e2e_macos: + needs: test_linux + if: needs.test_linux.outputs.e2e == 'true' + runs-on: macos-15 + timeout-minutes: 30 + env: + ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + - name: Restore Electron artifact cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ github.workspace }}/.cache/electron + key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: electron-${{ runner.os }}- + - run: npm ci + - name: Keep overlay scrollbars visible + run: defaults write -g AppleShowScrollBars -string Always + - name: Desktop e2e + run: npm --workspace @maka/desktop run e2e + - name: Upload macOS desktop E2E diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-desktop-e2e-${{ github.run_id }}-${{ github.run_attempt }} + path: apps/desktop/e2e/test-results + if-no-files-found: warn + retention-days: 14 + - name: Alignment audit + run: node scripts/audit-alignment.mjs + + # Keep one required status name. A selected macOS lane must pass; an + # unselected lane must be skipped, so either drift fails closed. + test: + needs: [test_linux, e2e_macos] + if: always() + runs-on: ubuntu-latest + steps: + - name: Require successful platform lanes + env: + E2E_SELECTED: ${{ needs.test_linux.outputs.e2e }} + LINUX_RESULT: ${{ needs.test_linux.result }} + MACOS_RESULT: ${{ needs.e2e_macos.result }} + run: | + if [[ "$LINUX_RESULT" != "success" ]]; then + echo "Linux test lane failed: $LINUX_RESULT" >&2 + exit 1 + fi + expected_macos="skipped" + if [[ "$E2E_SELECTED" == "true" ]]; then + expected_macos="success" + fi + if [[ "$MACOS_RESULT" != "$expected_macos" ]]; then + echo "macOS E2E expected $expected_macos but was $MACOS_RESULT" >&2 + exit 1 + fi diff --git a/.maka-shots/3137-macos-overlay-scrollbar.png b/.maka-shots/3137-macos-overlay-scrollbar.png new file mode 100644 index 0000000000..8d40db17c3 Binary files /dev/null and b/.maka-shots/3137-macos-overlay-scrollbar.png differ diff --git a/apps/desktop/e2e/code-scroll.spec.ts b/apps/desktop/e2e/code-scroll.spec.ts index cb73873c60..754521f4a0 100644 --- a/apps/desktop/e2e/code-scroll.spec.ts +++ b/apps/desktop/e2e/code-scroll.spec.ts @@ -20,7 +20,7 @@ import { expect, test, COMPOSER_INPUT } from './fixtures'; test('a one-line Markdown code block exposes native and selection horizontal scrolling', async ({ - window: page, + codeScrollWindow: page, }) => { await page.setViewportSize({ width: 900, height: 700 }); const longLine = Array.from( @@ -101,23 +101,69 @@ test('a one-line Markdown code block exposes native and selection horizontal scr window.getSelection()?.removeAllRanges(); }); const code = viewport.locator('code'); - const codeBox = await code.boundingBox(); - if (!codeBox) throw new Error('code line has no visible bounds'); - const textY = codeBox.y + Math.min(codeBox.height / 2, 18); - await page.mouse.move(codeBox.x + 24, textY); - await page.mouse.down(); - await page.mouse.move(metrics.rect.x + metrics.rect.width + 50, textY, { steps: 20 }); - await expect.poll( - () => viewport.evaluate((element) => (element as HTMLElement).scrollLeft), - ).toBeGreaterThan(0); + const selectionStart = await code.evaluate((element) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + while (textNode && !(textNode.textContent ?? '').trim()) { + textNode = walker.nextNode(); + } + if (!textNode?.textContent) throw new Error('code line has no selectable text'); + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, Math.min(3, textNode.textContent.length)); + const rect = range.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + throw new Error('code line text has no visible range'); + } + return { + x: rect.left + Math.min(2, rect.width / 2), + y: rect.top + rect.height / 2, + }; + }); + const moveAcrossPaintedFrames = async (fromX: number, toX: number, steps: number) => { + for (let step = 1; step <= steps; step += 1) { + const progress = step / steps; + await page.mouse.move(fromX + (toX - fromX) * progress, selectionStart.y); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => resolve())), + ); + } + }; + await page.bringToFront(); + await page.mouse.click(metrics.rect.x + metrics.rect.width / 2, selectionStart.y); + await expect.poll(() => page.evaluate(() => document.hasFocus())).toBe(true); + await viewport.evaluate(() => window.getSelection()?.removeAllRanges()); + await page.mouse.dblclick(selectionStart.x, selectionStart.y, { delay: 50 }); await expect.poll( () => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0), - ).toBeGreaterThan(10); - const afterSelectionDrag = await viewport.evaluate((element) => ({ - scrollLeft: (element as HTMLElement).scrollLeft, - selection: window.getSelection()?.toString() ?? '', - })); - await page.mouse.up(); + ).toBeGreaterThan(3); + + const extensionStartX = selectionStart.x + 120; + let afterSelectionDrag: { scrollLeft: number; selection: string } | undefined; + await page.keyboard.down('Shift'); + await page.mouse.move(extensionStartX, selectionStart.y); + await page.mouse.down(); + try { + await moveAcrossPaintedFrames( + extensionStartX, + metrics.rect.x + metrics.rect.width + 50, + 20, + ); + await expect.poll( + () => viewport.evaluate((element) => (element as HTMLElement).scrollLeft), + ).toBeGreaterThan(0); + await expect.poll( + () => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0), + ).toBeGreaterThan(10); + afterSelectionDrag = await viewport.evaluate((element) => ({ + scrollLeft: (element as HTMLElement).scrollLeft, + selection: window.getSelection()?.toString() ?? '', + })); + } finally { + await page.mouse.up(); + await page.keyboard.up('Shift'); + } + if (!afterSelectionDrag) throw new Error('selection drag did not settle'); expect(afterWheelScroll).toBeGreaterThan(0); expect(afterKeyboardScroll).toBeGreaterThan(0); expect(afterSelectionDrag.scrollLeft).toBeGreaterThan(0); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index f4c9f8ef5a..84dd25005d 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -34,7 +34,7 @@ import { tryAcquireInteractiveRootOwner, } from '@maka/storage/root-authority'; import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; -import { buildFixtureEnv, isCiLinuxDisplay } from '../../../scripts/fixture-env.mjs'; +import { buildFixtureEnv, isCiIsolatedDisplay } from '../../../scripts/fixture-env.mjs'; import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs'; const DESKTOP_ROOT = process.cwd(); @@ -340,6 +340,7 @@ async function withE2eWindow( gitReviewExtraFiles, parentRemovalSessions, newTaskProject, + windowSize, }: { seed: boolean; readinessSelector: string; @@ -355,6 +356,8 @@ async function withE2eWindow( gitReviewExtraFiles?: number; parentRemovalSessions?: boolean; newTaskProject?: boolean; + /** Deterministic native fixture size for geometry-sensitive surfaces. */ + windowSize?: { width: number; height: number }; }, use: (page: Page, context: { userDataDir: string }) => Promise, ): Promise { @@ -380,15 +383,23 @@ async function withE2eWindow( app = await electron.launch({ args: ['.'], cwd: DESKTOP_ROOT, - env: buildFixtureEnv(userDataDir, homeDir, { - scenario: e2eFixtureScenario, - locale, - platform, - scrollMotion, - // xvfb throttles a hidden window's compositor to ~1fps. Geometry - // fixtures opt in locally; every fixture is visible on isolated CI X. - showWindow: showWindow || isCiLinuxDisplay(), - }), + env: { + ...buildFixtureEnv(userDataDir, homeDir, { + scenario: e2eFixtureScenario, + locale, + platform, + scrollMotion, + // Isolated CI displays throttle a hidden window's compositor. Geometry + // fixtures opt in locally; every fixture is visible on those runners. + showWindow: showWindow || isCiIsolatedDisplay(), + }), + ...(windowSize + ? { + MAKA_E2E_FIXTURE_WIDTH: String(windowSize.width), + MAKA_E2E_FIXTURE_HEIGHT: String(windowSize.height), + } + : {}), + }, }); app.on('console', (message) => { mainLogs.push(message.text()); @@ -396,7 +407,10 @@ async function withE2eWindow( }); let page: Page; try { - page = await app.firstWindow(); + // Runtime Host election is allowed 45 seconds. A fresh macOS runner can + // spend most of that budget starting its first Electron Candidate, so + // Playwright's 30-second default would fail before the product contract. + page = await app.firstWindow({ timeout: 60_000 }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); const logs = mainLogs.length > 0 ? `\nElectron main console:\n${mainLogs.join('\n')}` : ''; @@ -434,6 +448,7 @@ async function withE2eWindow( export const test = base.extend<{ window: Page; + codeScrollWindow: Page; onboardingWindow: Page; gitReviewWindow: { page: Page; projectRoot: string }; invocableSkillsWindow: Page; @@ -449,6 +464,16 @@ export const test = base.extend<{ window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use); }, + // Text selection is a native pointer interaction on macOS. Keep this window + // visible so Chromium receives the same focused drag sequence as a user. + codeScrollWindow: async ({}, use) => { + await withE2eWindow({ + seed: true, + readinessSelector: COMPOSER_INPUT, + locale: 'zh', + showWindow: true, + }, use); + }, onboardingWindow: async ({}, use) => { await withE2eWindow({ seed: false, @@ -534,6 +559,9 @@ export const test = base.extend<{ readinessSelector: '[data-turn-id]', e2eFixtureScenario: 'chat-prompt-rail', showWindow: true, + // Keep the bounded rail in its own scrolling state so the tests exercise + // clipped ticks instead of relying on every runner's font metrics to fit. + windowSize: { width: 1240, height: 740 }, }, use); }, // The same transcript, scrolling the way the shipped app scrolls. Separate @@ -547,6 +575,7 @@ export const test = base.extend<{ e2eFixtureScenario: 'chat-prompt-rail', showWindow: true, scrollMotion: 'smooth', + windowSize: { width: 1240, height: 740 }, }, use); }, // Settings → 模型, where `no-models` is the seeded openai-compatible relay — diff --git a/apps/desktop/e2e/new-task-draft-target.spec.ts b/apps/desktop/e2e/new-task-draft-target.spec.ts index ce7b554c5b..b712579e0b 100644 --- a/apps/desktop/e2e/new-task-draft-target.spec.ts +++ b/apps/desktop/e2e/new-task-draft-target.spec.ts @@ -61,21 +61,30 @@ test('the new-task draft follows the Project chosen under the composer', async ( // different path and was never broken. await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME)); - await composer.click(); - await page.keyboard.type(DRAFT); + await composer.fill(DRAFT); await expect(composer).toHaveText(DRAFT); - await picker.click(); - await page.getByRole('menuitem', { name: '无项目', exact: true }).click(); - // The picker's label is the selected target, so this asserts the click moved - // the selection. Without it the draft assertion below would still pass if the - // menu item stopped selecting anything at all. + await picker.press('Enter'); + const projectItem = page.getByRole('menuitem', { name: NEW_TASK_PROJECT_NAME, exact: true }); + const noProjectItem = page.getByRole('menuitem', { name: '无项目', exact: true }); + await expect(projectItem).toBeFocused(); + await page.keyboard.press('End'); + await expect(noProjectItem).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(noProjectItem).toHaveCount(0); + // The picker's label is the selected target, so this asserts the menu action + // moved the selection. Without it the draft assertion below would still pass + // if the menu item stopped selecting anything at all. await expect(picker).toHaveAttribute('aria-label', /无项目/); await settle(page); await expect(composer).toHaveText(DRAFT); - await picker.click(); - await page.getByRole('menuitem', { name: NEW_TASK_PROJECT_NAME, exact: true }).click(); + // Keyboard activation follows the menu button's public interaction contract + // and is not suppressed by the pointer light-dismiss guard while the first + // selection's replacement picker settles. + await picker.press('Enter'); + await expect(projectItem).toBeFocused(); + await page.keyboard.press('Enter'); await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME)); await settle(page); await expect(composer).toHaveText(DRAFT); diff --git a/apps/desktop/e2e/playwright.config.ts b/apps/desktop/e2e/playwright.config.ts index a0962dcecf..2ac3b810f2 100644 --- a/apps/desktop/e2e/playwright.config.ts +++ b/apps/desktop/e2e/playwright.config.ts @@ -33,8 +33,8 @@ import { defineConfig } from '@playwright/test'; * outlived two rounds of pruning. `playwright test --list` is the only figure * that cannot rot. * - * CI shards run on isolated X displays, so jobs still overlap without sharing - * focus or a compositor. Local parallelism is opt-in for the same reason. + * Linux CI uses an isolated X display; macOS CI shows the window so App Nap + * cannot throttle the compositor. Local parallelism is still opt-in. * * Run from apps/desktop via `npm run e2e`, which builds the app first. */ @@ -49,7 +49,9 @@ export default defineConfig({ // to mount (the cold-start convergence point — connection seed, onboarding // clear, renderer hydrated), so cold-start variance never reaches the test. retries: 0, - timeout: 60_000, + // Keep enough room for the 60-second first-window bound plus the fixture's + // readiness assertion. Runtime Host election remains independently capped. + timeout: 90_000, expect: { timeout: 10_000 }, outputDir: 'test-results', use: { diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index acef92f51e..60162039f5 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -123,6 +123,27 @@ async function loadPromptRailBeyondVirtualWindow(page: Page): Promise { .toBeGreaterThan(100); } +async function scrollTranscriptUntilTurn(page: Page, turnId: string): Promise { + // One programmatic jump is not enough on a shown macOS window: a + // ResizeObserver can restore a top-of-transcript scroll anchor after we + // set scrollTop, and the virtualizer then keeps the head window. Re-apply + // the jump until the tail turn is actually mounted. + await expect.poll(async () => { + await scrollTranscriptTo(page, 'bottom'); + await notifyTranscriptScrolled(page); + return page.evaluate((id) => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + const mounted = [...document.querySelectorAll('[data-virtual-turn-id]')] + .map((turn) => turn.dataset.virtualTurnId ?? ''); + return { + hasTurn: mounted.includes(id), + scrollTop: root ? Math.round(root.scrollTop) : -1, + lastMounted: mounted.at(-1) ?? null, + }; + }, turnId); + }, { message: `the transcript mounts ${turnId} at the bottom` }).toMatchObject({ hasTurn: true }); +} + test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => { // Measured over ALL ticks, not a sample: a helper that skips what it cannot // evaluate creates its blind spot exactly where a regression lives. @@ -181,25 +202,122 @@ test('the pointer is always on a tick while it travels down the rail', async ({ }) => { // The hover falloff reads which tick the pointer entered. A gap between the // hit boxes is a band where it is over the rail and over no tick, so the - // effect drops out and picks up again every few pixels of travel. Walked a - // pixel at a time rather than sampled between two ticks: a single midpoint - // would pass on a rail whose gaps sat anywhere else. - const travel = await page.evaluate(() => { - const bars = [...document.querySelectorAll('.maka-prompt-rail-tick-bar')]; - if (bars.length < 2) throw new Error('the prompt rail needs at least two ticks'); - const first = bars[0]!.getBoundingClientRect(); - const last = bars[bars.length - 1]!.getBoundingClientRect(); - const x = Math.round(first.left + first.width / 2); - const misses: number[] = []; - for (let y = Math.round(first.top + first.height / 2); y <= Math.round(last.top + last.height / 2); y += 1) { - const found = document.elementFromPoint(x, y); - if (!found?.closest('.maka-prompt-rail-tick')) misses.push(y); - } - return { misses: misses.length, span: Math.round(last.bottom - first.top) }; - }); + // effect drops out and picks up again every few pixels of travel. Walk the + // fractional CSS-pixel path as well as every box centre and seam: integer + // rounding can place a narrow rail's x coordinate on its outside edge, while + // sparse midpoint sampling could miss an intercept elsewhere. + // A bounded rail is an intentional scroller. Hidden ticks outside its clip + // are not unreachable; they become visible when the reader scrolls the rail. + // Check the painted column at both edges and in the middle so every group of + // ticks is covered without mistaking clipped DOM boxes for viewport overflow. + for (const position of [ + { name: 'top', ratio: 0 }, + { name: 'middle', ratio: 0.5 }, + { name: 'bottom', ratio: 1 }, + ] as const) { + await expect + .poll(async () => page.evaluate(async ({ ratio }) => { + const rail = document.querySelector('.maka-prompt-rail'); + const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; + if (!rail || ticks.length < 2) throw new Error('the prompt rail needs at least two ticks'); + + const maxScroll = Math.max(0, rail.scrollHeight - rail.clientHeight); + const targetScroll = maxScroll * ratio; + rail.scrollTop = targetScroll; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const railBox = rail.getBoundingClientRect(); + const boxes = ticks.map((tick) => tick.getBoundingClientRect()); + const visible = boxes + .map((box, index) => ({ box, index, tick: ticks[index]! })) + .filter(({ box }) => { + const center = box.top + box.height / 2; + return center >= railBox.top && center <= railBox.bottom; + }); + const first = visible[0]; + const last = visible.at(-1); + if (!first || !last) throw new Error('the prompt rail has no visible ticks'); + + const describe = (found: Element | null) => + found instanceof Element + ? `${found.tagName.toLowerCase()}.${found.className.toString().trim().replace(/\s+/g, '.')}` + : 'null'; + type Miss = { + index: number; + kind: 'center' | 'seam' | 'path'; + hit: string; + y?: number; + }; + let missCount = 0; + const sample: Miss[] = []; + const recordMiss = (miss: Miss) => { + missCount += 1; + if (sample.length < 3) sample.push(miss); + }; + + for (const { box, index, tick } of visible) { + const found = document.elementFromPoint( + box.left + box.width / 2, + box.top + box.height / 2, + ); + if (found?.closest('.maka-prompt-rail-tick') !== tick) { + recordMiss({ index, kind: 'center', hit: describe(found) }); + } + } + for (let index = 0; index < visible.length - 1; index += 1) { + const before = visible[index]!; + const after = visible[index + 1]!; + const found = document.elementFromPoint( + before.box.left + before.box.width / 2, + (before.box.bottom + after.box.top) / 2, + ); + const landed = found?.closest('.maka-prompt-rail-tick'); + if (landed !== before.tick && landed !== after.tick) { + recordMiss({ index: before.index, kind: 'seam', hit: describe(found) }); + } + } + const x = first.box.left + first.box.width / 2; + const startY = first.box.top + first.box.height / 2; + const endY = last.box.top + last.box.height / 2; + for (let y = startY, index = 0; y <= endY; y += 0.25, index += 1) { + const found = document.elementFromPoint(x, y); + if (!found?.closest('.maka-prompt-rail-tick')) { + recordMiss({ index, kind: 'path', y, hit: describe(found) }); + } + } - expect(travel.span).toBeGreaterThan(0); - expect(travel.misses).toBe(0); + const gaps = boxes.slice(1).map((box, index) => box.top - boxes[index]!.bottom); + return { + railInsideViewport: railBox.top >= 0 && railBox.bottom <= window.innerHeight, + scrollSettled: Math.abs(rail.scrollTop - targetScroll) <= 1, + enoughVisibleTicks: visible.length >= 2, + edgeReached: + ratio === 0 + ? first.index === 0 + : ratio === 1 + ? last.index === ticks.length - 1 + : true, + misses: missCount, + hasSpan: endY > startY, + continuousBoxes: Math.max(...gaps) <= 0.25, + sample, + }; + }, position), { + message: `the visible prompt rail column is continuously user-reachable at ${position.name}`, + }) + .toMatchObject({ + railInsideViewport: true, + scrollSettled: true, + enoughVisibleTicks: true, + edgeReached: true, + misses: 0, + hasSpan: true, + continuousBoxes: true, + sample: [], + }); + } }); test('the first click of a session lands on its prompt and holds', async ({ @@ -226,7 +344,9 @@ test('the first click of a session lands on its prompt and holds', async ({ // opening scroll position its top is already above the scrollport, which // passes an upper-bound-only check without the jump doing anything at all. const targetTurnId = 'turn-prompt-rail-1'; - await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); + // A normal Playwright click goes through Electron's native pointer path, so + // this assertion covers both titlebar reachability and the jump contract. + await page.locator('.maka-prompt-rail-tick').first().click(); const landing = async () => page.evaluate((turnId) => { @@ -277,7 +397,7 @@ test('long transcripts keep a bounded mounted turn window', async ({ })).toEqual({ list: 16, turn: 16 }); expect(await count()).toBeGreaterThan(0); expect(await count()).toBeLessThanOrEqual(100); - await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); + await page.locator('.maka-prompt-rail-tick').first().click(); await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); expect(await count()).toBeGreaterThan(0); expect(await count()).toBeLessThanOrEqual(100); @@ -289,8 +409,7 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri const scroller = page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]'); await scroller.waitFor(); await loadPromptRailBeyondVirtualWindow(page); - await scrollTranscriptTo(page, 'bottom'); - await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + await scrollTranscriptUntilTurn(page, 'turn-prompt-rail-120'); const retainedTurnId = await page.evaluate(() => { const turns = document.querySelectorAll('[data-virtual-turn-id]'); const turn = turns.item(turns.length - 1); @@ -345,15 +464,26 @@ test('a tick is what the pointer lands on, not the scrollbar', async ({ // `elementFromPoint`, not `hover()`: dispatched events cannot see occlusion, // and macOS's overlay scrollbar occludes without taking layout space. const hit = await page.evaluate(() => { - const tick = document.querySelector('.maka-prompt-rail-tick'); - if (!tick) throw new Error('the prompt rail has no ticks'); + const ticks = [...document.querySelectorAll('.maka-prompt-rail-tick')]; + if (ticks.length === 0) throw new Error('the prompt rail has no ticks'); + // Same X as every tick, so the overlay contract does not depend on which + // one we pick. The first tick's centre can sit under the titlebar overlay, + // where `elementFromPoint` is null and `?.closest(...) !== null` would + // false-pass. Probe the middle of the column, inside the viewport. + const tick = ticks[Math.floor(ticks.length / 2)]!; const box = tick.getBoundingClientRect(); - const found = document.elementFromPoint( - Math.round(box.left + box.width / 2), - Math.round(box.top + box.height / 2), - ); - return { insideRail: found?.closest('.maka-prompt-rail') !== null }; + const x = Math.round(box.left + box.width / 2); + const y = Math.round(box.top + box.height / 2); + const found = document.elementFromPoint(x, y); + return { + insideRail: Boolean(found?.closest('.maka-prompt-rail')), + x, + y, + hit: found instanceof Element + ? `${found.tagName.toLowerCase()}.${found.className.toString().trim().replace(/\s+/g, '.')}` + : 'null', + }; }); - expect(hit.insideRail).toBe(true); + expect(hit, `tick center hit ${hit.hit} at ${hit.x},${hit.y}`).toMatchObject({ insideRail: true }); }); diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 04decdddca..39cc57cacb 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -218,6 +218,17 @@ test('titlebar workbar action restores an existing tool instead of the picker', await expect(collapseButton).toBeVisible(); await expect(workspaceActions.getByRole('button', { name: '打开工作栏工具' })).toHaveCount(0); + // Normalize the titlebar safe area before measuring the simulated delta. + // macOS runners can expose a non-zero native titlebar-area inset, so using + // that live value as the baseline would make an 80px override move by only + // the difference between the two values. + await page.evaluate(() => { + document.documentElement.style.setProperty('--maka-titlebar-overlay-right-width', '0px'); + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); + const activeTab = panelToolbar.getByRole('tab', { selected: true }); await expect(activeTab).toBeVisible(); const [toolbarBox, tabBox, toggleBox] = await Promise.all([ diff --git a/apps/desktop/e2e/skill-draft-lifecycle.spec.ts b/apps/desktop/e2e/skill-draft-lifecycle.spec.ts index 227a8bc2a7..179d7c7604 100644 --- a/apps/desktop/e2e/skill-draft-lifecycle.spec.ts +++ b/apps/desktop/e2e/skill-draft-lifecycle.spec.ts @@ -54,9 +54,19 @@ async function composeWithSkill(page: Page, text: string, name: RegExp): Promise await composer.fill(text); await composer.click(); await composer.pressSequentially(' /'); - const option = page.getByRole('listbox', { name: /技能/ }).getByRole('option', { name }); + const listbox = page.getByRole('listbox', { name: /技能/ }); + const option = listbox.getByRole('option', { name }); await expect(option).toBeVisible(); - await option.click(); + // The suggestion list can re-rank once when the cold Skill projection + // catches up. Walk its active-descendant state instead of racing a pointer + // target that is being replaced under the cursor. + const optionCount = await listbox.getByRole('option').count(); + for (let index = 0; index < optionCount; index += 1) { + if ((await option.getAttribute('aria-selected')) === 'true') break; + await composer.press('ArrowDown'); + } + await expect(option).toHaveAttribute('aria-selected', 'true'); + await composer.press('Enter'); } async function beginRevision(page: Page): Promise { diff --git a/apps/desktop/e2e/slash-command-menu.spec.ts b/apps/desktop/e2e/slash-command-menu.spec.ts index 49c705992c..2eb6260d32 100644 --- a/apps/desktop/e2e/slash-command-menu.spec.ts +++ b/apps/desktop/e2e/slash-command-menu.spec.ts @@ -79,7 +79,8 @@ test('compacts the active session', async ({ const compact = menu.getByRole('group', { name: '命令' }).getByRole('option', { name: /压缩上下文.*\/compact/, }); - await compact.click(); + await expect(compact).toHaveAttribute('aria-selected', 'true'); + await composer.press('Enter'); await expect.poll(() => composer.textContent()).toBe('/compact '); await expect(menu).not.toBeVisible(); diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 271fd2fc8f..13dad061b1 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -29,26 +29,36 @@ test('WorkHub target metadata does not overlap the submitted Session result', as timeout: 20_000, }); - const sessionName = await page.evaluate(async () => - (await window.maka.sessions.list())[0]?.name, - ); - expect(sessionName).toBeTruthy(); + await expect + .poll( + async () => + page.evaluate(async () => (await window.maka.sessions.list())[0]?.name), + { timeout: 20_000 }, + ) + .toBe('支付回调幂等性'); + const sessionName = '支付回调幂等性'; await page.evaluate(async () => { await window.maka.settings.updateClient({ workHub: { enabled: true } }); }); await expect(page.getByRole('main', { name: 'WorkHub' })).toBeVisible(); + await expect( + page.locator('.workhub-projected-turn .workhub-submitted', { + hasText: sessionName, + }), + ).toBeVisible(); const workHubComposer = page.locator( '.workhub-surface .maka-composer-editor [contenteditable="true"]', ); await workHubComposer.fill(`继续${sessionName},补充重复投递测试点。`); await workHubComposer.press('Enter'); - await expect(page.locator('.workhub-result')).toBeVisible(); + const submitted = page.locator('.workhub-submitted').last(); + await expect(submitted.locator('.workhub-result')).toBeVisible(); - const geometry = await page.evaluate(() => { - const button = document.querySelector('.workhub-submitted > button')!; + const geometry = await submitted.evaluate((root) => { + const button = root.querySelector(':scope > button')!; const project = button.querySelector('.workhub-submitted-session small')!; - const result = document.querySelector('.workhub-result')!; + const result = root.querySelector('.workhub-result')!; const buttonBox = button.getBoundingClientRect(); const projectBox = project.getBoundingClientRect(); const resultBox = result.getBoundingClientRect(); diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index 25fc0453d4..a9e2cf1110 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -17,7 +17,7 @@ * under the License. */ -/* Codex-style prompt navigation rail: bounded prompt landmarks pinned to the +/* Prompt navigation rail: bounded prompt landmarks pinned to the right edge of the chat scrollport. Low-key by default, brightens on hover; each tick jumps to that prompt and the active turn's tick stays highlighted. The tick bar draws in `currentColor` so active/hover just shift the neutral @@ -65,17 +65,29 @@ layout space, but its hit region still intercepts the pointer, so a rail parked under it renders yet reads as dead. Linux's in-flow scrollbar moves the content column left instead, which is why the regression - sailed through CI green. Measured on macOS the dead band starts 14px in - from the scroller's right edge, so the rail rests at - `right: space-1 + space-2` (12px), clear of it, and hovering settles it - 3px further inward — the old motion's translateX(3px) did the opposite - and pushed the ticks into the band. `translateY(-50%)` stays: it only - centres vertically and has no part in the hit-region problem. */ - right: calc(var(--space-1) + var(--space-2)); - top: calc((var(--maka-prompt-rail-scrollport, 100svh) - var(--maka-prompt-rail-dock, 0px)) / 2); + sailed through CI green. The overlay dead band is 14px when the bar + appears only while scrolling, and ~16px when the system shows scroll + bars Always — GitHub's macos-15 image uses Always, and a visible + Electron window can stack a native overlay on the webview's own bar. + Rest the rail at `space-8 + space-4` (48px), clear of that stack, and + hover it 3px further inward. The old motion's translateX(3px) did the + opposite and pushed the ticks into the band. `translateY(-50%)` stays: + it only centres vertically and has no part in the hit-region problem. */ + right: calc(var(--space-8) + var(--space-4)); + /* Centre inside the plate's usable vertical band, not the whole scrollport: + the absolute macOS titlebar owns the clearance above it and would otherwise + cover the first ticks even though the rail paints there. */ + top: calc( + var(--maka-plate-titlebar-clearance, 0px) + + ( + var(--maka-prompt-rail-scrollport, 100svh) - + var(--maka-plate-titlebar-clearance, 0px) - var(--maka-prompt-rail-dock, 0px) + ) / 2 + ); transform: translateY(-50%); max-height: calc( - var(--maka-prompt-rail-scrollport, 100svh) - var(--maka-prompt-rail-dock, 0px) - (2 * var(--space-2)) + var(--maka-prompt-rail-scrollport, 100svh) - var(--maka-plate-titlebar-clearance, 0px) - + var(--maka-prompt-rail-dock, 0px) - (2 * var(--space-2)) ); overflow-y: auto; overscroll-behavior: contain; @@ -110,7 +122,7 @@ .maka-prompt-rail:hover { opacity: 1; - right: calc(var(--space-1) + var(--space-2) + 3px); + right: calc(var(--space-8) + var(--space-4) + 3px); transform: translateY(-50%); } diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 5324bebe6d..583497d5a0 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -258,6 +258,28 @@ test('core CI validates pull requests and the resulting main branch state', () = assert.match(workflow, /\[\[ "\$BASE_SHA" =~ \^0\+\$ \]\]/u); }); +test('core CI gates the stable test check on selected macOS E2E', () => { + const workflow = readWorkflow('ci.yml'); + const linux = workflow.slice(workflow.indexOf('\n test_linux:\n')); + const macos = workflow.slice(workflow.indexOf('\n e2e_macos:\n')); + const aggregate = workflow.slice(workflow.lastIndexOf('\n test:\n')); + const uploadStart = macos.indexOf(' - name: Upload macOS desktop E2E diagnostics\n'); + const upload = macos.slice(uploadStart, macos.indexOf('\n - ', uploadStart + 1)); + + assert.match(linux, /outputs:\n\s+e2e: \$\{\{ steps\.plan\.outputs\.e2e \}\}/u); + assert.match(macos, /needs: test_linux/u); + assert.match(macos, /if: needs\.test_linux\.outputs\.e2e == 'true'/u); + assert.match(macos, /runs-on: macos-15/u); + assert.match(macos, /defaults write -g AppleShowScrollBars -string Always/u); + assert.ok(uploadStart >= 0); + assert.match(upload, /if: always\(\)/u); + assert.match(upload, /uses: actions\/upload-artifact@/u); + assert.match(upload, /path: apps\/desktop\/e2e\/test-results/u); + assert.match(aggregate, /needs: \[test_linux, e2e_macos\]/u); + assert.match(aggregate, /if: always\(\)/u); + assert.match(aggregate, /expected_macos="success"/u); +}); + test('core CI uses the Windows inventory package-script authority', () => { const workflow = readWorkflow('ci.yml'); diff --git a/scripts/fixture-env.mjs b/scripts/fixture-env.mjs index 1e1876c0c2..07ddc08dac 100644 --- a/scripts/fixture-env.mjs +++ b/scripts/fixture-env.mjs @@ -100,7 +100,7 @@ export function buildFixtureEnv(userDataDir, homeDir, options = {}) { if (options.timezone) env.MAKA_E2E_FIXTURE_TIMEZONE = options.timezone; // Windows launch hidden so a run never steals the developer's focus; a // caller that needs the compositor (hit testing, real input) or a throttled - // headless display (see isCiLinuxDisplay) asks for a visible window + // headless display (see isCiIsolatedDisplay) asks for a visible window // explicitly. The decision stays with the caller: this builder is a pure // function of its arguments, so a test asserting "hidden run stays hidden" // means the same thing on a laptop and on a CI runner. @@ -121,13 +121,40 @@ export function buildFixtureEnv(userDataDir, homeDir, options = {}) { * protocols crawl. Only that isolated virtual display gets a visible window; * nobody is watching it. * - * This is the one ambient read the launch environment needs, kept out of - * `buildFixtureEnv` so the builder stays a pure function. Callers compose it: - * `showWindow: wantVisible || isCiLinuxDisplay()`. + * This helper stays Linux-only so tests can assert the xvfb case in isolation. + * Call sites that also need GitHub macOS runners (App Nap) should compose + * `isCiIsolatedDisplay()` instead. * * @param {NodeJS.ProcessEnv} [env] * @param {NodeJS.Platform} [platform] */ export function isCiLinuxDisplay(env = process.env, platform = process.platform) { - return Boolean(env.CI) && platform === 'linux'; + return isTruthyCiFlag(env.CI) && platform === 'linux'; +} + +/** + * Isolated CI displays that throttle a hidden Electron window. Linux CI uses + * xvfb; GitHub's macOS runners App-Nap background windows the same way. Local + * developer machines stay hidden so a suite does not steal focus. + * + * This is the ambient read the launch environment needs, kept out of + * `buildFixtureEnv` so the builder stays a pure function. Callers compose it: + * `showWindow: wantVisible || isCiIsolatedDisplay()`. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {NodeJS.Platform} [platform] + */ +export function isCiIsolatedDisplay(env = process.env, platform = process.platform) { + return isCiLinuxDisplay(env, platform) || (isTruthyCiFlag(env.CI) && platform === 'darwin'); +} + +/** + * `CI=false` and `CI=0` must stay hidden. `Boolean("false")` is true. + * + * @param {string | undefined} value + */ +function isTruthyCiFlag(value) { + if (value == null) return false; + const normalized = String(value).trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; } diff --git a/scripts/fixture-window.mjs b/scripts/fixture-window.mjs index 148289d29e..d2b1b77029 100644 --- a/scripts/fixture-window.mjs +++ b/scripts/fixture-window.mjs @@ -38,7 +38,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { _electron as electron } from '@playwright/test'; import { closeElectronApplication } from './electron-lifecycle.mjs'; -import { buildFixtureEnv, isCiLinuxDisplay } from './fixture-env.mjs'; +import { buildFixtureEnv, isCiIsolatedDisplay } from './fixture-env.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const DESKTOP_DIR = join(ROOT, 'apps', 'desktop'); @@ -157,9 +157,9 @@ export async function withFixtureWindow(scenario, options, fn) { // ref's build from a temporary worktree through the same launcher. desktopDir = DESKTOP_DIR, } = options ?? {}; - // xvfb throttles a hidden window's compositor to ~1fps; only that isolated - // display gets a visible window. Local hit tests stay accessory/Dock-hidden. - const ciVisible = isCiLinuxDisplay(); + // Isolated CI displays throttle a hidden window; only those runners get a + // visible window. Local hit tests stay accessory/Dock-hidden. + const ciVisible = isCiIsolatedDisplay(); const launchArgs = mapWindowInactive && !ciVisible ? inactiveWindowElectronArgs() : ['.']; const userDataDir = await mkdtemp(join(tmpdir(), 'maka-fixture-'));