Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment thread
1625567290 marked this conversation as resolved.
timeout-minutes: 30
env:
ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Binary file added .maka-shots/3137-macos-overlay-scrollbar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 62 additions & 16 deletions apps/desktop/e2e/code-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<void>((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);
Expand Down
51 changes: 40 additions & 11 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -340,6 +340,7 @@ async function withE2eWindow(
gitReviewExtraFiles,
parentRemovalSessions,
newTaskProject,
windowSize,
}: {
seed: boolean;
readinessSelector: string;
Expand All @@ -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<void>,
): Promise<void> {
Expand All @@ -380,23 +383,34 @@ 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());
if (mainLogs.length > 20) mainLogs.shift();
});
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')}` : '';
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 —
Expand Down
27 changes: 18 additions & 9 deletions apps/desktop/e2e/new-task-draft-target.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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: {
Expand Down
Loading
Loading