@stigmer/react 3.1.2 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/composer/SessionComposer.d.ts +6 -5
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +1 -1
- package/composer/SessionComposer.js.map +1 -1
- package/execution/ArtifactPreviewModal.d.ts +5 -5
- package/execution/ArtifactPreviewModal.js +4 -4
- package/execution/ArtifactPreviewModal.js.map +1 -1
- package/execution/FileChangeProgressBar.d.ts +47 -0
- package/execution/FileChangeProgressBar.d.ts.map +1 -0
- package/execution/FileChangeProgressBar.js +49 -0
- package/execution/FileChangeProgressBar.js.map +1 -0
- package/execution/FileChangesView.d.ts.map +1 -1
- package/execution/FileChangesView.js +19 -4
- package/execution/FileChangesView.js.map +1 -1
- package/execution/FileReviewAtoms.d.ts +27 -0
- package/execution/FileReviewAtoms.d.ts.map +1 -0
- package/execution/FileReviewAtoms.js +64 -0
- package/execution/FileReviewAtoms.js.map +1 -0
- package/execution/FileReviewCard.d.ts +2 -2
- package/execution/FileReviewCard.d.ts.map +1 -1
- package/execution/FileReviewCard.js +32 -62
- package/execution/FileReviewCard.js.map +1 -1
- package/execution/MessageEntry.d.ts +0 -9
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +1 -11
- package/execution/MessageEntry.js.map +1 -1
- package/execution/MessageThread.d.ts +3 -4
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +25 -11
- package/execution/MessageThread.js.map +1 -1
- package/execution/PlanArtifactCard.d.ts +16 -10
- package/execution/PlanArtifactCard.d.ts.map +1 -1
- package/execution/PlanArtifactCard.js +31 -7
- package/execution/PlanArtifactCard.js.map +1 -1
- package/execution/PlanCompletionCard.d.ts +4 -4
- package/execution/PlanCompletionCard.d.ts.map +1 -1
- package/execution/PlanCompletionCard.js +4 -4
- package/execution/PlanCompletionCard.js.map +1 -1
- package/execution/PlanStreamingCard.d.ts +7 -2
- package/execution/PlanStreamingCard.d.ts.map +1 -1
- package/execution/PlanStreamingCard.js +11 -6
- package/execution/PlanStreamingCard.js.map +1 -1
- package/execution/index.d.ts +4 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +2 -0
- package/execution/index.js.map +1 -1
- package/execution/use-build-from-plan-hotkey.d.ts +1 -1
- package/execution/use-build-from-plan-hotkey.js +1 -1
- package/execution/useArtifactCopy.d.ts +51 -0
- package/execution/useArtifactCopy.d.ts.map +1 -0
- package/execution/useArtifactCopy.js +75 -0
- package/execution/useArtifactCopy.js.map +1 -0
- package/execution/useArtifactDownload.d.ts.map +1 -1
- package/execution/useArtifactDownload.js +17 -5
- package/execution/useArtifactDownload.js.map +1 -1
- package/execution/useCreateAgentExecution.d.ts +5 -3
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/internal/file-tree/FileTreeNode.d.ts.map +1 -1
- package/internal/file-tree/FileTreeNode.js +2 -2
- package/internal/file-tree/FileTreeNode.js.map +1 -1
- package/library/detect-plan-artifact.d.ts +27 -9
- package/library/detect-plan-artifact.d.ts.map +1 -1
- package/library/detect-plan-artifact.js +26 -6
- package/library/detect-plan-artifact.js.map +1 -1
- package/library/index.d.ts +1 -1
- package/library/index.d.ts.map +1 -1
- package/library/index.js +1 -1
- package/library/index.js.map +1 -1
- package/package.json +4 -4
- package/session/PlanEditor.js +1 -1
- package/session/PlanEditor.js.map +1 -1
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +32 -13
- package/session/SessionViewer.js.map +1 -1
- package/session/plan-document.d.ts +14 -2
- package/session/plan-document.d.ts.map +1 -1
- package/session/plan-document.js +14 -3
- package/session/plan-document.js.map +1 -1
- package/session/usePlanDraft.d.ts +1 -1
- package/session/useSessionConversation.d.ts +9 -2
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +19 -2
- package/session/useSessionConversation.js.map +1 -1
- package/src/composer/SessionComposer.tsx +7 -6
- package/src/execution/ArtifactPreviewModal.tsx +9 -9
- package/src/execution/FileChangeProgressBar.tsx +121 -0
- package/src/execution/FileChangesView.tsx +18 -4
- package/src/execution/FileReviewAtoms.tsx +89 -0
- package/src/execution/FileReviewCard.tsx +57 -90
- package/src/execution/MessageEntry.tsx +0 -62
- package/src/execution/MessageThread.tsx +29 -15
- package/src/execution/PlanArtifactCard.tsx +130 -52
- package/src/execution/PlanCompletionCard.tsx +5 -5
- package/src/execution/PlanStreamingCard.tsx +15 -11
- package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
- package/src/execution/__tests__/FileChangeProgressBar.test.tsx +125 -0
- package/src/execution/__tests__/FileReviewCard.test.tsx +66 -7
- package/src/execution/__tests__/MessageThread.test.tsx +1 -1
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +52 -18
- package/src/execution/__tests__/PlanCompletionCard.test.tsx +2 -2
- package/src/execution/__tests__/PlanStreamingCard.test.tsx +11 -4
- package/src/execution/__tests__/buildThreadItems-plan-completion.test.ts +104 -8
- package/src/execution/__tests__/message-entry.test.tsx +6 -37
- package/src/execution/__tests__/useArtifactCopy.test.tsx +90 -0
- package/src/execution/__tests__/useArtifactDownload.test.tsx +2 -0
- package/src/execution/index.ts +6 -0
- package/src/execution/use-build-from-plan-hotkey.ts +1 -1
- package/src/execution/useArtifactCopy.ts +109 -0
- package/src/execution/useArtifactDownload.ts +17 -5
- package/src/execution/useCreateAgentExecution.ts +5 -3
- package/src/index.ts +6 -0
- package/src/internal/file-tree/FileTreeNode.tsx +13 -2
- package/src/library/__tests__/detect-plan-artifact.test.ts +21 -0
- package/src/library/detect-plan-artifact.ts +32 -10
- package/src/library/index.ts +2 -0
- package/src/session/PlanEditor.tsx +1 -1
- package/src/session/SessionViewer.tsx +50 -18
- package/src/session/__tests__/PlanEditor.test.tsx +4 -4
- package/src/session/__tests__/PlanStreamingDocument.test.tsx +1 -1
- package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +57 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +44 -0
- package/src/session/facets/__tests__/ArtifactsTab.test.tsx +7 -7
- package/src/session/plan-document.ts +14 -3
- package/src/session/usePlanDraft.ts +1 -1
- package/src/session/useSessionConversation.ts +32 -4
- package/src/workspace/EditorTabs.tsx +38 -9
- package/src/workspace/ExplorerTree.tsx +10 -4
- package/src/workspace/FileViewer.tsx +27 -6
- package/src/workspace/WorkspaceContentSearch.tsx +44 -20
- package/src/workspace/WorkspaceEditor.tsx +19 -16
- package/src/workspace/WorkspaceFileSearch.tsx +51 -28
- package/src/workspace/WorkspaceSurface.tsx +106 -48
- package/src/workspace/__tests__/EditorTabs.test.tsx +4 -2
- package/src/workspace/__tests__/ExplorerTree.test.tsx +52 -0
- package/src/workspace/__tests__/FileViewer.test.tsx +18 -0
- package/src/workspace/__tests__/WorkspaceContentSearch.test.tsx +38 -5
- package/src/workspace/__tests__/WorkspaceEditor-initialPanel.test.tsx +19 -0
- package/src/workspace/__tests__/WorkspaceFileSearch.test.tsx +27 -6
- package/src/workspace/__tests__/WorkspaceSurface.test.tsx +70 -0
- package/src/workspace/__tests__/a11y/harness.tsx +140 -0
- package/src/workspace/__tests__/a11y/mock-capabilities.ts +138 -0
- package/src/workspace/__tests__/a11y/workspace-editor.a11y.test.tsx +65 -0
- package/src/workspace/__tests__/a11y/workspace-explorer-search.a11y.test.tsx +112 -0
- package/src/workspace/__tests__/a11y/workspace-viewer-tabs.a11y.test.tsx +69 -0
- package/styles.css +1 -1
- package/workspace/EditorTabs.d.ts +21 -3
- package/workspace/EditorTabs.d.ts.map +1 -1
- package/workspace/EditorTabs.js +18 -6
- package/workspace/EditorTabs.js.map +1 -1
- package/workspace/ExplorerTree.js +8 -4
- package/workspace/ExplorerTree.js.map +1 -1
- package/workspace/FileViewer.d.ts.map +1 -1
- package/workspace/FileViewer.js +19 -8
- package/workspace/FileViewer.js.map +1 -1
- package/workspace/WorkspaceContentSearch.d.ts.map +1 -1
- package/workspace/WorkspaceContentSearch.js +23 -2
- package/workspace/WorkspaceContentSearch.js.map +1 -1
- package/workspace/WorkspaceEditor.d.ts.map +1 -1
- package/workspace/WorkspaceEditor.js +8 -10
- package/workspace/WorkspaceEditor.js.map +1 -1
- package/workspace/WorkspaceFileSearch.d.ts.map +1 -1
- package/workspace/WorkspaceFileSearch.js +23 -3
- package/workspace/WorkspaceFileSearch.js.map +1 -1
- package/workspace/WorkspaceSurface.d.ts.map +1 -1
- package/workspace/WorkspaceSurface.js +54 -18
- package/workspace/WorkspaceSurface.js.map +1 -1
|
@@ -146,6 +146,25 @@ describe("WorkspaceEditor initialPanel", () => {
|
|
|
146
146
|
expect(screen.getByText("new-repo")).toBeTruthy();
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
it("labels the manual git inputs for assistive tech (no OAuth connection)", () => {
|
|
150
|
+
// With no gitHubConnection, the panel falls back to manual URL/branch entry.
|
|
151
|
+
render(
|
|
152
|
+
<WorkspaceEditor
|
|
153
|
+
workspace={createMockWorkspace()}
|
|
154
|
+
enableGitHub
|
|
155
|
+
enableLocal={false}
|
|
156
|
+
initialPanel="github"
|
|
157
|
+
/>,
|
|
158
|
+
);
|
|
159
|
+
// Accessible names come from aria-label, not the disappearing placeholder.
|
|
160
|
+
expect(
|
|
161
|
+
screen.getByRole("textbox", { name: "Git repository URL" }),
|
|
162
|
+
).toBeTruthy();
|
|
163
|
+
expect(
|
|
164
|
+
screen.getByRole("textbox", { name: "Branch (optional)" }),
|
|
165
|
+
).toBeTruthy();
|
|
166
|
+
});
|
|
167
|
+
|
|
149
168
|
it("shows loading state when GitHub is checking connection with initialPanel='github'", () => {
|
|
150
169
|
const connection = createMockGitHubConnection({ isLoading: true });
|
|
151
170
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { render, screen, fireEvent, cleanup
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
3
3
|
import { WorkspaceFileSearch } from "../WorkspaceFileSearch";
|
|
4
4
|
import { __clearWorkspaceListingCache } from "../workspaceListingCache";
|
|
5
5
|
import type { WorkspaceEntry } from "../useWorkspaceEntries";
|
|
@@ -74,7 +74,7 @@ describe("WorkspaceFileSearch", () => {
|
|
|
74
74
|
|
|
75
75
|
typeQuery("button");
|
|
76
76
|
const option = await screen.findByRole("option");
|
|
77
|
-
fireEvent.click(
|
|
77
|
+
fireEvent.click(option);
|
|
78
78
|
|
|
79
79
|
expect(onOpenFile).toHaveBeenCalledWith(entry.id, "src/button.tsx");
|
|
80
80
|
// Single entry → no group header.
|
|
@@ -126,9 +126,7 @@ describe("WorkspaceFileSearch", () => {
|
|
|
126
126
|
);
|
|
127
127
|
typeQuery("button");
|
|
128
128
|
const option = await screen.findByRole("option");
|
|
129
|
-
expect(
|
|
130
|
-
"true",
|
|
131
|
-
);
|
|
129
|
+
expect(option.getAttribute("aria-current")).toBe("true");
|
|
132
130
|
});
|
|
133
131
|
|
|
134
132
|
it("shows a no-match state", async () => {
|
|
@@ -140,7 +138,10 @@ describe("WorkspaceFileSearch", () => {
|
|
|
140
138
|
/>,
|
|
141
139
|
);
|
|
142
140
|
typeQuery("zzzzz");
|
|
143
|
-
|
|
141
|
+
// Assert the visible message, not the parallel sr-only live-region copy.
|
|
142
|
+
expect(
|
|
143
|
+
await screen.findByText(/no files matching/i, { ignore: ".sr-only" }),
|
|
144
|
+
).toBeTruthy();
|
|
144
145
|
});
|
|
145
146
|
|
|
146
147
|
it("renders a truncation notice from a notice entry", async () => {
|
|
@@ -184,6 +185,26 @@ describe("WorkspaceFileSearch", () => {
|
|
|
184
185
|
expect(onOpenFile.mock.calls[0][0]).toBe(entry.id);
|
|
185
186
|
});
|
|
186
187
|
|
|
188
|
+
it("announces result status in a polite live region", async () => {
|
|
189
|
+
render(
|
|
190
|
+
<WorkspaceFileSearch
|
|
191
|
+
entries={[makeEntry()]}
|
|
192
|
+
lister={vi.fn(async () => FILES)}
|
|
193
|
+
onOpenFile={vi.fn()}
|
|
194
|
+
/>,
|
|
195
|
+
);
|
|
196
|
+
const status = screen.getByRole("status");
|
|
197
|
+
expect(status.getAttribute("aria-live")).toBe("polite");
|
|
198
|
+
|
|
199
|
+
typeQuery("button");
|
|
200
|
+
await screen.findByRole("option");
|
|
201
|
+
expect(status.textContent).toMatch(/matching file/i);
|
|
202
|
+
|
|
203
|
+
typeQuery("zzzzz");
|
|
204
|
+
await screen.findByText(/no files matching/i, { ignore: ".sr-only" });
|
|
205
|
+
expect(status.textContent).toMatch(/no files matching/i);
|
|
206
|
+
});
|
|
207
|
+
|
|
187
208
|
it("caps rendered results and reports the total", async () => {
|
|
188
209
|
const entry = makeEntry();
|
|
189
210
|
const many: WorkspaceFileEntry[] = Array.from({ length: 250 }, (_, i) => ({
|
|
@@ -152,6 +152,23 @@ describe("WorkspaceSurface", () => {
|
|
|
152
152
|
expect(tab.getAttribute("aria-selected")).toBe("true");
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
it("associates the active tab with the single editor tabpanel", () => {
|
|
156
|
+
renderSurface();
|
|
157
|
+
const tab = screen.getByRole("tab", { name: /main\.go/ });
|
|
158
|
+
const panel = screen.getByRole("tabpanel");
|
|
159
|
+
const panelId = panel.getAttribute("id");
|
|
160
|
+
expect(panelId).toBeTruthy();
|
|
161
|
+
expect(tab.getAttribute("id")).toBeTruthy();
|
|
162
|
+
// Tab -> panel and panel -> active tab, both directions wired.
|
|
163
|
+
expect(tab.getAttribute("aria-controls")).toBe(panelId);
|
|
164
|
+
expect(panel.getAttribute("aria-labelledby")).toBe(tab.getAttribute("id"));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("renders no tabpanel when no editor is open (nothing to label)", () => {
|
|
168
|
+
renderSurface({ selectedFile: null, editors: [] });
|
|
169
|
+
expect(screen.queryByRole("tabpanel")).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
155
172
|
it("collapses back to chat when the toolbar control is clicked", () => {
|
|
156
173
|
const { onCollapse } = renderSurface();
|
|
157
174
|
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
|
|
@@ -242,6 +259,59 @@ describe("WorkspaceSurface extraViews", () => {
|
|
|
242
259
|
fireEvent.keyDown(search, { key: "ArrowDown" });
|
|
243
260
|
expect(screen.getByTestId("config-probe")).toBeTruthy();
|
|
244
261
|
});
|
|
262
|
+
|
|
263
|
+
it("reaches an injected view beyond the second by keyboard (focus follows selection)", () => {
|
|
264
|
+
// Regression guard: the old handler moved selection but never DOM focus, so
|
|
265
|
+
// focus stayed pinned on the entered button and every view past the second
|
|
266
|
+
// (Config, Changes, …) was keyboard-unreachable. Each arrow must advance
|
|
267
|
+
// focus so the next arrow computes from the new position.
|
|
268
|
+
renderSurface({ extraViews: [configView, changesView] });
|
|
269
|
+
const explorer = screen.getByRole("radio", { name: "Explorer" });
|
|
270
|
+
explorer.focus();
|
|
271
|
+
expect(document.activeElement).toBe(explorer);
|
|
272
|
+
|
|
273
|
+
// Step 1: Explorer -> Search, focus moves with the selection.
|
|
274
|
+
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
|
|
275
|
+
const search = screen.getByRole("radio", { name: "Search" });
|
|
276
|
+
expect(document.activeElement).toBe(search);
|
|
277
|
+
expect(search.getAttribute("aria-checked")).toBe("true");
|
|
278
|
+
|
|
279
|
+
// Step 2: Search -> Config. The step the old code could not make.
|
|
280
|
+
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
|
|
281
|
+
const config = screen.getByRole("radio", { name: "Config" });
|
|
282
|
+
expect(document.activeElement).toBe(config);
|
|
283
|
+
expect(config.getAttribute("aria-checked")).toBe("true");
|
|
284
|
+
expect(screen.getByTestId("config-probe")).toBeTruthy();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("jumps to the last and first rail views with End and Home", () => {
|
|
288
|
+
renderSurface({ extraViews: [configView, changesView] });
|
|
289
|
+
const explorer = screen.getByRole("radio", { name: "Explorer" });
|
|
290
|
+
explorer.focus();
|
|
291
|
+
|
|
292
|
+
fireEvent.keyDown(document.activeElement!, { key: "End" });
|
|
293
|
+
const changes = screen.getByRole("radio", { name: "Changes (3)" });
|
|
294
|
+
expect(document.activeElement).toBe(changes);
|
|
295
|
+
expect(changes.getAttribute("aria-checked")).toBe("true");
|
|
296
|
+
|
|
297
|
+
fireEvent.keyDown(document.activeElement!, { key: "Home" });
|
|
298
|
+
const explorerAgain = screen.getByRole("radio", { name: "Explorer" });
|
|
299
|
+
expect(document.activeElement).toBe(explorerAgain);
|
|
300
|
+
expect(explorerAgain.getAttribute("aria-checked")).toBe("true");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("moves focus with selection in the Name|Text search-mode toggle", () => {
|
|
304
|
+
renderSurface({ searcher });
|
|
305
|
+
fireEvent.click(screen.getByRole("radio", { name: "Search" }));
|
|
306
|
+
const toggle = screen.getByRole("radiogroup", { name: "Search mode" });
|
|
307
|
+
const name = within(toggle).getByRole("radio", { name: "Name" });
|
|
308
|
+
name.focus();
|
|
309
|
+
|
|
310
|
+
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
|
|
311
|
+
const text = within(toggle).getByRole("radio", { name: "Text" });
|
|
312
|
+
expect(document.activeElement).toBe(text);
|
|
313
|
+
expect(text.getAttribute("aria-checked")).toBe("true");
|
|
314
|
+
});
|
|
245
315
|
});
|
|
246
316
|
|
|
247
317
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Shared harness for the browser-mode accessibility audits (DD-22).
|
|
2
|
+
//
|
|
3
|
+
// Renders an audited component inside the `.stgm` scope against the SDK's
|
|
4
|
+
// SHIPPED stylesheet (the prebuilt `dist/styles.css`, not a test-time
|
|
5
|
+
// recompile), then runs axe-core with the SAME policy as the e2e page audit
|
|
6
|
+
// (`test/e2e/tests/functional/accessibility.spec.ts`): WCAG 2.0 A/AA tags,
|
|
7
|
+
// fail on critical/serious, log moderate/minor.
|
|
8
|
+
//
|
|
9
|
+
// Why this file exists and how it stays honest:
|
|
10
|
+
// - Contrast/target-size are the point, so it runs in a real browser via
|
|
11
|
+
// `vitest.a11y.config.ts` (happy-dom can't evaluate either).
|
|
12
|
+
// - axe is scoped to the rendered container and restricted to WCAG tags, so
|
|
13
|
+
// page-level rules (`region`, `landmark-one-main`, `html-has-lang`, …) don't
|
|
14
|
+
// false-positive on a component rendered in isolation.
|
|
15
|
+
// - The wrapper carries the themed background so `color-contrast` is measured
|
|
16
|
+
// against the real surface color in BOTH color modes, not the browser default.
|
|
17
|
+
|
|
18
|
+
import "../../../../dist/styles.css";
|
|
19
|
+
|
|
20
|
+
import { render, cleanup } from "@testing-library/react";
|
|
21
|
+
import type { ReactElement } from "react";
|
|
22
|
+
import axe from "axe-core";
|
|
23
|
+
import { expect } from "vitest";
|
|
24
|
+
import type { Stigmer } from "@stigmer/sdk";
|
|
25
|
+
import { StigmerContext } from "../../../context.js";
|
|
26
|
+
import { __clearWorkspaceListingCache } from "../../workspaceListingCache.js";
|
|
27
|
+
|
|
28
|
+
/** The color modes every state is audited under. */
|
|
29
|
+
export const COLOR_MODES = ["light", "dark"] as const;
|
|
30
|
+
export type ColorMode = (typeof COLOR_MODES)[number];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Rules that are audited but reported as advisory (logged, non-blocking) rather
|
|
34
|
+
* than failing the suite — mirroring the e2e audit's graduated rollout, which
|
|
35
|
+
* ships fixing only some severities first.
|
|
36
|
+
*
|
|
37
|
+
* `color-contrast`: the workspace panel's deliberately-dense small muted text
|
|
38
|
+
* (tree labels, section headers, `text-[0.65rem]` path subtitles/previews,
|
|
39
|
+
* count badges, the empty-state placeholder) falls below WCAG AA 4.5:1 in both
|
|
40
|
+
* modes. This is a PRE-EXISTING, app-wide design-token/density concern (the
|
|
41
|
+
* `--stgm-muted-foreground` token at small sizes), not a Session 18 regression,
|
|
42
|
+
* and its fix needs a deliberate design pass across presets (tracked in DD-22's
|
|
43
|
+
* Follow-ups). Kept advisory here so this audit can land and guard the
|
|
44
|
+
* STRUCTURAL hardening (roles, names, relationships, nesting) immediately.
|
|
45
|
+
* Promote back to blocking once the contrast pass ships.
|
|
46
|
+
*/
|
|
47
|
+
const ADVISORY_RULE_IDS = new Set<string>(["color-contrast"]);
|
|
48
|
+
|
|
49
|
+
// The audited surfaces reach the SDK client only through the diff path's
|
|
50
|
+
// artifact hooks, which skip all fetches for inline file changes (the only kind
|
|
51
|
+
// the fixtures build). No client method is ever invoked, so an empty stub is
|
|
52
|
+
// sufficient — and required, since `useStigmer` throws outside a provider.
|
|
53
|
+
const stubClient = {} as unknown as Stigmer;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render `ui` inside a themed `.stgm` container for the given color mode.
|
|
57
|
+
* Returns the container so callers can await the settled DOM before auditing.
|
|
58
|
+
*/
|
|
59
|
+
export function renderAudited(ui: ReactElement, mode: ColorMode): HTMLElement {
|
|
60
|
+
const container = document.createElement("div");
|
|
61
|
+
container.className = "stgm";
|
|
62
|
+
container.setAttribute("data-stgm-color-mode", mode);
|
|
63
|
+
// The panel always sits on a themed surface; matching it here means
|
|
64
|
+
// color-contrast is computed against the real background, not body white.
|
|
65
|
+
container.style.background = "var(--stgm-background)";
|
|
66
|
+
container.style.color = "var(--stgm-foreground)";
|
|
67
|
+
// A fixed height gives the flex/scroll surfaces (ResizableSplit, editor pane)
|
|
68
|
+
// a real box to lay out in — observer-driven measurement that is a no-op in
|
|
69
|
+
// happy-dom but correct in Chromium.
|
|
70
|
+
container.style.height = "720px";
|
|
71
|
+
container.style.width = "1024px";
|
|
72
|
+
// RTL does NOT attach a caller-provided container to the document, so attach
|
|
73
|
+
// it ourselves — both `screen` queries and axe need it in the live DOM.
|
|
74
|
+
document.body.appendChild(container);
|
|
75
|
+
|
|
76
|
+
render(<StigmerContext.Provider value={stubClient}>{ui}</StigmerContext.Provider>, {
|
|
77
|
+
container,
|
|
78
|
+
});
|
|
79
|
+
return container;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function summarize(violations: axe.Result[]): string {
|
|
83
|
+
return violations
|
|
84
|
+
.map((v) => {
|
|
85
|
+
const nodes = v.nodes
|
|
86
|
+
.slice(0, 5)
|
|
87
|
+
.map((n) => ` · ${n.target.join(" ")}\n ${n.html.slice(0, 160)}`)
|
|
88
|
+
.join("\n");
|
|
89
|
+
return ` [${v.impact}] ${v.id}: ${v.help} (${v.nodes.length} node(s))\n${nodes}`;
|
|
90
|
+
})
|
|
91
|
+
.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Run the WCAG 2.0 A/AA audit over `container`. Fails the test on any
|
|
96
|
+
* critical/serious violation (mirroring the e2e gate); logs moderate/minor.
|
|
97
|
+
*/
|
|
98
|
+
export async function auditA11y(container: HTMLElement, label: string): Promise<void> {
|
|
99
|
+
const results = await axe.run(container, {
|
|
100
|
+
runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Blocking: critical/serious violations that are NOT on the advisory list.
|
|
104
|
+
const serious = results.violations.filter(
|
|
105
|
+
(v) =>
|
|
106
|
+
(v.impact === "critical" || v.impact === "serious") &&
|
|
107
|
+
!ADVISORY_RULE_IDS.has(v.id),
|
|
108
|
+
);
|
|
109
|
+
// Advisory: everything else worth logging — the deferred rules plus any
|
|
110
|
+
// moderate/minor findings.
|
|
111
|
+
const advisory = results.violations.filter(
|
|
112
|
+
(v) =>
|
|
113
|
+
ADVISORY_RULE_IDS.has(v.id) ||
|
|
114
|
+
v.impact === "moderate" ||
|
|
115
|
+
v.impact === "minor",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (advisory.length > 0) {
|
|
119
|
+
// Non-blocking a11y signal (mirrors the e2e audit's moderate/minor logging).
|
|
120
|
+
console.warn(`[a11y: ${label}] non-blocking violations:\n${summarize(advisory)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
serious,
|
|
125
|
+
`[a11y: ${label}] critical/serious axe violations:\n${summarize(serious)}`,
|
|
126
|
+
).toHaveLength(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reset cross-test state. The listing cache is module-level (keyed by entry id),
|
|
131
|
+
* so without this a scenario could take a prior scenario's cached listing —
|
|
132
|
+
* masking the truncation banner or leaking a stale tree. Call in `afterEach`.
|
|
133
|
+
*/
|
|
134
|
+
export function resetWorkspaceAudit(): void {
|
|
135
|
+
cleanup();
|
|
136
|
+
// cleanup() unmounts React trees but does not remove caller-provided
|
|
137
|
+
// containers; drop any leftover `.stgm` wrappers so scenarios never bleed.
|
|
138
|
+
document.querySelectorAll(".stgm").forEach((node) => node.remove());
|
|
139
|
+
__clearWorkspaceListingCache();
|
|
140
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// In-memory workspace capabilities for the browser-mode accessibility audit
|
|
2
|
+
// (DD-22). These mirror the shapes a real host injects (DD-004) — a GitHub
|
|
3
|
+
// lister/reader on web, a Tauri walker on desktop — but serve a tiny fixed
|
|
4
|
+
// tree so the Session 18 hardened surfaces render deterministically, with no
|
|
5
|
+
// network, backend, or token. Fixtures render REAL component output only; they
|
|
6
|
+
// introduce no Tailwind utility classes of their own, so the prebuilt
|
|
7
|
+
// `dist/styles.css` (generated by `@source` scanning over SDK source) covers
|
|
8
|
+
// every class in the audited DOM.
|
|
9
|
+
|
|
10
|
+
import { create } from "@bufbuild/protobuf";
|
|
11
|
+
import {
|
|
12
|
+
FileChangeSchema,
|
|
13
|
+
FileContentSchema,
|
|
14
|
+
type FileChange,
|
|
15
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
16
|
+
import {
|
|
17
|
+
FileChangeCaptureLevel,
|
|
18
|
+
FileChangeType,
|
|
19
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
20
|
+
import type { WorkspaceEntry } from "../../useWorkspaceEntries.js";
|
|
21
|
+
import type {
|
|
22
|
+
WorkspaceFileEntry,
|
|
23
|
+
WorkspaceFileLister,
|
|
24
|
+
} from "../../WorkspaceFileLister.js";
|
|
25
|
+
import type {
|
|
26
|
+
WorkspaceFileContent,
|
|
27
|
+
WorkspaceFileReader,
|
|
28
|
+
} from "../../WorkspaceFileReader.js";
|
|
29
|
+
import type { WorkspaceContentSearcher } from "../../WorkspaceContentSearcher.js";
|
|
30
|
+
|
|
31
|
+
/** A git entry whose listing is a normal (non-truncated) multi-level tree. */
|
|
32
|
+
export const EXPLORER_ENTRY: WorkspaceEntry = {
|
|
33
|
+
id: "a11y-explorer",
|
|
34
|
+
name: "acme/app",
|
|
35
|
+
type: "git",
|
|
36
|
+
gitUrl: "https://github.com/acme/app.git",
|
|
37
|
+
gitBranch: "main",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A git entry whose listing is capped, exercising the truncation banner. */
|
|
41
|
+
export const TRUNCATED_ENTRY: WorkspaceEntry = {
|
|
42
|
+
id: "a11y-truncated",
|
|
43
|
+
name: "acme/monorepo",
|
|
44
|
+
type: "git",
|
|
45
|
+
gitUrl: "https://github.com/acme/monorepo.git",
|
|
46
|
+
gitBranch: "main",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The file opened by the viewer audits (present in {@link EXPLORER_ENTRY}). */
|
|
50
|
+
export const OPEN_FILE_PATH = "src/index.ts";
|
|
51
|
+
|
|
52
|
+
const OPEN_FILE_TEXT = [
|
|
53
|
+
"export interface Greeting {",
|
|
54
|
+
" readonly message: string;",
|
|
55
|
+
"}",
|
|
56
|
+
"",
|
|
57
|
+
"export function greet(name: string): Greeting {",
|
|
58
|
+
" return { message: `Hello, ${name}!` };",
|
|
59
|
+
"}",
|
|
60
|
+
"",
|
|
61
|
+
].join("\n");
|
|
62
|
+
|
|
63
|
+
const EXPLORER_LISTING: readonly WorkspaceFileEntry[] = [
|
|
64
|
+
{ path: "src", isDirectory: true },
|
|
65
|
+
{ path: "src/index.ts", isDirectory: false },
|
|
66
|
+
{ path: "src/lib", isDirectory: true },
|
|
67
|
+
{ path: "src/lib/format.ts", isDirectory: false },
|
|
68
|
+
{ path: "src/lib/logo.png", isDirectory: false },
|
|
69
|
+
{ path: "README.md", isDirectory: false },
|
|
70
|
+
{ path: "package.json", isDirectory: false },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const TRUNCATED_LISTING: readonly WorkspaceFileEntry[] = [
|
|
74
|
+
{ path: "src", isDirectory: true },
|
|
75
|
+
{ path: "src/a.ts", isDirectory: false },
|
|
76
|
+
{ path: "src/b.ts", isDirectory: false },
|
|
77
|
+
// The lister's advisory notice — the cache collapses this into the single
|
|
78
|
+
// `truncated` banner the audit asserts is accessible (DD-11).
|
|
79
|
+
{ path: "Listing truncated — repository too large.", isDirectory: false, notice: true },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Lister serving the fixed tree, keyed by entry id. */
|
|
83
|
+
export const mockLister: WorkspaceFileLister = async (entry) => {
|
|
84
|
+
if (entry.id === TRUNCATED_ENTRY.id) return [...TRUNCATED_LISTING];
|
|
85
|
+
if (entry.id === EXPLORER_ENTRY.id) return [...EXPLORER_LISTING];
|
|
86
|
+
return [];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const TEXT_CONTENT: WorkspaceFileContent = {
|
|
90
|
+
text: OPEN_FILE_TEXT,
|
|
91
|
+
isBinary: false,
|
|
92
|
+
size: OPEN_FILE_TEXT.length,
|
|
93
|
+
encoding: "utf-8",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const BINARY_CONTENT: WorkspaceFileContent = {
|
|
97
|
+
text: null,
|
|
98
|
+
isBinary: true,
|
|
99
|
+
size: 2048,
|
|
100
|
+
encoding: "base64",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Reader returning text for source files and a binary sentinel for the png. */
|
|
104
|
+
export const mockReader: WorkspaceFileReader = async (_entry, path) => {
|
|
105
|
+
if (path.endsWith(".png")) return BINARY_CONTENT;
|
|
106
|
+
return TEXT_CONTENT;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Content searcher returning a couple of deterministic line hits. */
|
|
110
|
+
export const mockSearcher: WorkspaceContentSearcher = async (entry, query) => {
|
|
111
|
+
if (entry.id !== EXPLORER_ENTRY.id) return null;
|
|
112
|
+
return {
|
|
113
|
+
matches: [
|
|
114
|
+
{ path: "src/index.ts", line: 5, preview: `export function ${query}(name: string): Greeting {` },
|
|
115
|
+
{ path: "src/lib/format.ts", line: 2, preview: ` // ${query} helper` },
|
|
116
|
+
],
|
|
117
|
+
truncated: false,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* An inline (non-offloaded) MODIFY change for the open file, so the diff pane
|
|
123
|
+
* resolves its before/after text synchronously — `useFileChangeContent` issues
|
|
124
|
+
* no artifact fetch for inline sides, so the audit needs no live backend.
|
|
125
|
+
*/
|
|
126
|
+
export function fileChangeForOpenFile(): FileChange {
|
|
127
|
+
return create(FileChangeSchema, {
|
|
128
|
+
path: OPEN_FILE_PATH,
|
|
129
|
+
changeType: FileChangeType.MODIFY,
|
|
130
|
+
captureLevel: FileChangeCaptureLevel.WHOLE_FILE,
|
|
131
|
+
before: create(FileContentSchema, {
|
|
132
|
+
body: { case: "inline", value: "export function greet(name: string) {\n return name;\n}\n" },
|
|
133
|
+
}),
|
|
134
|
+
after: create(FileContentSchema, {
|
|
135
|
+
body: { case: "inline", value: OPEN_FILE_TEXT },
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Accessibility audit (DD-22) — WorkspaceEditor (the Config-facet workspace
|
|
2
|
+
// entry manager).
|
|
3
|
+
//
|
|
4
|
+
// Covers the Session 18 hardening here: aria-labeled git inputs and focus rings
|
|
5
|
+
// on the editor's buttons. Audits two states — the default action list and the
|
|
6
|
+
// manual "add git repository" form — in light + dark against the shipped CSS.
|
|
7
|
+
|
|
8
|
+
import { describe, it, afterEach } from "vitest";
|
|
9
|
+
import { screen } from "@testing-library/react";
|
|
10
|
+
import { WorkspaceEditor } from "../../WorkspaceEditor.js";
|
|
11
|
+
import type { UseWorkspaceEntriesReturn } from "../../useWorkspaceEntries.js";
|
|
12
|
+
import {
|
|
13
|
+
COLOR_MODES,
|
|
14
|
+
auditA11y,
|
|
15
|
+
renderAudited,
|
|
16
|
+
resetWorkspaceAudit,
|
|
17
|
+
} from "./harness.js";
|
|
18
|
+
|
|
19
|
+
/** A minimal workspace hook return — rendering only reads `entries`. */
|
|
20
|
+
function mockWorkspace(
|
|
21
|
+
entries: UseWorkspaceEntriesReturn["entries"] = [],
|
|
22
|
+
): UseWorkspaceEntriesReturn {
|
|
23
|
+
return {
|
|
24
|
+
entries,
|
|
25
|
+
addGitRepo: () => {},
|
|
26
|
+
addLocalPath: () => {},
|
|
27
|
+
remove: () => {},
|
|
28
|
+
clear: () => {},
|
|
29
|
+
clearLocal: () => {},
|
|
30
|
+
toInput: () => [],
|
|
31
|
+
hasEntries: entries.length > 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const GIT_ENTRY = {
|
|
36
|
+
id: "editor-entry",
|
|
37
|
+
name: "acme/app",
|
|
38
|
+
type: "git" as const,
|
|
39
|
+
gitUrl: "https://github.com/acme/app.git",
|
|
40
|
+
gitBranch: "main",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
afterEach(resetWorkspaceAudit);
|
|
44
|
+
|
|
45
|
+
describe("WorkspaceEditor a11y", () => {
|
|
46
|
+
it.each(COLOR_MODES)("default action list with an entry (%s)", async (mode) => {
|
|
47
|
+
const container = renderAudited(
|
|
48
|
+
<WorkspaceEditor workspace={mockWorkspace([GIT_ENTRY])} enableLocal />,
|
|
49
|
+
mode,
|
|
50
|
+
);
|
|
51
|
+
await screen.findByText("Connect GitHub");
|
|
52
|
+
await auditA11y(container, `editor list · ${mode}`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each(COLOR_MODES)("manual add-git form with labeled inputs (%s)", async (mode) => {
|
|
56
|
+
const container = renderAudited(
|
|
57
|
+
// Empty entries + initialPanel="github" with no connection opens the
|
|
58
|
+
// manual URL/branch form (the labeled git inputs Session 18 hardened).
|
|
59
|
+
<WorkspaceEditor workspace={mockWorkspace()} initialPanel="github" />,
|
|
60
|
+
mode,
|
|
61
|
+
);
|
|
62
|
+
await screen.findByLabelText("Git repository URL");
|
|
63
|
+
await auditA11y(container, `editor git form · ${mode}`);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Accessibility audit (DD-22) — the explorer tree and both search surfaces.
|
|
2
|
+
//
|
|
3
|
+
// Covers the Session 18 hardening for these surfaces: file-tree `role="tree"` +
|
|
4
|
+
// `aria-level` + `aria-hidden` glyphs, the neutral truncation banner + Retry
|
|
5
|
+
// focus ring, the search `role="status"` live region, the combobox/listbox
|
|
6
|
+
// results, and the `Name | Text` search-mode radiogroup — each in light + dark
|
|
7
|
+
// against the shipped stylesheet.
|
|
8
|
+
|
|
9
|
+
import { describe, it, afterEach } from "vitest";
|
|
10
|
+
import { screen, fireEvent } from "@testing-library/react";
|
|
11
|
+
import { WorkspaceSurface } from "../../WorkspaceSurface.js";
|
|
12
|
+
import {
|
|
13
|
+
EXPLORER_ENTRY,
|
|
14
|
+
TRUNCATED_ENTRY,
|
|
15
|
+
mockLister,
|
|
16
|
+
mockReader,
|
|
17
|
+
mockSearcher,
|
|
18
|
+
} from "./mock-capabilities.js";
|
|
19
|
+
import {
|
|
20
|
+
COLOR_MODES,
|
|
21
|
+
auditA11y,
|
|
22
|
+
renderAudited,
|
|
23
|
+
resetWorkspaceAudit,
|
|
24
|
+
} from "./harness.js";
|
|
25
|
+
|
|
26
|
+
const noop = () => {};
|
|
27
|
+
|
|
28
|
+
/** The controlled props every WorkspaceSurface render needs, minus the ones a
|
|
29
|
+
* given scenario varies (entries/lister/searcher/view). */
|
|
30
|
+
const baseProps = {
|
|
31
|
+
reader: mockReader,
|
|
32
|
+
editors: [],
|
|
33
|
+
selectedFile: null,
|
|
34
|
+
onOpenFile: noop,
|
|
35
|
+
onActivateEditor: noop,
|
|
36
|
+
onPinEditor: noop,
|
|
37
|
+
onCloseEditor: noop,
|
|
38
|
+
onCollapse: noop,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
afterEach(resetWorkspaceAudit);
|
|
42
|
+
|
|
43
|
+
describe("WorkspaceSurface a11y — explorer + search", () => {
|
|
44
|
+
it.each(COLOR_MODES)("explorer tree, populated listing (%s)", async (mode) => {
|
|
45
|
+
const container = renderAudited(
|
|
46
|
+
<WorkspaceSurface
|
|
47
|
+
entries={[EXPLORER_ENTRY]}
|
|
48
|
+
lister={mockLister}
|
|
49
|
+
view="files"
|
|
50
|
+
{...baseProps}
|
|
51
|
+
/>,
|
|
52
|
+
mode,
|
|
53
|
+
);
|
|
54
|
+
// The first root auto-expands; wait for a top-level file before auditing so
|
|
55
|
+
// axe sees the loaded tree, not the loading state.
|
|
56
|
+
await screen.findByText("README.md");
|
|
57
|
+
await auditA11y(container, `explorer tree · ${mode}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each(COLOR_MODES)("explorer truncation banner (%s)", async (mode) => {
|
|
61
|
+
const container = renderAudited(
|
|
62
|
+
<WorkspaceSurface
|
|
63
|
+
entries={[TRUNCATED_ENTRY]}
|
|
64
|
+
lister={mockLister}
|
|
65
|
+
view="files"
|
|
66
|
+
{...baseProps}
|
|
67
|
+
/>,
|
|
68
|
+
mode,
|
|
69
|
+
);
|
|
70
|
+
await screen.findByText(/Showing a partial listing/i);
|
|
71
|
+
await auditA11y(container, `explorer truncation · ${mode}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it.each(COLOR_MODES)("filename search with results (%s)", async (mode) => {
|
|
75
|
+
const container = renderAudited(
|
|
76
|
+
<WorkspaceSurface
|
|
77
|
+
entries={[EXPLORER_ENTRY]}
|
|
78
|
+
lister={mockLister}
|
|
79
|
+
searcher={mockSearcher}
|
|
80
|
+
view="search"
|
|
81
|
+
{...baseProps}
|
|
82
|
+
/>,
|
|
83
|
+
mode,
|
|
84
|
+
);
|
|
85
|
+
const input = await screen.findByRole("combobox", {
|
|
86
|
+
name: "Search workspace files by name",
|
|
87
|
+
});
|
|
88
|
+
fireEvent.change(input, { target: { value: "index" } });
|
|
89
|
+
await screen.findByRole("listbox", { name: "Search results" });
|
|
90
|
+
await auditA11y(container, `filename search · ${mode}`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each(COLOR_MODES)("content (text) search with results (%s)", async (mode) => {
|
|
94
|
+
const container = renderAudited(
|
|
95
|
+
<WorkspaceSurface
|
|
96
|
+
entries={[EXPLORER_ENTRY]}
|
|
97
|
+
lister={mockLister}
|
|
98
|
+
searcher={mockSearcher}
|
|
99
|
+
view="search"
|
|
100
|
+
{...baseProps}
|
|
101
|
+
/>,
|
|
102
|
+
mode,
|
|
103
|
+
);
|
|
104
|
+
fireEvent.click(await screen.findByRole("radio", { name: "Text" }));
|
|
105
|
+
const input = await screen.findByRole("combobox", {
|
|
106
|
+
name: "Search text in workspace files",
|
|
107
|
+
});
|
|
108
|
+
fireEvent.change(input, { target: { value: "greet" } });
|
|
109
|
+
await screen.findByRole("listbox", { name: "Search results" });
|
|
110
|
+
await auditA11y(container, `content search · ${mode}`);
|
|
111
|
+
});
|
|
112
|
+
});
|