Skip to content

Commit e0ca81a

Browse files
committed
feat: comprehensive BDD test suite with scoped UI interactions
- Add 113 BDD scenarios (5 backend, 108 frontend) covering channels, threads, tasks, worktrees, chat, git panel, settings, and WebSocket events - Split tests into TestBDDBackendFeatures and TestBDDFrontendFeatures with per-scenario Chrome tabs and 120s timeouts - Scope click steps to UI regions via data-testid (sidebar, tasks panel, global tasks panel, context menu, branch picker) using XPath + chromedp.WaitVisible for reliable, unambiguous element targeting - Add data-testid to all React panel components, sidebar, context menu, and branch picker - Use test-runner container image in CI with auto-build on Dockerfile change - Fix Git panel branches/commits not loading due to null worktrees - Fix Chromium in-process execution for Docker component test infra
1 parent 32444ba commit e0ca81a

59 files changed

Lines changed: 2782 additions & 52 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.yaml

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99

1010
permissions:
1111
contents: read
12+
packages: write
1213

1314
jobs:
1415
test:
@@ -73,15 +74,50 @@ jobs:
7374
- name: Run Windows terminal tests
7475
run: go test -race -count=1 -v ./internal/terminal/
7576

76-
perf:
77+
ensure-test-runner:
7778
runs-on: ubuntu-latest
7879
steps:
7980
- uses: actions/checkout@v4
80-
- uses: actions/setup-go@v5
81+
- name: Log in to GitHub Container Registry
82+
uses: docker/login-action@v3
8183
with:
82-
go-version: "1.26"
83-
- name: Run component performance tests
84-
run: make test-component
84+
registry: ghcr.io
85+
username: ${{ github.actor }}
86+
password: ${{ secrets.GITHUB_TOKEN }}
87+
- name: Pull or build test-runner image
88+
run: |
89+
IMAGE=ghcr.io/radutopala/loop/test-runner
90+
HASH=$(sha256sum scripts/test-runner.Dockerfile | cut -c1-12)
91+
TAG="df-${HASH}"
92+
if docker pull "${IMAGE}:${TAG}" 2>/dev/null; then
93+
echo "Image up-to-date (${TAG})"
94+
else
95+
echo "Dockerfile changed or image missing, building..."
96+
docker build -t "${IMAGE}:${TAG}" -t "${IMAGE}:latest" \
97+
-f scripts/test-runner.Dockerfile scripts/
98+
docker push "${IMAGE}:${TAG}"
99+
docker push "${IMAGE}:latest"
100+
fi
101+
102+
component-bdd:
103+
needs: ensure-test-runner
104+
runs-on: ubuntu-latest
105+
container:
106+
image: ghcr.io/radutopala/loop/test-runner:latest
107+
steps:
108+
- uses: actions/checkout@v4
109+
- name: Run BDD component tests
110+
run: make test-component-bdd
111+
112+
component-perf:
113+
needs: ensure-test-runner
114+
runs-on: ubuntu-latest
115+
container:
116+
image: ghcr.io/radutopala/loop/test-runner:latest
117+
steps:
118+
- uses: actions/checkout@v4
119+
- name: Run API performance tests
120+
run: make test-component-perf
85121

86122
electron-typecheck:
87123
runs-on: ubuntu-latest

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ memory/
4848
!internal/memory/
4949
tickets/
5050
.worktrees/
51+
52+
# Component test artifacts
53+
test/component/screenshots/

Makefile

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help build install test test-integration test-component lint coverage coverage-check docker-build run clean restart docker-shell docker-snapshot app-dev app-dev-docker app-install app-build-binary app-dist-linux app-icons
1+
.PHONY: help build install test test-integration test-component test-runner-build test-runner-push lint coverage coverage-check docker-build run clean restart docker-shell docker-snapshot app-dev app-dev-docker app-install app-build-binary app-dist-linux app-icons
22
.DEFAULT_GOAL := help
33

44
help: ## Show available targets
@@ -25,13 +25,40 @@ test: ## Run all tests
2525
test-integration: ## Run integration tests (requires tokens in ~/.loop/config.integration.json)
2626
go test -v -tags integration -race -count=1 -timeout 10m ./internal/slack/ ./internal/discord/
2727

