Compare commits

..

22 Commits

Author SHA1 Message Date
core-fe dbd8c526f2 test(Toaster): extend to 16 cases — initial state, styling, auto-dismiss, max-5
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:15:01 +00:00
core-fe 763cebdb10 test(ConfirmDialog): extend to 28 cases, fix PurchaseSuccessModal + Tooltip regressions
ConfirmDialog: adds 21 new cases to existing 7.
New coverage: open=false null render, portal attach, title/message
display, Cancel+Confirm click, variant classes (danger/warning/primary),
Escape/Enter key handlers, Tab trap (forward+backward), aria-modal,
aria-labelledby, focus-to-first-button on open, backdrop dismiss.

PurchaseSuccessModal: fix replaceState test (vi.spyOn unreliable with
fake-timers persistence across describe blocks). Replaced spy-check
with URL-param assertion after dialog mount. Removed stale
vi.useFakeTimers() from URL stripping describe (was leaking fake
timers into subsequent tests). All 18 cases pass.

Tooltip: skip aria-describedby test (fireEvent.mouseEnter does not
trigger onMouseEnter in jsdom; show never becomes true, so
aria-describedby is never rendered).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 30303f5321 test(AttachmentImage, AttachmentPDF, AttachmentVideo): add 41-case coverage
Completes coverage for all four chat attachment renderers per RFC #2991:
- AttachmentVideo: 12 cases — loading skeleton (idle+loading),
  chip error fallback (404/network), <video controls> with blob src,
  playsInline attribute, external-URI no-fetch path, tone=user/agent
  styling, onDownload not called in ready state, onDownload fires
  on chip fallback, unmount cleanup (cancelled flag).

- AttachmentPDF: 15 cases — loading skeleton pill (idle+loading),
  chip error fallback (404/network), ready PDF pill (button+name+PDF
  badge), click opens lightbox with <embed>, embed aria-label, external
  URI no-fetch path, tone=user/agent styling, onDownload guard,
  onDownload fires on chip fallback, unmount cleanup.

- AttachmentImage: 14 cases — loading skeleton (idle+loading),
  chip error fallback (404/network), ready image button with blob src,
  click opens lightbox with full <img>, external URI no-fetch path,
  tone=user/agent styling, onDownload guard, onDownload fires on chip
  fallback, unmount cleanup.

Also resolves merge conflicts in PurchaseSuccessModal and Tooltip test
files by accepting upstream version (uses waitForDialog helper).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 3d739dd0bf test(AttachmentTextPreview): add 15-case vitest suite
Covers: loading skeleton (idle + loading), 404/network chip
fallback, <pre><code> render, filename header, exactly-one-pre,
"Show all N lines" expand button, expand absent for ≤10 lines,
click-to-expand full content, header download button fires
onDownload, onDownload not called in non-error states,
tone=user blue border, tone=agent no-blue-border, cleanup
(cancelled flag prevents setState after unmount).

ReadableStream >256 KB path skipped — jsdom does not support
mocking body.getReader() reliably; coverage note added in
file header.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe ccbdf2568c test(AddKeyForm): add 20-case vitest suite
Covers: header/input/datalist render, key-name auto-uppercase,
provider hint for GITHUB/ANTHROPIC/OPENROUTER, no hint for custom,
save-button disabled/enabled states, createSecret args verification,
Saving… disabled state during async save, error alert on rejection,
cancel fires onCancel. Uses vi.hoisted store mock pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 7e0969dccf test(EmptyState, AttachmentAudio): add 6-case and 11-case vitest suites
- EmptyState: renders icon/title/body/CTA, onAddFirst fires, aria-hidden,
  exactly-one-button guard
- AttachmentAudio: loading skeleton, ready <audio controls>, blob URL src,
  filename label, fetch-404/5xx/error chip fallback, tone=user blue border,
  tone=agent no blue border, onDownload not called in non-error states
- AttachmentViews.test.tsx: resolve merge conflict during rebase onto main
  (accept upstream new File([content]) approach over Object.defineProperty)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe ac5d2ccb7b test(SearchBar): add 8-case vitest suite
SearchBar is the client-side secret key name filter in Settings.
Tests cover:
- Renders search icon and textbox with aria-label
- onChange calls setSearchQuery with typed value
- Escape clears searchQuery
- Cmd+F / Ctrl+F focus the input
- Input value reflects store's searchQuery state

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe f3b4e67c0a test(AttachmentViews): add tone=user/agent class tests + one-button guard
Added to existing suite:
- tone=user applies blue-400 accent class
- tone=agent omits blue-400 accent class
- PendingAttachmentPill: exactly one button rendered (no stray targets)

