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.
88 changes: 71 additions & 17 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, activate },
}) => {
await page.setViewportSize({ width: 900, height: 700 });
const longLine = Array.from(
Expand Down Expand Up @@ -101,23 +101,77 @@ 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);
const selectionGesture = 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 startRange = document.createRange();
startRange.setStart(textNode, 0);
startRange.setEnd(textNode, 1);
const anchorRange = document.createRange();
anchorRange.setStart(textNode, 0);
anchorRange.setEnd(textNode, Math.min(12, textNode.textContent.length));
const startRect = startRange.getBoundingClientRect();
const anchorRect = anchorRange.getBoundingClientRect();
if (
startRect.width <= 0 || startRect.height <= 0 ||
anchorRect.width <= 0 || anchorRect.height <= 0
) {
throw new Error('code line text has no visible range');
}
return {
startX: startRect.left + startRect.width / 2,
anchorX: anchorRect.right - startRect.width / 2,
y: startRect.top + startRect.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, selectionGesture.y);
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
}
};
await page.bringToFront();
const nativeFocus = await activate();
expect(nativeFocus.appActive).toBe(true);
expect(nativeFocus.windowFocused).toBe(true);
await page.mouse.click(metrics.rect.x + metrics.rect.width / 2, selectionGesture.y);
await expect.poll(() => page.evaluate(() => document.hasFocus())).toBe(true);
await viewport.evaluate(() => window.getSelection()?.removeAllRanges());

let afterSelectionDrag: { scrollLeft: number; selection: string } | undefined;
await page.mouse.move(selectionGesture.startX, selectionGesture.y);
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);
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();
try {
await moveAcrossPaintedFrames(selectionGesture.startX, selectionGesture.anchorX, 8);
await expect.poll(
() => viewport.evaluate(() => window.getSelection()?.toString().length ?? 0),
).toBeGreaterThan(3);
await moveAcrossPaintedFrames(
selectionGesture.anchorX,
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();
}
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
88 changes: 75 additions & 13 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,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 @@ -338,6 +338,7 @@ async function withE2eWindow(
gitReviewExtraFiles,
parentRemovalSessions,
newTaskProject,
windowSize,
}: {
seed: boolean;
readinessSelector: string;
Expand All @@ -353,8 +354,16 @@ 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>,
use: (
page: Page,
context: {
userDataDir: string;
activateWindow(): Promise<{ appActive: boolean; windowFocused: boolean }>;
},
) => Promise<void>,
): Promise<void> {
const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-'));
// Lives inside the throwaway userData dir so the existing teardown removes
Expand All @@ -378,23 +387,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 All @@ -420,7 +440,29 @@ async function withE2eWindow(
const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : '';
throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error });
}
await use(page, { userDataDir });
const activateWindow = async () => {
await app.evaluate(({ app: electronApp }) => {
if (process.platform === 'darwin') electronApp.focus({ steal: true });
else electronApp.focus();
});
const windowHandle = await app.browserWindow(page);
let windowFocused = false;
try {
windowFocused = await windowHandle.evaluate((window) => {
if (window.isMinimized()) window.restore();
window.show();
window.focus();
return window.isFocused();
});
} finally {
await windowHandle.dispose();
}
const appActive = await app.evaluate(({ app: electronApp }) =>
process.platform === 'darwin' ? electronApp.isActive() : true,
);
return { appActive, windowFocused };
};
await use(page, { userDataDir, activateWindow });
} finally {
try {
if (app) await closeElectronApplication(app, 5_000);
Expand All @@ -432,6 +474,10 @@ async function withE2eWindow(

export const test = base.extend<{
window: Page;
codeScrollWindow: {
page: Page;
activate(): Promise<{ appActive: boolean; windowFocused: boolean }>;
};
onboardingWindow: Page;
gitReviewWindow: { page: Page; projectRoot: string };
invocableSkillsWindow: Page;
Expand All @@ -447,6 +493,18 @@ 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,
}, async (page, { activateWindow }) => {
await use({ page, activate: activateWindow });
});
},
onboardingWindow: async ({}, use) => {
await withE2eWindow({
seed: false,
Expand Down Expand Up @@ -532,6 +590,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 @@ -545,6 +606,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
Loading
Loading