28-
test-component: ## Run component performance tests (via Docker on host, natively in CI)
29-
@if [ "$$CI" = "true" ] || [ -f /.dockerenv ]; then \
30-
bash scripts/test-component.sh; \
28+
test-component-bdd: ## Run BDD component tests (via Docker on host, natively in CI)
29+
@if [ "$$CI" = "true" ] || ([ -f /.dockerenv ] && command -v apt-get >/dev/null 2>&1); then \
30+
TEST_RUN=$${TEST_RUN:-"TestBDDBackendFeatures|TestBDDFrontendFeatures"} bash scripts/test-component.sh; \
31+
else \
32+
docker rm -f loop-bdd 2>/dev/null; \
33+
docker run --name loop-bdd -v "$$(pwd)":/app -w /app \
34+
-v loop-gomod:/go/pkg/mod -v loop-gocache:/root/.cache/go-build \
35+
-e TEST_RUN="$${TEST_RUN:-TestBDDBackendFeatures|TestBDDFrontendFeatures}" \
36+
$(if $(GODOG_TAGS),-e GODOG_TAGS="$(GODOG_TAGS)") \
37+
$(if $(GODOG_CONCURRENCY),-e GODOG_CONCURRENCY="$(GODOG_CONCURRENCY)") \
38+
ghcr.io/radutopala/loop/test-runner:latest bash scripts/test-component.sh; \
39+
fi
40+
41+
test-component-perf: ## Run API performance tests (via Docker on host, natively in CI)
42+
@if [ "$$CI" = "true" ] || ([ -f /.dockerenv ] && command -v apt-get >/dev/null 2>&1); then \
43+
TEST_RUN=TestAPIPerfTestSuite bash scripts/test-component.sh; \
3144
else \
32-
docker run --rm -v "$$(pwd)":/app -w /app golang:1.26 bash scripts/test-component.sh; \
45+
docker run --rm -v "$$(pwd)":/app -w /app \
46+
-v loop-gomod:/go/pkg/mod -v loop-gocache:/root/.cache/go-build \
47+
-e TEST_RUN=TestAPIPerfTestSuite \
48+
ghcr.io/radutopala/loop/test-runner:latest bash scripts/test-component.sh; \
3349
fi
3450

51+
test-component-bdd-host: ## Run frontend BDD tests against host Chrome browser (no Docker)
52+
CHROME_CDP_URL=$${CHROME_CDP_URL:-auto} TEST_RUN=$${TEST_RUN:-TestBDDFrontendFeatures} GODOG_CONCURRENCY=1 bash scripts/test-component.sh
53+
54+
TEST_RUNNER_IMAGE := ghcr.io/radutopala/loop/test-runner:latest
55+
56+
test-runner-build: ## Build the test-runner Docker image
57+
docker build -t $(TEST_RUNNER_IMAGE) -f scripts/test-runner.Dockerfile scripts/
58+
59+
test-runner-push: test-runner-build ## Build and push the test-runner Docker image
60+
docker push $(TEST_RUNNER_IMAGE)
61+
3562
lint: ## Run golangci-lint (with auto-fix)
3663
docker run --rm --name loop-lint -v "$$(pwd)":/app -v /app/app/node_modules -w /app golangci/golangci-lint:v2.11.4 golangci-lint run -v --fix ./...
3764

app/src/api/api.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
let apiUrl = "http://localhost:8222";
22

