Skip to content

Commit 95439eb

Browse files
committed
improve desktop accessibility coverage
Generated-by: Codex
1 parent 62556ab commit 95439eb

35 files changed

Lines changed: 1513 additions & 78 deletions

.github/workflows/ci.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,15 @@ jobs:
214214
run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; }
215215
- name: Desktop e2e
216216
run: xvfb-run -a npm --workspace @maka/desktop run e2e
217+
- name: Browser WebContentsView semantic smoke
218+
# Hosted Linux runners cannot configure Electron's SUID helper. This
219+
# smoke loads only its loopback fixture; production stays sandboxed.
220+
run: xvfb-run -a npm exec --workspace @maka/desktop -- electron --no-sandbox scripts/browser-observe-act-smoke.mjs
217221
- name: Alignment audit
218222
run: xvfb-run -a node scripts/audit-alignment.mjs
219-
# Storybook build + initial render smoke. Embedded mode disables every play
220-
# function, so this lane never duplicates desktop interaction or layout E2E.
221-
# It launches Chromium only to catch catalog runtime errors. See FIDELITY.md.
223+
# Storybook builds every source-defined state, executes play functions, and
224+
# audits the resulting Chromium AX tree. Runtime-only Electron and native
225+
# WebContentsView effects remain in the e2e lane above.
222226
storybook:
223227
needs: changes
224228
if: needs.changes.outputs.storybook == 'true'
@@ -230,6 +234,8 @@ jobs:
230234
node-version: '24'
231235
cache: npm
232236
- run: npm ci
237+
- name: AX tree audit contract tests
238+
run: node --test scripts/ax-tree-audit.test.mjs
233239
# Stories import @maka/core / @maka/ui package exports (dist/). The old
234240
# e2e job paid for this via `build:with-deps`; the split job must too.
235241
- name: Build workspace packages
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
await expect(page.getByRole('dialog')).toBeVisible();
90+
await assertAxHealth(cdp, 'overlay/keyboard-help');
91+
await page.keyboard.press('Escape');
92+
93+
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k');
94+
await expect(page.getByRole('dialog')).toBeVisible();
95+
await assertAxHealth(cdp, 'overlay/command-palette');
96+
await page.keyboard.press('Escape');
97+
});
98+
99+
test('composer and workbar entry points expose named actionable controls', async ({
100+
window: page,
101+
}) => {
102+
const cdp = await page.context().newCDPSession(page);
103+
await expect(page.getByRole('main')).toHaveCount(1);
104+
await expect(page.getByRole('region', { name: '新任务对话' })).toBeVisible();
105+
await assertAxHealth(cdp, 'conversation/new-task');
106+
107+
const composer = page.locator(COMPOSER_INPUT);
108+
await composer.fill('create a session for accessibility coverage');
109+
await composer.press('Enter');
110+
await expect(page.getByRole('main')).toHaveCount(1);
111+
await expect(page.getByRole('region', { name: // })).toBeVisible();
112+
await assertAxHealth(cdp, 'conversation/session');
113+
114+
await page.getByRole('button', { name: '展开任务工作栏' }).click();
115+
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
116+
await assertAxHealth(cdp, 'workbar/launcher');
117+
118+
const workbarPanels = [
119+
'侧边对话',
120+
'变更',
121+
'终端',
122+
'浏览器',
123+
'生成文件',
124+
'待办',
125+
'追踪',
126+
] as const;
127+
for (const panel of workbarPanels) {
128+
await page
129+
.getByRole('list', { name: '打开工具' })
130+
.getByRole('button', { name: new RegExp(`^${panel}(?: |$)`) })
131+
.click();
132+
const activeTab = page.getByRole('tab', { name: new RegExp(panel) });
133+
await expect(activeTab).toBeVisible();
134+
await expect(activeTab).toHaveAttribute('aria-selected', 'true');
135+
await assertAxHealth(cdp, `workbar/${panel}`);
136+
if (panel !== workbarPanels.at(-1)) {
137+
await page.getByRole('button', { name: '打开工作栏标签' }).first().click();
138+
await expect(page.getByRole('list', { name: '打开工具' })).toBeVisible();
139+
}
140+
}
141+
});

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: 46 additions & 23 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) => {
@@ -138,21 +154,6 @@ async function runSmoke() {
138154
throw new Error('snapshot did not expose the expected numbered refs');
139155
}
140156

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-
156157
// ── Visible-lease, end to end through BrowserSession + the real host ──────
157158
// Exercises the wiring the fake-host unit tests can't: controller
158159
// .hasLiveViewport() ← setViewport, the host's canDrive, and the gate firing
@@ -218,23 +219,45 @@ async function runSmoke() {
218219
takeover: 'mutate',
219220
});
220221
setTimeout(() => leaseManager.get('leaseS').setViewport({ x: 0, y: 0, width: 1024, height: 768 }), 30);
221-
let raceLanded = true;
222+
let raceLanded = false;
222223
try {
223-
await racingType;
224+
const result = await racingType;
225+
raceLanded = result.verified === true && result.actual === 'leased';
224226
} catch {
225227
raceLanded = false;
226228
}
227-
check('visible-lease waits out a modal-close viewport restore so an approved mutate lands', raceLanded);
229+
check('visible-lease waits out a modal-close viewport restore and verifies the typed value', raceLanded);
228230

229231
// 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(
232+
const leaseClick = await withBrowserPage('leaseS', 'click', (lease) => lease.click(leaseSubmit), {
233+
takeover: 'mutate',
234+
});
235+
check(
236+
'visible-lease click resolves a single match by ref',
237+
leaseClick.matches_n === 1,
238+
`matches_n=${leaseClick.matches_n}`,
239+
);
240+
const leaseOut = await waitForPageValue(
241+
(remainingMs) =>
242+
withBrowserPage(
243+
'leaseS',
244+
'read',
245+
(lease) => lease.evaluate('document.getElementById("out").textContent'),
246+
{ takeover: 'observe', timeoutMs: remainingMs },
247+
),
248+
'clicked:leased',
249+
);
250+
check('visible-lease allows + lands a click once shown with a viewport', leaseOut === 'clicked:leased');
251+
252+
// EXTRACT through the same production session path.
253+
const leaseHtml = await withBrowserPage(
232254
'leaseS',
233-
'read',
234-
(lease) => lease.evaluate('document.getElementById("out").textContent'),
255+
'extract',
256+
(lease) => lease.evaluate('document.body.outerHTML'),
235257
{ takeover: 'observe' },
236258
);
237-
check('visible-lease allows + lands a click once shown with a viewport', leaseOut === 'clicked:leased');
259+
const markdown = htmlToMarkdown(String(leaseHtml));
260+
check('extract markdown reflects the page effect', markdown.includes('clicked:leased'));
238261

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

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3063,6 +3063,9 @@ function AppShellContent({
30633063
// Reset conversation-owned scroll state without remounting the
30643064
// composer: its contenteditable DOM carries the live draft.
30653065
conversationKey={activeId}
3066+
scrollToBottomLabel={
3067+
desktopConversationCopy.actions.scrollMainToBottom
3068+
}
30663069
hidden={navSelection.section !== 'sessions'}
30673070
composer={
30683071
<>

0 commit comments

Comments
 (0)