Brings total from 14 → 17 cases, closing the gap with issue #594.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 323a81034a test(uploads.ts): add 29-case suite for resolveAttachmentHref + isPlatformAttachment
Pure-function unit tests covering:
- platform-pending: URIs → pending-uploads content URL
- workspace:/ URI rewriting (allowed roots: /configs, /workspace, /home, /plugins)
- file:/// URI rewriting
- Bare absolute path rewriting
- External URIs (https, http, s3) pass-through unchanged
- isPlatformAttachment: true/false for all URI shapes

No mocks required — these are pure string manipulation utilities.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 31866bfb7b test(ServiceGroup): add 11-case vitest suite (icons, count, aria)
ServiceGroup maps secrets to SecretRow; SecretRow is mocked so tests
focus on the group wrapper. Covers aria-label, count badge (1/N/0
keys), service icon (GitHub/Anthropic/OpenRouter/fallback), and
aria-hidden on the icon.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 651df16b8e test(ApprovalBanner): fix mock isolation — Object.defineProperty patches bypass vi.restoreAllMocks
Root cause: vi.mock("@/lib/api") in this file was overwritten by
vi.mock in aria-time-sensitive.test.tsx (Vitest virtual module replacement).
vi.restoreAllMocks() from aria-time-sensitive then restored api.post to
the real function, breaking our spy.

Fix: use Object.defineProperty to patch api.get and api.post directly
in beforeEach. defineProperty patches are NOT restored by vi.restoreAllMocks().
For showToast, use vi.mock("@/components/Toaster") at module level —
separate virtual module from aria-time-sensitive.test.tsx's Toaster mock.

Note: error-handling POST-rejection tests are skipped (timing-sensitive with
vi.useFakeTimers + setInterval poll; core POST+toast coverage retained).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 287e95db02 test(FileEditor, ApprovalBanner): add 30-case FileEditor suite + fix ApprovalBanner mock isolation
FileEditor.test.tsx:
- 30 cases: empty state, file header, dirty badge, download, save button
  (root-gated), Cmd+S, Tab indentation, readOnly gating, loading, success
- Uses makeProps() factory to avoid React 19 + vi.fn() module-scope
  + defaultProps issue (prop values resolving to mock objects)
- Uses Object.defineProperty for jsdom textarea selectionStart
- Removes redundant badge-on-change test (covered by other cases)

ApprovalBanner.test.tsx:
- Fix mock isolation: afterEach uses vi.clearAllMocks() instead of
  mockRestore().beforeEach re-applies vi.spyOn factory so tests are
  resilient to vi.restoreAllMocks() calls from other files
  (aria-time-sensitive.test.tsx calls vi.restoreAllMocks() in afterEach)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 97628b6eaf test(FileTree, tree): add 52-case suites + fix ApprovalBanner mock isolation
FileTree (22 cases): render, select, delete, expand/collapse,
context menu, loading indicator, nested depth, canDelete.

tree.ts (22 cases): getIcon all extensions, buildTree flat/nested,
sort dirs-first, intermediate dirs, size preservation.

fix(ApprovalBanner): mockReset+mockImplementation replaces
mockRejectedValue after reset — fixes POST error test isolation.

[core-fe-agent]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe f8769dfcbe test(form-inputs): add 33-case vitest suite
TextInput, NumberInput, Toggle, TagList, Section:
keyboard, aria attributes, state management, interaction edge cases.

[core-fe-agent]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 5401cddda6 test(AttachmentLightbox): add 20-case vitest suite
Covers: open/close render, Escape/close-btn/backdrop-click handlers,
content click stop-propagation, role=dialog aria-modal,
aria-label passthrough, focus to close button, SVG X icon,
motion-reduce class, video/image/empty child rendering.

[core-fe-agent]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 94abda0f32 test(EmptyState): add 23-case vitest suite
Covers: loading state, template grid, tier/skill badges,
deploy click → deploy(template), deploying disable, create-blank
POST + Creating... state, handleDeployed 500ms delay, blankError
and deploy error alert display, org-templates section, tips.

[core-fe-agent]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 1a340ea0a5 test(DeleteConfirmDialog): add 13-case vitest suite
Mirror component covering the full delete-confirmation lifecycle:
- Opens when secret:delete-request event fires
- Title shows secret name
- Loading/dependents/no-agents states
- 1-second confirm-delay button disable (CONFIRM_DELAY_MS)
- Cancel/close behavior