3-
// When running in a browser (not Electron), probe host.docker.internal first
4-
// so the app works from Docker container browsers, then fall back to localhost.
3+
// When running in a browser (not Electron), probe for the API server.
4+
// Try same-origin first (works when Vite proxies /api), then external URLs.
55
async function probeApiUrl(): Promise<void> {
66
if (typeof window === "undefined") return;
7-
const candidates = ["http://host.docker.internal:8222", "http://localhost:8222"];
7+
const candidates = [
8+
window.location.origin, // same-origin (Vite proxy or co-located server)
9+
"http://host.docker.internal:8222",
10+
"http://localhost:8222",
11+
];
812
for (const url of candidates) {
913
try {
1014
const res = await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(1000) });

app/src/components/layout/HeaderBranchPicker.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export function HeaderBranchPicker({ channelId, branch, onBranchChanged, onCreat
6161
<div ref={ref} style={{ position: "relative", display: "flex", alignItems: "center" }}>
6262
<button
6363
onClick={handleOpen}
64+
title="Branch"
6465
style={{
6566
display: "flex",
6667
alignItems: "center",
@@ -91,6 +92,7 @@ export function HeaderBranchPicker({ channelId, branch, onBranchChanged, onCreat
9192
</button>
9293
{open && (
9394
<div
95+
data-testid="branch-picker"
9496
style={{
9597
position: "absolute",
9698
top: "100%",

app/src/components/panels/BrowserPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export function BrowserPanel({ channelId, fixedMode }: BrowserPanelProps) {
184184

185185
return (
186186
<div
187+
data-testid="browser-panel"
187188
style={{
188189
flex: 1,
189190
display: "flex",

app/src/components/panels/ContainersPanel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export const ContainersPanel = forwardRef<ContainersPanelHandle, ContainersPanel
133133

134134
return (
135135
<div
136+
data-testid="containers-panel"
136137
style={{
137138
flex: 1,
138139
backgroundColor: colors.sidebar,

app/src/components/panels/EditorPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ export function EditorPanel({ channelId, dirPath, branch, embedded, tabsStorageK
541541
}, []);
542542

543543
return (
544-
<FilePanel title="Editor" dirPath={dirPath} branch={branch} noPadding embedded={embedded} {...panelProps}>
544+
<FilePanel title="Editor" dirPath={dirPath} branch={branch} noPadding embedded={embedded} dataTestId="editor-panel" {...panelProps}>
545545
<div style={{ display: "flex", height: "100%", userSelect: treeResizing ? "none" : undefined }}>
546546
{/* File tree */}
547547
<div

app/src/components/panels/FilePanel.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,15 @@ interface FilePanelProps {
4848
noPadding?: boolean;
4949
/** When true, suppress outer chrome (resize, drag region, header) and render only children. */
5050
embedded?: boolean;
51+
dataTestId?: string;
5152
onToggleSidebar?: () => void;
5253
onOpenPalette?: () => void;
5354
onToggleMaximize?: () => void;
5455
onClose: () => void;
5556
children: ReactNode;
5657
}
5758

58-
export function FilePanel({ title, dirPath, branch, maximized, sidebarOpen, noPadding, embedded, onToggleSidebar, onOpenPalette, onToggleMaximize, onClose, children }: FilePanelProps) {
59+
export function FilePanel({ title, dirPath, branch, maximized, sidebarOpen, noPadding, embedded, dataTestId, onToggleSidebar, onOpenPalette, onToggleMaximize, onClose, children }: FilePanelProps) {
5960
const { colors, fontSizes } = useTheme();
6061
const [width, setWidth] = useState(loadWidth);
6162
const [resizing, setResizing] = useState(false);
@@ -72,7 +73,7 @@ export function FilePanel({ title, dirPath, branch, maximized, sidebarOpen, noPa
7273

7374
if (embedded) {
7475
return (
75-
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", backgroundColor: colors.sidebar, zoom: fontSizes.panels / 12, borderRadius: colors.islandRadius, boxShadow: colors.islandShadow, border: colors.islandBorder }}>
76+
<div data-testid={dataTestId} style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", backgroundColor: colors.sidebar, zoom: fontSizes.panels / 12, borderRadius: colors.islandRadius, boxShadow: colors.islandShadow, border: colors.islandBorder }}>
7677
<div style={{ flex: 1, overflow: "auto", padding: noPadding ? 0 : "12px 16px" }}>
7778
{children}
7879
</div>
@@ -110,6 +111,7 @@ export function FilePanel({ title, dirPath, branch, maximized, sidebarOpen, noPa
110111

111112
return (
112113
<div
114+
data-testid={dataTestId}
113115
style={{
114116
width: maximized ? "100%" : width,
115117
minWidth: maximized ? 0 : MIN_WIDTH,
@@ -354,7 +356,7 @@ export function MarkdownFilePanel({ dirPath, branch, ...props }: MarkdownFilePan
354356
}, [content]);
355357

356358
return (
357-
<FilePanel title="README" dirPath={dirPath} branch={branch} {...props}>
359+
<FilePanel title="README" dirPath={dirPath} branch={branch} dataTestId="file-panel" {...props}>
358360
{error && (
359361
<div style={{ color: colors.error, fontSize: 13 }}>{error}</div>
360362
)}

app/src/components/panels/GitPanel.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function GitPanel({ channelId, dirPath, branch, maximized, sidebarOpen, t
100100
const all = new Set(info.branches);
101101
if (info.current) all.add(info.current);
102102
// Also include worktree branches.
103-
for (const wt of info.worktrees) {
103+
for (const wt of info.worktrees ?? []) {
104104
if (wt.branch) all.add(wt.branch);
105105
}
106106
const sorted = [...all].sort();
@@ -404,7 +404,7 @@ export function GitPanel({ channelId, dirPath, branch, maximized, sidebarOpen, t
404404

405405
if (embedded) {
406406
return (
407-
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", backgroundColor: colors.sidebar, zoom: fontSizes.panels / 12 }}>
407+
<div data-testid="git-panel" style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", backgroundColor: colors.sidebar, zoom: fontSizes.panels / 12 }}>
408408
{gitToolbar}
409409
{gitMode === "commits" ? commitsContent : diffContent}
410410
{contextMenuOverlay}
@@ -414,6 +414,7 @@ export function GitPanel({ channelId, dirPath, branch, maximized, sidebarOpen, t
414414

415415
return (
416416
<div
417+
data-testid="git-panel"
417418
ref={panelRef}
418419
style={{
419420
width: maximized ? "100%" : width,

0 commit comments

Comments
 (0)