Skip to content

Commit 676320c

Browse files
committed
improve desktop accessibility coverage
Generated-by: Codex
1 parent dd4b2d0 commit 676320c

41 files changed

Lines changed: 1721 additions & 83 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ jobs:
4444
- name: Test CI planner
4545
run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs
4646

47+
- name: Test AX tree audit contract
48+
run: node --test scripts/ax-tree-audit.test.mjs
49+
4750
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
4851
if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.cli_package == 'true'
4952
with:
@@ -147,6 +150,12 @@ jobs:
147150
if: steps.plan.outputs.e2e == 'true'
148151
run: xvfb-run -a npm exec -w @maka/desktop -- playwright test --config e2e/playwright.config.ts
149152

153+
- name: Browser WebContentsView semantic smoke
154+
if: steps.plan.outputs.e2e == 'true'
155+
# Hosted Linux runners cannot configure Electron's SUID helper. This
156+
# smoke loads only its loopback fixture; production stays sandboxed.
157+
run: xvfb-run -a npm exec --workspace @maka/desktop -- electron --no-sandbox scripts/browser-observe-act-smoke.mjs
158+
150159
- name: Alignment audit
151160
if: steps.plan.outputs.e2e == 'true'
152161
run: xvfb-run -a node scripts/audit-alignment.mjs
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { CDPSession, Page } from '@playwright/test';
2+
import { expect, test, COMPOSER_INPUT } from './fixtures';
3+
import { auditAxTree } from '../../../scripts/ax-tree-audit.mjs';
4+
import { groupedNav } from '../src/renderer/settings/settings-nav';
5+
6+
function exactNameWithOptionalBadge(label: string): RegExp {
7+
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8+
return new RegExp(`^${escaped}(?: Beta)?$`);
9+
}
10+
11+
async function assertAxHealth(cdp: CDPSession, surface: string): Promise<void> {
12+
const result = await cdp.send('Accessibility.getFullAXTree');
13+
const audit = auditAxTree(result.nodes);
14+
expect(audit.problems, `${surface} exposes an unhealthy AX tree`).toEqual([]);
15+
}
16+
17+
async function openSettings(page: Page): Promise<void> {
18+
const sidebarToggle = page.getByRole('button', { name: '展开侧边栏' });
19+
if (await sidebarToggle.isVisible()) await sidebarToggle.click();
20+
await page.getByRole('button', { name: '设置', exact: true }).click();
21+
await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible();
22+
}
23+
24+
test('every settings page exposes named actionable controls', async ({ window: page }) => {
25+
await openSettings(page);
26+
const cdp = await page.context().newCDPSession(page);
27+
const navigation = page.getByRole('navigation', { name: '设置分组' });
28+
await expect(page.getByRole('main')).toHaveCount(1);
29+
const sectionLabels = (await navigation.getByRole('button').allTextContents())
30+
.map((label) => label.trim().replace(/\s*Beta$/, ''))
31+
.filter((label) => label.length > 0 && label !== '返回应用');
32+
const expectedSectionLabels = groupedNav('zh')
33+
.flatMap(({ items }) => items)
34+
.filter(({ enabled }) => enabled)
35+
.map(({ label }) => label);
36+
expect(sectionLabels, 'settings navigation must expose every enabled page in source order').toEqual(
37+
expectedSectionLabels,
38+
);
39+
40+
for (const section of sectionLabels) {
41+
const sectionButton = navigation.getByRole('button', {
42+
name: exactNameWithOptionalBadge(section),
43+
});
44+
await sectionButton.click();
45+
await expect(sectionButton).toHaveAttribute('aria-current', 'page');
46+
await expect(page.getByRole('heading', { name: section, exact: true })).toBeVisible();
47+
await assertAxHealth(cdp, `settings/${section}`);
48+
}
49+
});
50+
51+
test('module pages and global overlays expose named actionable controls', async ({
52+
window: page,
53+
}) => {
54+
const cdp = await page.context().newCDPSession(page);
55+
await expect(page.getByRole('link', { name: '跳到主要内容' })).toHaveCount(1);
56+
await expect(page.getByRole('link', { name: 'Skip to content' })).toHaveCount(0);
57+
const sidebarToggle = page.getByRole('button', { name: '展开侧边栏' });
58+
if (await sidebarToggle.isVisible()) await sidebarToggle.click();
59+
const navigation = page.getByRole('navigation', { name: '任务列表' });
60+
61+
await navigation.getByRole('button', { name: '扩展', exact: true }).click();
62+
await expect(page.getByRole('main')).toHaveCount(1);
63+
await expect(page.getByRole('region', { name: '扩展', exact: true })).toBeVisible();
64+
const extensionsNavigation = page.getByRole('navigation', { name: // });
65+
await expect(
66+
extensionsNavigation.getByRole('button', { name: '技能', exact: true }),
67+
).toHaveAttribute('aria-current', 'page');
68+
await assertAxHealth(cdp, 'extensions/skills');
69+
const mcpButton = extensionsNavigation.getByRole('button', { name: 'MCP', exact: true });
70+
await mcpButton.click();
71+
await expect(mcpButton).toHaveAttribute('aria-current', 'page');
72+
await assertAxHealth(cdp, 'extensions/mcp');
73+
74+
await navigation.getByRole('button', { name: // }).click();
75+
const automationsNavigation = page.getByRole('navigation', { name: // });
76+
await expect(
77+
automationsNavigation.getByRole('button', { name: '定时任务', exact: true }),
78+
).toHaveAttribute('aria-current', 'page');
79+
await assertAxHealth(cdp, 'automations/scheduled-tasks');
80+
const dailyReviewButton = automationsNavigation.getByRole('button', {
81+
name: '每日回顾',
82+
exact: true,
83+
});
84+
await dailyReviewButton.click();
85+
await expect(dailyReviewButton).toHaveAttribute('aria-current', 'page');
86+
await assertAxHealth(cdp, 'automations/daily-review');
87+
88+
await page.keyboard.press('Shift+Slash');
89+
const keyboardHelpDialog = page.getByRole('dialog', { name: '键盘快捷键' });
90+
await expect(keyboardHelpDialog).toBeVisible();
91+
await assertAxHealth(cdp, 'overlay/keyboard-help');
92+
await page.keyboard.press('Escape');
93+
await expect(keyboardHelpDialog).toBeHidden();
94+
95+
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k');
96+
const commandPaletteDialog = page.getByRole('dialog', { name: '命令面板' });
97+
await expect(commandPaletteDialog).toBeVisible();
98+
await assertAxHealth(cdp, 'overlay/command-palette');
99+
await page.keyboard.press('Escape');
100+
});
101+
102+
test('composer and workbar entry points expose named actionable controls', async ({
103+
window: page,
104+
}) => {
105+
const cdp = await page.context().newCDPSession(page);
106+
await expect(page.getByRole('main')).toHaveCount(1);
107+
await expect(page.getByRole('region', { name: '新任务对话' })).toBeVisible();
108+
await assertAxHealth(cdp, 'conversation/new-task');
109+
110+
const composer = page.locator(COMPOSER_INPUT);
111+
await composer.fill('create a session for accessibility coverage');
112+
await composer.press('Enter');
113+
await expect(page.getByRole('main')).toHaveCount(1);
114+
await expect(page.getByRole('region', { name: // })).toBeVisible();
115+
await assertAxHealth(cdp, 'conversation/session');
116+
117+
await page.getByRole('button', { name: '展开任务工作栏' }).click();
118+
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
119+
await assertAxHealth(cdp, 'workbar/launcher');
120+
121+
const workbarPanels = [
122+
'侧边对话',
123+
'变更',
124+
'终端',
125+
'浏览器',
126+
'生成文件',
127+
'待办',
128+
'追踪',
129+
] as const;
130+
for (const panel of workbarPanels) {
131+
await page
132+
.getByRole('list', { name: '打开工具' })
133+
.getByRole('button', { name: new RegExp(`^${panel}(?: |$)`) })
134+
.click();
135+
const activeTab = page.getByRole('tab', { name: new RegExp(panel) });
136+
await expect(activeTab).toBeVisible();
137+
await expect(activeTab).toHaveAttribute('aria-selected', 'true');
138+
await assertAxHealth(cdp, `workbar/${panel}`);
139+
if (panel !== workbarPanels.at(-1)) {
140+
await page.getByRole('button', { name: '打开工作栏标签' }).first().click();
141+
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
142+
}
143+
}
144+
});

apps/desktop/e2e/sidebar-project-row.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ test('task row action menu accepts pointer selection', async ({
8888
const sidebar = page.getByRole('navigation', { name: '任务列表' });
8989
const taskRow = sessionRow(sidebar, `${LONG_SIDEBAR_SESSION_PREFIX}00`);
9090
await taskRow.hover();
91-
await taskRow.getByRole('button', { name: '任务操作', exact: true }).click();
91+
await taskRow.getByRole('button', { name: /$/ }).click();
9292

9393
const rename = page.getByRole('menuitem', { name: '重命名', exact: true });
9494
await expect(rename).toBeVisible();

apps/desktop/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"smoke:real-window": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs",
3939
"smoke:programmatic-window": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs --programmatic-only",
4040
"launch:fixture": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs --manual",
41-
"smoke:browser": "npm run build:workspace-deps && npm run build:main && electron scripts/browser-observe-act-smoke.mjs"
41+
"smoke:browser": "npm run build:workspace-deps && npm run build:main && npm run smoke:browser:run",
42+
"smoke:browser:run": "electron scripts/browser-observe-act-smoke.mjs"
4243
},
4344
"dependencies": {
4445
"@astryxdesign/core": "0.4.0",

apps/desktop/scripts/browser-observe-act-smoke.mjs

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ function findRef(snapshot, needle) {
6767
return null;
6868
}
6969

70+
async function waitForPageValue(read, expected, timeoutMs = 1_000) {
71+
const deadline = Date.now() + timeoutMs;
72+
let actual;
73+
do {
74+
const remainingMs = deadline - Date.now();
75+
if (remainingMs <= 0) break;
76+
actual = await read(remainingMs);
77+
if (actual === expected) return actual;
78+
const pollDelayMs = Math.min(20, deadline - Date.now());
79+
if (pollDelayMs > 0) {
80+
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
81+
}
82+
} while (Date.now() < deadline);
83+
return actual;
84+
}
85+
7086
async function runSmoke() {
7187
// Loopback fixture server, OS-assigned port — deterministic, no network.
7288
const server = createServer((_req, res) => {
@@ -104,7 +120,8 @@ async function runSmoke() {
104120
const controller2 = new BrowserViewController(win, 'smoke-backstop', () => {});
105121
check(
106122
'security backstop installs once per partition (no listener pileup)',
107-
partition.listenerCount('will-download') === downloadListenersBefore,
123+
downloadListenersBefore === 1 &&
124+
partition.listenerCount('will-download') === downloadListenersBefore,
108125
`${downloadListenersBefore} -> ${partition.listenerCount('will-download')}`,
109126
);
110127
await controller2.dispose();
@@ -138,21 +155,6 @@ async function runSmoke() {
138155
throw new Error('snapshot did not expose the expected numbered refs');
139156
}
140157

141-
// ACT by numbered ref: type into [queryRef], then click [submitRef].
142-
const fill = await page.fillText(queryRef, 'hello');
143-
check('fillText verifies the typed value by ref', fill.verified === true && fill.actual === 'hello', `actual=${JSON.stringify(fill.actual)}`);
144-
145-
const clicked = await page.click(submitRef);
146-
check('click resolves a single match by ref', clicked.matches_n === 1, `matches_n=${clicked.matches_n}`);
147-
148-
// VERIFY the act landed in the real DOM.
149-
const out = await page.evaluate('document.getElementById("out").textContent');
150-
check('the click handler wrote clicked:hello', out === 'clicked:hello', `out=${JSON.stringify(out)}`);
151-
152-
// EXTRACT: real HTML → markdown (mirrors browser_extract).
153-
const markdown = htmlToMarkdown(String(await page.evaluate('document.body.outerHTML')));
154-
check('extract markdown reflects the page text', markdown.includes('clicked:hello'));
155-
156158
// ── Visible-lease, end to end through BrowserSession + the real host ──────
157159
// Exercises the wiring the fake-host unit tests can't: controller
158160
// .hasLiveViewport() ← setViewport, the host's canDrive, and the gate firing
@@ -218,23 +220,45 @@ async function runSmoke() {
218220
takeover: 'mutate',
219221
});
220222
setTimeout(() => leaseManager.get('leaseS').setViewport({ x: 0, y: 0, width: 1024, height: 768 }), 30);
221-
let raceLanded = true;
223+
let raceLanded = false;
222224
try {
223-
await racingType;
225+
const result = await racingType;
226+
raceLanded = result.verified === true && result.actual === 'leased';
224227
} catch {
225228
raceLanded = false;
226229
}
227-
check('visible-lease waits out a modal-close viewport restore so an approved mutate lands', raceLanded);
230+
check('visible-lease waits out a modal-close viewport restore and verifies the typed value', raceLanded);
228231

229232
// With the viewport back, a click drives the real DOM.
230-
await withBrowserPage('leaseS', 'click', (lease) => lease.click(leaseSubmit), { takeover: 'mutate' });
231-
const leaseOut = await withBrowserPage(
233+
const leaseClick = await withBrowserPage('leaseS', 'click', (lease) => lease.click(leaseSubmit), {
234+
takeover: 'mutate',
235+
});
236+
check(
237+
'visible-lease click resolves a single match by ref',
238+
leaseClick.matches_n === 1,
239+
`matches_n=${leaseClick.matches_n}`,
240+
);
241+
const leaseOut = await waitForPageValue(
242+
(remainingMs) =>
243+
withBrowserPage(
244+
'leaseS',
245+
'read',
246+
(lease) => lease.evaluate('document.getElementById("out").textContent'),
247+
{ takeover: 'observe', timeoutMs: remainingMs },
248+
),
249+
'clicked:leased',
250+
);
251+
check('visible-lease allows + lands a click once shown with a viewport', leaseOut === 'clicked:leased');
252+
253+
// EXTRACT through the same production session path.
254+
const leaseHtml = await withBrowserPage(
232255
'leaseS',
233-
'read',
234-
(lease) => lease.evaluate('document.getElementById("out").textContent'),
256+
'extract',
257+
(lease) => lease.evaluate('document.body.outerHTML'),
235258
{ takeover: 'observe' },
236259
);
237-
check('visible-lease allows + lands a click once shown with a viewport', leaseOut === 'clicked:leased');
260+
const markdown = htmlToMarkdown(String(leaseHtml));
261+
check('extract markdown reflects the page effect', markdown.includes('clicked:leased'));
238262

239263
// Throttling tracks shown-ness (the P2 fix): a shown view runs full-speed so
240264
// native clicks composite, and HIDING it restores background throttling so a

apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const originalGlobals = {
2020
matchMedia: globalThis.matchMedia,
2121
HTMLElement: globalThis.HTMLElement,
2222
HTMLIFrameElement: globalThis.HTMLIFrameElement,
23+
getComputedStyle: globalThis.getComputedStyle,
2324
requestAnimationFrame: globalThis.requestAnimationFrame,
2425
cancelAnimationFrame: globalThis.cancelAnimationFrame,
2526
IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
@@ -636,6 +637,9 @@ async function renderPage(options: {
636637
matchMedia,
637638
HTMLElement: window.HTMLElement,
638639
HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
640+
getComputedStyle: (element: Element) => ({
641+
color: (element as HTMLElement).style?.color || 'currentColor',
642+
}) as CSSStyleDeclaration,
639643
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0),
640644
cancelAnimationFrame: (handle: number) => clearTimeout(handle),
641645
IS_REACT_ACT_ENVIRONMENT: true,

apps/desktop/src/renderer/app-shell.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3120,6 +3120,9 @@ function AppShellContent({
31203120
// Reset conversation-owned scroll state without remounting the
31213121
// composer: its contenteditable DOM carries the live draft.
31223122
conversationKey={activeId}
3123+
scrollToBottomLabel={
3124+
desktopConversationCopy.actions.scrollMainToBottom
3125+
}
31233126
hidden={navSelection.section !== 'sessions'}
31243127
composer={
31253128
<>
@@ -3603,6 +3606,7 @@ function AppShellContent({
36033606
hostId: newTask.directoryHost.hostId,
36043607
name: newTask.directoryHost.profile.name,
36053608
} : undefined}
3609+
returnFocusTo={newTask.directoryOpener}
36063610
onClose={newTask.closeDirectoryPicker}
36073611
onRegistered={(project, host) => {
36083612
void newTask.acceptRegisteredProject(project, host).catch((error) => {

0 commit comments

Comments
 (0)