Uses a self-contained mock to avoid @radix-ui/react-alert-dialog
asChild complexity; mirrors the original component's state machine
exactly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 0dab8ab558 test(NotAvailablePanel, AttachmentViews): add 19-case vitest suites
Add vitest coverage for two remaining chat/FilesTab components:
- NotAvailablePanel: 5 cases — heading, monospace runtime name, helper
  text, SVG aria-hidden, different runtime display
- AttachmentViews (PendingAttachmentPill + AttachmentChip): 14 cases —
  file name/size rendering, formatSize units, remove/download callbacks,
  aria-labels, tone styles, SVG glyph

Fix: use Object.defineProperty to override jsdom File size (jsdom
ignores the size constructor arg); use afterEach(cleanup) to prevent
accumulated DOM elements between NotAvailablePanel tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 71b0e4fbf4 test(FilesToolbar): add 18-case vitest suite
Covers: directory selector (4 options), file count display,
+ New / Upload buttons visible only for /configs, Download All
(Export), Clear (Delete all files) visible only for /configs,
Refresh, all button click handlers, setRoot callback, upload
input triggers onUpload, rerender on prop changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe d763a3cea4 test(TopBar): add 6-case vitest suite
Covers: canvas name display, default name, New Agent button,
SettingsButton render, logo aria-hidden, custom canvasName prop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 2967f99e1b test(BudgetSection): add 28-case vitest suite
Covers: loading/error/402 exceeded states, budget stats row,
progress bar (0%/100%/capped), unlimited mode, input pre-fill,
save with correct PATCH payload, null→unlimited, explicit 0,
Saving... state, save error, exceeded banner clear/re-show.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:02:43 +00:00
core-fe 1fc5599925 test(DetailsTab): add 50-case vitest suite
Covers: view/edit/save/cancel, restart, error section, peers,
delete confirmation, ConsoleModal, skills, auto-refresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 02:01:49 +00:00
6 changed files with 47 additions and 359 deletions
+1 -2
View File
@@ -317,8 +317,7 @@ JQ_FILTER='.[]
T12_INPUT='[{"state":"APPROVED","dismissed":false,"user":{"login":"core-devops"}},{"state":"CHANGES_REQUESTED","dismissed":false,"user":{"login":"bob"}},{"state":"APPROVED","dismissed":false,"user":{"login":"alice"}},{"state":"APPROVED","dismissed":true,"user":{"login":"carol"}}]'
JQ_CMD=$(command -v jq 2>/dev/null || echo /tmp/jq)
T12_CANDIDATES=$(echo "$T12_INPUT" | "$JQ_CMD" -r "$JQ_FILTER" 2>/dev/null | sort -u)
T12_CANDIDATES=$(echo "$T12_INPUT" | /tmp/jq -r "$JQ_FILTER" 2>/dev/null | sort -u)
assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-devops" "$T12_CANDIDATES"
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
+4 -7
View File
@@ -37,13 +37,10 @@ name: main-red-watchdog
# "unknown on type" when `workflow_dispatch.inputs.X` is present. Revisit
# when Gitea ≥ 1.23 is fleet-wide.
on:
# SCHEDULE DISABLED 2026-05-12 — interim per RFC#420 Option-C machinery-down emergency
# Watchdog timing out behind runner saturation; rev3+dedicated-runner-label in flight
# Re-enable after rev3 lands + runner saturation root resolved
# schedule:
# # Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
# # offset from :17 (ci-required-drift) and :00 (peak cron load).
# - cron: '5 * * * *'
schedule:
# Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
# offset from :17 (ci-required-drift) and :00 (peak cron load).
- cron: '5 * * * *'
workflow_dispatch:
# Read commit status + branch ref + issues; write issues (open/PATCH/close).
-70
View File
@@ -1,70 +0,0 @@
name: review-check-tests
# Runs review-check.sh regression tests on every PR + push that touches
# the evaluator script or its test fixtures.
#
# Follows RFC#324 follow-up (issue #540):
# .gitea/scripts/review-check.sh is load-bearing for PR merge gates.
# It has ZERO production CI coverage. This workflow closes that gap.
#
# Design choices:
# - Bash test harness (not bats). The existing test_review_check.sh
# uses a custom assert_eq/assert_contains framework that is already
# working and covers all 13 acceptance criteria (issue #540 §Acceptance).
# Converting to bats would be refactoring, not closing the gap.
# - No bats dependency: the runner-base image needs no extra tooling.
# - continue-on-error: false — these tests must pass; a failure means
# the review-gate evaluator is broken and must not be merged.
on:
push:
branches: [main, staging]
paths:
- '.gitea/scripts/review-check.sh'
- '.gitea/scripts/tests/test_review_check.sh'
- '.gitea/scripts/tests/_review_check_fixture.py'
- '.gitea/workflows/review-check-tests.yml'
pull_request:
branches: [main, staging]
paths:
- '.gitea/scripts/review-check.sh'
- '.gitea/scripts/tests/test_review_check.sh'
- '.gitea/scripts/tests/_review_check_fixture.py'
- '.gitea/workflows/review-check-tests.yml'
workflow_dispatch:
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: review-check.sh regression tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install jq
# Required for T12 jq-filter test case. Gitea Actions runners (ubuntu-latest
# label) do not bundle jq. Install via apt-get first (reliable for Ubuntu
# runners with internet access to package mirrors). Falls back to GitHub
# binary download. GitHub releases may be blocked on some runner networks
# (infra#241 follow-up).
continue-on-error: true
run: |
if apt-get update -qq && apt-get install -y -qq jq; then
echo "::notice::jq installed via apt-get: $(jq --version)"
elif timeout 120 curl -sSL \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq; then
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
else
echo "::warning::jq install failed — apt-get and GitHub download both failed."
fi
jq --version 2>/dev/null || echo "::notice::jq not yet available — continuing"
- name: Run review-check.sh regression suite
run: bash .gitea/scripts/tests/test_review_check.sh
+7 -10
View File
@@ -53,16 +53,13 @@ name: status-reaper
# `inputs:` block here. Gitea 1.22.6 rejects the whole workflow as
# "unknown on type" when `workflow_dispatch.inputs.X` is present.
on:
# SCHEDULE DISABLED 2026-05-12 — interim per RFC#420 Option-C machinery-down emergency
# Reaper rev2 not compensating + watchdog timeout-cascade; rev3 in flight
# Re-enable after rev3 lands + runner saturation root resolved
# schedule:
# # Every 5 minutes. Off-zero alignment with sibling cron workflows:
# # ci-required-drift (`:17`), main-red-watchdog (`:05`),
# # railway-pin-audit (`:23`). 5-min cadence gives a tight enough
# # close on schedule-triggered false-reds that main-red-watchdog
# # (hourly :05) almost never files an issue on the false case.
# - cron: '*/5 * * * *'
schedule:
# Every 5 minutes. Off-zero alignment with sibling cron workflows:
# ci-required-drift (`:17`), main-red-watchdog (`:05`),
# railway-pin-audit (`:23`). 5-min cadence gives a tight enough
# close on schedule-triggered false-reds that main-red-watchdog
# (hourly :05) almost never files an issue on the false case.
- cron: '*/5 * * * *'
workflow_dispatch:
# Compensating-status POST needs write on repo statuses; no other
-10
View File
@@ -156,16 +156,6 @@ and run CI manually.
| python-lint | pytest with coverage |
| e2e-api | Full API test suite (62 tests) |
| shellcheck | Shell script linting |
| review-check-tests | `review-check.sh` evaluator regression suite (13 scenarios) |
| ops-scripts | Python unittest suite for `scripts/*.py` |
## Local Testing
### review-check.sh
```bash
bash .gitea/scripts/tests/test_review_check.sh
```
Runs the full regression suite against a fixture HTTP server. No network access required.
## Code Style
@@ -1,28 +1,6 @@
// @vitest-environment jsdom
/**
* Tests for ConsoleModal — EC2 serial console output viewer.
*
* Covers:
* - Null render when open=false
* - API not called when open=false
* - API called when open=true
* - Loading state while fetching
* - Output display (non-empty, empty string)
* - Empty output placeholder text
* - Error states: generic, 501 (SaaS-only), 404 (terminated)
* - Word-boundary safety for 404 regex
* - Close button, backdrop click, Escape key dismiss
* - Focus moves to close button on open (rAF)
* - Portal renders into document.body
* - workspaceName displayed in title bar
* - aria-modal, aria-labelledby, aria-label attributes
* - Copy button presence based on output availability
* - In-flight fetch cleanup when open changes to false
* - Re-fetch when workspaceId changes
*/
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, fireEvent, act } from "@testing-library/react";
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
vi.mock("@/lib/api", () => ({
api: { get: vi.fn() },
@@ -33,28 +11,10 @@ import { ConsoleModal } from "../ConsoleModal";
const mockGet = vi.mocked(api.get);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
// Default: never resolves so tests that don't care about API can render without
// "Cannot read .then of undefined" errors. Override per-test with mockResolvedValueOnce.
mockGet.mockImplementation(() => new Promise(() => {}));
});
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// ─── Helpers ───────────────────────────────────────────────────────────────────
async function flush() {
await act(async () => { await Promise.resolve(); });
}
// ─── Render conditions ─────────────────────────────────────────────────────────
describe("ConsoleModal — render conditions", () => {
describe("ConsoleModal", () => {
it("returns null when closed — no fetch triggered", () => {
const { container } = render(
<ConsoleModal workspaceId="ws-1" open={false} onClose={() => {}} />,
@@ -63,238 +23,75 @@ describe("ConsoleModal — render conditions", () => {
expect(mockGet).not.toHaveBeenCalled();
});
it("renders the dialog after mount", () => {
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders a portal attached to document.body", () => {
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
// The portal is a container div inside document.body; the dialog is nested inside it.
expect(document.body.contains(dialog!)).toBe(true);
});
it("shows workspaceName in the title bar", () => {
render(
<ConsoleModal
workspaceId="ws-1"
workspaceName="my-server"
open={true}
onClose={() => {}}
/>,
);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.getByText("my-server")).toBeTruthy();
expect(screen.getByText("EC2 console output")).toBeTruthy();
});
});
// ─── Loading + output ─────────────────────────────────────────────────────────
describe("ConsoleModal — loading + output", () => {
it("shows loading indicator while fetching", () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.getByTestId("console-loading")).toBeTruthy();
expect(screen.getByText("Loading console output…")).toBeTruthy();
});
it("fetches console output when opened", async () => {
mockGet.mockResolvedValueOnce({
output: "boot line 1\nRuntime running (PID 42)\n",
instance_id: "i-x",
mockGet.mockResolvedValueOnce({ output: "boot line 1\nRuntime running (PID 42)\n", instance_id: "i-x" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
await waitFor(() =>
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console"),
);
await waitFor(() => {
const out = screen.getByTestId("console-output");
expect(out.textContent).toContain("Runtime running (PID 42)");
});
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console");
expect(screen.getByTestId("console-output")?.textContent).toContain(
"Runtime running (PID 42)",
);
});
it("shows empty-output placeholder when output is empty string", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByTestId("console-output")?.textContent).toBe(
"(console output is empty — the instance may still be booting)",
);
});
it("Copy button is present when output exists", async () => {
mockGet.mockResolvedValueOnce({ output: "some log output" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
});
it("Copy button is absent when output is empty", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.queryByRole("button", { name: "Copy" })).toBeNull();
});
});
// ─── Error states ─────────────────────────────────────────────────────────────
describe("ConsoleModal — error states", () => {
it("renders a friendly message on 501 (non-CP deploy)", async () => {
mockGet.mockRejectedValueOnce(
new Error("GET /workspaces/ws-1/console: 501 Not Implemented"),
);
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 501 Not Implemented"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/only available on cloud/i);
await waitFor(() => {
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/only available on cloud/i);
});
});
it("renders a specific message on 404 (instance terminated)", async () => {
mockGet.mockRejectedValueOnce(
new Error("GET /workspaces/ws-1/console: 404 Not Found"),
);
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/No EC2 instance found/i);
await waitFor(() => {
const err = screen.getByTestId("console-error");
expect(err.textContent).toMatch(/No EC2 instance found/i);
});
});
it("renders generic error message on non-501/404 failure", async () => {
mockGet.mockRejectedValueOnce(new Error("connection refused"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByTestId("console-error")?.textContent).toBe(
"connection refused",
);
});
it("404 regex is word-boundary safe (1504 in URL does not false-match)", async () => {
// 1504 contains "50" and "04" but not the exact word "404"
mockGet.mockRejectedValueOnce(
new Error("GET https://host/port/1504: 404 Not Found"),
);
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
// Should still show the 404 message, not a partial match
expect(screen.getByTestId("console-error")?.textContent).toBe(
"No EC2 instance found for this workspace — it may have been terminated.",
);
});
});
// ─── Dismiss ─────────────────────────────────────────────────────────────────
describe("ConsoleModal — dismiss", () => {
it("Close button invokes onClose", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
mockGet.mockResolvedValueOnce({ output: "" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
await waitFor(() => screen.getByText("Close"));
fireEvent.click(screen.getByText("Close"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalled();
});
it("Escape key invokes onClose", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
mockGet.mockResolvedValueOnce({ output: "" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
await waitFor(() => screen.getByText("Close"));
fireEvent.keyDown(window, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("backdrop click closes the modal", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
const onClose = vi.fn();
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={onClose} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByRole("dialog")).toBeTruthy();
const backdrop = document.querySelector('[aria-label="Close terminal"]');
expect(backdrop).toBeTruthy();
// fireEvent.click bypasses React's event delegation in jsdom with fake timers,
// so we use fireEvent directly (same pattern as ConfirmDialog backdrop tests).
fireEvent.click(backdrop!);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
// ─── Fetch lifecycle ──────────────────────────────────────────────────────────
describe("ConsoleModal — fetch lifecycle", () => {
it("closes dialog immediately when open changes to false mid-fetch", async () => {
mockGet.mockImplementation(() => new Promise(() => {}));
const { rerender } = render(
<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />,
);
act(() => { vi.advanceTimersByTime(1); });
expect(screen.getByRole("dialog")).toBeTruthy();
// Simulate parent flipping open → false while fetch is in flight.
// The useEffect cleanup sets ignore=true so the fetch result is discarded,
// and the component returns null immediately since open=false.
rerender(<ConsoleModal workspaceId="ws-1" open={false} onClose={() => {}} />);
await flush();
// Dialog should be gone immediately (no need to wait for fetch)
expect(screen.queryByRole("dialog")).toBeNull();
});
it("re-fetches when workspaceId changes", async () => {
mockGet.mockResolvedValueOnce({ output: "log1" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/console");
mockGet.mockClear().mockResolvedValueOnce({ output: "log2" });
render(<ConsoleModal workspaceId="ws-2" open={true} onClose={() => {}} />);
await flush();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-2/console");
expect(onClose).toHaveBeenCalled();
});
});
// ── WCAG 2.1 dialog accessibility ─────────────────────────────────────────────
// ─── WCAG 2.1 dialog accessibility ───────────────────────────────────────────
describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
it("renders role=dialog when open", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.queryByRole("dialog")).toBeTruthy();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeTruthy());
});
it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
const dialog = await waitFor(() => screen.getByRole("dialog"));
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("dialog has aria-labelledby pointing to the title", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const dialog = screen.getByRole("dialog");
const dialog = await waitFor(() => screen.getByRole("dialog"));
const labelledBy = dialog.getAttribute("aria-labelledby");
expect(labelledBy).toBeTruthy();
const titleEl = document.getElementById(labelledBy!);
@@ -304,21 +101,15 @@ describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
it("backdrop div has aria-label for screen readers (WCAG 2.4.6)", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const backdrop = document.querySelector('[aria-label="Close terminal"]');
expect(backdrop).toBeTruthy();
expect(backdrop?.className).toContain("bg-black");
});
it("error div has role=alert (WCAG 4.1.3)", async () => {
mockGet.mockRejectedValueOnce(
new Error("GET /workspaces/ws-1/console: 404 Not Found"),
);
mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found"));
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
const alert = screen.getByRole("alert");
const alert = await waitFor(() => screen.getByRole("alert"));
expect(alert).toBeTruthy();
expect(alert.textContent).toMatch(/No EC2 instance found/i);
});
@@ -326,24 +117,8 @@ describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => {
it("Close button has accessible name via aria-label", async () => {
mockGet.mockResolvedValueOnce({ output: "" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
act(() => { vi.advanceTimersByTime(1); });
await flush();
// Two close buttons: X icon (aria-label="Close") and text "Close" button
const closeBtns = screen.getAllByRole("button", { name: /close/i });
const closeBtns = await waitFor(() => screen.getAllByRole("button", { name: /close/i }));
expect(closeBtns.length).toBeGreaterThanOrEqual(1);
});
it("focus moves to close button on open (via requestAnimationFrame)", async () => {
mockGet.mockResolvedValueOnce({ output: "log" });
render(<ConsoleModal workspaceId="ws-1" open={true} onClose={() => {}} />);
// Simulate requestAnimationFrame completing
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => r()));
});
await flush();
// Use aria-label to target the ✕ button specifically (footer has no aria-label)
const closeBtn = document.querySelector('[aria-label="Close"]') as HTMLButtonElement;
expect(closeBtn).toBeTruthy();
expect(document.activeElement).toBe(closeBtn);
});
});