From 5bc61f08d5196216a270b47b24013a6501d49ed2 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Mon, 25 May 2026 17:34:35 -0400 Subject: [PATCH 1/2] [issues/510] Add manual QA environments (Ubuntu Docker, Cursor) and BATS tests for QA scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds discoverable manual QA environments for platform-specific and IDE-specific test cases. Ubuntu TCs now have a Docker-based desktop environment with VS Code pre-configured. Cursor TCs get a guide file with copy-pasteable commands. Adds 808 lines of BATS tests achieving full branch coverage on resolve-qa-labels.js, the QA label resolution engine used by all QA scripts. ## Changes - Ubuntu Docker QA: Dockerfile (Ubuntu 24.04 + XFCE + VS Code), entrypoint that generates TC checklist, builds extension, and launches noVNC desktop at localhost:6080 - Cursor QA: guide file written to fixture workspace root with TC IDs, scenarios, and commands for assisted TCs - npm scripts: test:release:ubuntu, test:release:cursor for discoverable access to both environments - BATS tests: 129 tests (808 lines) covering all branches of resolve-qa-labels.js — argument parsing, YAML auto-discovery, parsing, JSON grouping, filtering, and output formats - .vscodeignore cleanup: excludes qa/, test-fixtures/, dev docs, temp files, and Docker artifacts from .vsix (was shipping 4MB+ of test data) - Docker volume for node_modules: isolates Linux-native binaries from host macOS install ## Test Plan - [ ] All 1979 unit tests pass - [ ] All 129 BATS tests pass - [ ] Manual: `pnpm test:release:ubuntu` builds and launches Ubuntu desktop with extension installed - [ ] Manual: `pnpm test:release:cursor` prints guide and launches Cursor with fixture workspace - [ ] Manual: `pnpm test:release:grep ` works inside Ubuntu container ## Related - Closes https://github.com/couimet/rangeLink/issues/510 --- .gitignore | 5 + package.json | 2 + .../rangelink-vscode-extension/.vscodeignore | 28 +- .../rangelink-vscode-extension/TESTING.md | 35 + .../docker/Dockerfile.ubuntu | 48 ++ .../docker/entrypoint.sh | 86 ++ .../rangelink-vscode-extension/package.json | 2 + .../qa/qa-test-cases-v1.1.0.yaml | 59 +- .../scripts/generate-qa-issue.sh | 4 +- .../scripts/generate-qa-test-plan.sh | 4 + .../generate-release-testing-instructions.sh | 12 +- .../scripts/qa-cursor.sh | 60 ++ .../scripts/qa-ubuntu-docker.sh | 102 +++ .../scripts/resolve-qa-labels.js | 26 +- .../suite/builtInAiAssistants.test.ts | 246 ++++++ tests/shell/resolve-qa-labels.bats | 778 ++++++++++++++++++ 16 files changed, 1447 insertions(+), 50 deletions(-) create mode 100644 packages/rangelink-vscode-extension/docker/Dockerfile.ubuntu create mode 100755 packages/rangelink-vscode-extension/docker/entrypoint.sh create mode 100755 packages/rangelink-vscode-extension/scripts/qa-cursor.sh create mode 100755 packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh create mode 100644 tests/shell/resolve-qa-labels.bats diff --git a/.gitignore b/.gitignore index 9bc7624fe..ae1dfed52 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ packages/*/icon_large.png # Claude config .claude/settings.local.json +# Docker image build stamp (local timestamp for rebuild detection) +packages/rangelink-vscode-extension/.docker-image-stamp + # Temp files created by integration test suites (cleaned up on normal exit; guard for CI kills) packages/rangelink-vscode-extension/__rl-test-*.ts packages/rangelink-vscode-extension/__rl-test-*.txt @@ -39,11 +42,13 @@ packages/rangelink-vscode-extension/.vscode/ # Generated QA output (not committed) packages/rangelink-vscode-extension/qa/output/ +packages/rangelink-vscode-extension/qa/fixtures/workspace/cursor-qa-guide.txt # Folders where Claude Code is told to create and manage working documents .breadcrumbs/ .claude-questions/ .commit-msgs/ .scratchpads/ + # Claude skill working directories .claude-work/ diff --git a/package.json b/package.json index 0ab07d132..aeaff1aa7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "test:bats": "bats tests/shell/", "test:release": "pnpm --filter rangelink-vscode-extension test:release", "test:release:automated": "pnpm --filter rangelink-vscode-extension test:release:automated", + "test:release:cursor": "pnpm --filter rangelink-vscode-extension test:release:cursor", "test:release:grep": "pnpm --filter rangelink-vscode-extension test:release:grep", + "test:release:ubuntu": "pnpm --filter rangelink-vscode-extension test:release:ubuntu", "test:release:with-extensions": "pnpm --filter rangelink-vscode-extension test:release:with-extensions", "validate:qa-coverage:vscode-extension": "pnpm --filter rangelink-vscode-extension validate:qa-coverage", "verify:qa-scripts:vscode-extension": "pnpm --filter rangelink-vscode-extension verify:qa-scripts", diff --git a/packages/rangelink-vscode-extension/.vscodeignore b/packages/rangelink-vscode-extension/.vscodeignore index ae8cfacf1..a86427459 100644 --- a/packages/rangelink-vscode-extension/.vscodeignore +++ b/packages/rangelink-vscode-extension/.vscodeignore @@ -18,10 +18,30 @@ tsconfig.json out/** .vscode/** .vscode-test/** +.vscode-test.*.mjs node_modules/** coverage/** jest.config.* scripts/** +**/jest.config.* +esbuild.config.js + +# Documentation (only README ships) +DEVELOPMENT.md +TESTING.md +PUBLISHING.md +**/*.md + +# QA and test fixtures (large, not needed at runtime) +qa/** +test-fixtures/** + +# Docker +docker/** +.docker-image-stamp + +# Temp files from integration tests +__rl-test-* # Repository files .git* @@ -30,7 +50,13 @@ scripts/** # Keep these (automatic): # - package.json -# - README.md +# - README.md (let through despite **/*.md above) # - LICENSE # - icon.png # - dist/extension.js (bundled main entry) +!readme.md +!README.md +!changelog.md +!CHANGELOG.md +!LICENSE* +!icon*.png diff --git a/packages/rangelink-vscode-extension/TESTING.md b/packages/rangelink-vscode-extension/TESTING.md index e9b7f5d0b..936467a82 100644 --- a/packages/rangelink-vscode-extension/TESTING.md +++ b/packages/rangelink-vscode-extension/TESTING.md @@ -15,6 +15,8 @@ | Integration (CI-safe) | `pnpm test:release:automated` | CI / headless environments | ✅ | | Integration (extensions) | `pnpm test:release:with-extensions` | Tests needing real AI extensions | ✅ | | Integration (filter) | `pnpm test:release:grep ""` | Run specific TCs by ID or suite | — | +| Ubuntu manual QA | `pnpm test:release:ubuntu` | Manual QA of Ctrl+R keybindings | — | +| Cursor manual QA | `pnpm test:release:cursor` | Manual QA of Cursor IDE TCs | — | | Prepare QA test plan | `pnpm generate:qa-test-plan:vscode-extension` | Start of release cycle | — | | Generate QA issue | `pnpm generate:qa-issue:vscode-extension` | At the start of each release cycle | — | | Local QA checklist | `pnpm generate:qa-issue:vscode-extension -- --local` | Offline QA / before manual pass | — | @@ -233,6 +235,39 @@ Steps run in this order: --- +## Manual QA Environments + +Some TCs are marked `automated: false` with a `non_automatable_reason` because they require a specific platform or IDE that cannot be tested in the standard extension host. The scripts below launch dedicated environments for these TCs. + +### Ubuntu (Ctrl+R keybindings) + +`platform-specific` TCs require a Linux environment where `Ctrl` (not `Cmd`) is the primary modifier key. The provided Docker container runs Ubuntu 24.04 with XFCE desktop, VS Code, and the extension's repo mounted at `/workspace`. + +**Prerequisites:** Docker Desktop (or Docker Engine) installed and running. + +```bash +# Builds image on first run (or after Dockerfile changes: docker build -t rangelink-qa-ubuntu -f docker/Dockerfile.ubuntu .) +pnpm test:release:ubuntu +``` + +The container opens a noVNC web desktop at http://localhost:6080/vnc.html. Open a terminal inside the desktop, run the extension tests, or manually verify `Ctrl+R` keybinding variants against the fixture workspace at `/workspace/packages/rangelink-vscode-extension/qa/fixtures/workspace`. + +The Dockerfile is at `docker/Dockerfile.ubuntu`. VS Code is installed from the Microsoft apt repo. + +### Cursor (IDE-specific tests) + +`ide-specific` TCs require Cursor IDE. These can only be verified manually — Cursor has no headless extension host mode for third-party extensions. + +```bash +pnpm test:release:cursor +``` + +Builds the extension, installs it in Cursor, prints the Cursor-specific TCs to verify, then launches Cursor. Any workspace works — the fixture is just a convenience with a few file types ready. + +The `cursor` CLI must be on your PATH. If it's not, install it from Cursor's command palette: `Cmd+Shift+P` → "Install 'cursor' command". + +--- + ## QA Test Plan The QA test plan is a version-scoped YAML file that tracks both automated and manual test cases for a given release cycle. diff --git a/packages/rangelink-vscode-extension/docker/Dockerfile.ubuntu b/packages/rangelink-vscode-extension/docker/Dockerfile.ubuntu new file mode 100644 index 000000000..4d1054465 --- /dev/null +++ b/packages/rangelink-vscode-extension/docker/Dockerfile.ubuntu @@ -0,0 +1,48 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV DISPLAY=:1 + +# XFCE desktop + VNC + noVNC + VS Code prerequisites +RUN apt-get update && apt-get install -y --no-install-recommends \ + xfce4 xfce4-terminal dbus-x11 \ + tigervnc-standalone-server tigervnc-tools \ + novnc websockify \ + wget curl ca-certificates gnupg git \ + libnss3 libatk-bridge2.0-0 libdrm2 libgbm1 libasound2t64 \ + libx11-xcb1 libxcb-dri3-0 libxcomposite1 libxcursor1 libxdamage1 \ + libxi6 libxtst6 libxrandr2 libxss1 \ + && rm -rf /var/lib/apt/lists/* + +# VS Code from Microsoft apt repo +RUN wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/code stable main" > /etc/apt/sources.list.d/vscode.list \ + && apt-get update && apt-get install -y --no-install-recommends code \ + && rm -rf /var/lib/apt/lists/* + +# Node.js 22 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Wrapper: VS Code refuses to run as root without --no-sandbox + --user-data-dir. +# argv.json isn't read early enough to satisfy the root-user guard. +RUN mv /usr/bin/code /usr/bin/code-real && \ + echo '#!/bin/sh' > /usr/bin/code && \ + echo 'exec /usr/bin/code-real --no-sandbox --user-data-dir /root/.vscode-data "$@"' >> /usr/bin/code && \ + chmod +x /usr/bin/code && \ + mkdir -p /root/Desktop /root/.vnc && \ + echo '#!/bin/sh' > /root/.vnc/xstartup && \ + echo 'startxfce4' >> /root/.vnc/xstartup && \ + chmod +x /root/.vnc/xstartup + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +WORKDIR /workspace + +EXPOSE 6080 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/packages/rangelink-vscode-extension/docker/entrypoint.sh b/packages/rangelink-vscode-extension/docker/entrypoint.sh new file mode 100755 index 000000000..ab3d57c86 --- /dev/null +++ b/packages/rangelink-vscode-extension/docker/entrypoint.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +export CI=true + +# Generate Ubuntu TC checklist on the desktop +echo "==> Extracting Ubuntu TCs from QA YAML..." + +TC_FILE="/root/Desktop/qa-ubuntu-tests.txt" + +node /workspace/packages/rangelink-vscode-extension/scripts/resolve-qa-labels.js --json 2>/dev/null | node -e " + const d = JSON.parse(require('fs').readFileSync(0, 'utf8')); + const tcs = d.ubuntu_tcs; + const lines = []; + lines.push('Ubuntu QA — TCs to verify'); + lines.push('============================='); + lines.push(''); + lines.push('Copy-paste the commands below into this VS Code terminal.'); + lines.push(''); + if (!tcs.length) { + lines.push('No Ubuntu-specific TCs found.'); + } else { + for (const tc of tcs) { + const status = tc.automated === false ? 'manual' : tc.automated; + const reason = tc.nonAutomatableReason ? ' (' + tc.nonAutomatableReason + ')' : ''; + lines.push('[' + status + reason + '] ' + tc.id); + lines.push(' ' + tc.scenario); + if (tc.automated !== false) { + lines.push(' $ pnpm test:release:grep ' + tc.id); + } + lines.push(''); + } + } + lines.push(''); + lines.push('Fixture workspace: /workspace/packages/rangelink-vscode-extension/qa/fixtures/workspace'); + require('fs').writeFileSync('$TC_FILE', lines.join('\n')); +" + +echo "==> Wrote $TC_FILE" + +# First run: populate the container-native node_modules volume (Linux binaries for esbuild etc.) +if [[ ! -d /workspace/node_modules/.pnpm ]]; then + echo "==> Installing dependencies (first run, cached on subsequent runs)..." + cd /workspace + pnpm install 2>&1 + echo "==> Dependencies ready" +fi + +# Build and package the extension (Linux-native esbuild from the Docker volume). +# vsce triggers vscode:prepublish which runs tests — skip that by calling vsce directly. +echo "==> Building extension..." +cd /workspace +pnpm --filter rangelink-vscode-extension compile 2>&1 +cd /workspace/packages/rangelink-vscode-extension +../../scripts/sync-assets.sh 2>&1 +npx vsce package --no-dependencies 2>&1 +VSIX=$(ls /workspace/packages/rangelink-vscode-extension/rangelink-vscode-extension-*.vsix 2>/dev/null | head -1) +if [[ -z "$VSIX" ]]; then + echo "ERROR: .vsix not found after build — extension will not be installed" >&2 + exit 1 +fi + +# Start VNC server on :1 — no auth for local Docker use +tigervncserver :1 -geometry 1680x1050 -depth 24 -localhost yes -SecurityTypes None + +# Install the extension into VS Code (needs X running) +sleep 2 +if [[ -n "$VSIX" ]]; then + echo "==> Installing extension: $VSIX" + DISPLAY=:1 code --install-extension "$VSIX" 2>&1 || true +fi + +# Launch VS Code with fixture workspace +DISPLAY=:1 code "/workspace/packages/rangelink-vscode-extension/qa/fixtures/workspace" & + +# Bridge VNC (5901) to WebSocket (6080) +websockify --web /usr/share/novnc 0.0.0.0:6080 localhost:5901 & + +echo "" +echo "=============================================" +echo " Ubuntu QA Desktop ready" +echo " Open: http://localhost:6080/vnc.html" +echo "=============================================" +echo "" + +wait diff --git a/packages/rangelink-vscode-extension/package.json b/packages/rangelink-vscode-extension/package.json index b822a75f3..ea71882c6 100644 --- a/packages/rangelink-vscode-extension/package.json +++ b/packages/rangelink-vscode-extension/package.json @@ -52,8 +52,10 @@ "test:fast": "jest --coverage --testPathIgnorePatterns '/src/__integration-tests__/' '\\.integration\\.test\\.ts$'", "test:release": "./scripts/run-integration-tests.sh", "test:release:automated": "./scripts/run-integration-tests.sh --automated --exclude-label requires-extensions --exclude-label cursor", + "test:release:cursor": "./scripts/qa-cursor.sh", "test:release:grep": "./scripts/run-integration-tests.sh --grep", "test:release:prepare": "pnpm compile && rm -rf out/__integration-tests__ && tsc -p tsconfig.integration.json && node scripts/setup-integration-test-settings.js", + "test:release:ubuntu": "./scripts/qa-ubuntu-docker.sh run", "test:release:with-extensions": "./scripts/run-integration-tests.sh --with-extensions", "test:watch": "jest --watch", "validate:qa-coverage": "./scripts/validate-qa-coverage.sh", diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml index ff6cc533f..fd560a33d 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml @@ -20,6 +20,10 @@ # assisted — covered by an [assisted]-tagged integration test # that pauses for a human UI action # false — fully manual; preconditions and steps are required +# non_automatable_reason: Required when `automated: false`. Why this TC cannot be +# automated (even assisted): +# platform-specific — requires Win/Linux; CI runs on macOS +# ide-specific — requires Cursor IDE; not in VS Code host # # For `automated: true` and `automated: assisted` entries the integration test in # src/__integration-tests__/suite/ is the canonical source of setup and actions — @@ -59,6 +63,7 @@ test_cases: - 'Press Ctrl+R Ctrl+M' expected_result: 'Same QuickPick menu opens as clicking the status bar item.' automated: false + non_automatable_reason: platform-specific - id: status-bar-menu-005 feature: 'R-M Status Bar Menu' @@ -218,6 +223,7 @@ test_cases: - 'Open the terminal picker' expected_result: 'Hidden background terminals do not appear in the picker. Only user-visible terminals are listed.' automated: false + non_automatable_reason: ide-specific - id: terminal-picker-007 feature: 'Terminal Picker' @@ -504,17 +510,8 @@ test_cases: - clipboard feature: 'Clipboard Preservation — AI Assistant Lifecycle' scenario: 'Two rapid R-L operations to the same AI assistant — last write wins, prior content restored once' - preconditions: - - 'Claude Code bound as destination' - - 'rangelink.clipboard.preserve = "always" (default)' - steps: - - 'Copy "prior-content" to clipboard' - - 'Select lines 1-2 and press R-L (first send)' - - 'Immediately select lines 3-4 and press R-L (second send) without waiting for first to complete' - - 'After both pastes, paste (Cmd+V) into a text editor' - - 'Verify clipboard state' expected_result: 'Both RangeLinks are delivered. Prior clipboard content is restored after the last operation completes. No clipboard corruption from overlapping writes.' - automated: false + automated: assisted # --------------------------------------------------------------------------- # Section 6 — Send File Path Commands @@ -552,6 +549,7 @@ test_cases: - 'Observe terminal' expected_result: 'Terminal receives workspace-relative path. Same as send-file-path-001.' automated: false + non_automatable_reason: platform-specific - id: send-file-path-004 labels: @@ -1008,6 +1006,7 @@ test_cases: - 'Observe bound destination' expected_result: 'Same as send-terminal-selection-001.' automated: false + non_automatable_reason: platform-specific - id: send-terminal-selection-003 labels: @@ -1176,6 +1175,7 @@ test_cases: - 'Press Ctrl+R Ctrl+U' expected_result: 'Destination is unbound. Same confirmation behavior as unbind-001.' automated: false + non_automatable_reason: platform-specific - id: unbind-003 feature: 'R-U Unbind' @@ -1911,6 +1911,8 @@ test_cases: automated: assisted - id: cursor-ai-001 + labels: + - cursor feature: 'Built-in AI Assistants' scenario: 'Cursor AI Assistant appears in destination picker when running in Cursor IDE' preconditions: @@ -1921,10 +1923,12 @@ test_cases: - 'Confirm "Cursor AI Assistant" item appears in the AI Assistants group' expected_result: '"Cursor AI Assistant" is listed in the AI Assistants group of the destination picker' automated: false + non_automatable_reason: ide-specific - id: cursor-ai-002 labels: - clipboard + - cursor feature: 'Built-in AI Assistants' scenario: 'Binding to Cursor AI and sending a link delivers content to chat' preconditions: @@ -1937,6 +1941,7 @@ test_cases: - 'Confirm Cursor AI chat panel opens and receives the link + selected text' expected_result: 'Cursor AI chat opens and contains the RangeLink and selected code snippet' automated: false + non_automatable_reason: ide-specific - id: github-copilot-chat-001 feature: 'Built-in AI Assistants' @@ -1949,16 +1954,8 @@ test_cases: - clipboard feature: 'Built-in AI Assistants' scenario: 'Binding to GitHub Copilot Chat and sending a link delivers content to chat' - preconditions: - - 'GitHub Copilot Chat extension (GitHub.copilot-chat) is installed and active' - - 'A file is open in the editor with a selection' - steps: - - 'Run Command Palette → "RangeLink: Bind to GitHub Copilot Chat" (or select from R-D picker)' - - 'Confirm "✓ RangeLink bound to GitHub Copilot Chat" status bar message appears' - - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' - - 'Confirm GitHub Copilot Chat panel opens and receives the link + selected text' expected_result: 'GitHub Copilot Chat opens and contains the RangeLink and selected code snippet' - automated: false + automated: assisted - id: claude-code-004 labels: @@ -2004,6 +2001,7 @@ test_cases: - id: cursor-ai-003 labels: - clipboard + - cursor feature: 'Built-in AI Assistants' scenario: 'Cold panel paste verification — content arrives in Cursor AI chat input after a single R-L since bind' preconditions: @@ -2016,10 +2014,12 @@ test_cases: - 'Confirm logs show the single paste command (editor.action.clipboardPasteAction) succeeded' expected_result: 'Content arrives in Cursor AI chat input. Logs show paste command succeeded. Cursor AI is unaffected by the webview async clipboard read issue (native input).' automated: false + non_automatable_reason: ide-specific - id: cursor-ai-004 labels: - clipboard + - cursor feature: 'Built-in AI Assistants' scenario: 'Warm panel paste verification — content arrives in Cursor AI chat input on second R-L without cold-start refocus signals' preconditions: @@ -2032,38 +2032,23 @@ test_cases: - 'Confirm logs show NO cold-start refocus signals in the warm send' expected_result: 'Content arrives in Cursor AI chat input. Logs show no cold-start refocus signals. Paste command succeeds with minimal delay.' automated: false + non_automatable_reason: ide-specific - id: github-copilot-chat-003 labels: - clipboard feature: 'Built-in AI Assistants' scenario: 'Cold panel paste verification — content arrives in GitHub Copilot Chat input after a single R-L since bind' - preconditions: - - 'GitHub Copilot Chat extension (GitHub.copilot-chat) is installed and active' - - 'RangeLink is bound to GitHub Copilot Chat' - - 'No send has been performed since binding (cold start)' - steps: - - 'Select a range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' - - 'Confirm the GitHub Copilot Chat panel receives the link and selected code' - - 'Confirm logs show the single paste command (editor.action.clipboardPasteAction) succeeded' expected_result: 'Content arrives in GitHub Copilot Chat input. Logs show paste command succeeded with a post-paste delay. Single clipboard write architecture: no double-write.' - automated: false + automated: assisted - id: github-copilot-chat-004 labels: - clipboard feature: 'Built-in AI Assistants' scenario: 'Warm panel paste verification — content arrives in GitHub Copilot Chat input on second R-L without cold-start refocus signals' - preconditions: - - 'GitHub Copilot Chat extension (GitHub.copilot-chat) is installed and active' - - 'RangeLink is bound to GitHub Copilot Chat' - - 'At least one send has been performed since binding (warm path)' - steps: - - 'Select a different range of code and press Cmd+R Cmd+L (Mac) / Ctrl+R Ctrl+L (Win/Linux)' - - 'Confirm the GitHub Copilot Chat panel receives the link and selected code' - - 'Confirm logs show NO cold-start refocus signals in the warm send' expected_result: 'Content arrives in GitHub Copilot Chat input. Logs show no cold-start refocus signals. Paste command succeeds with minimal delay.' - automated: false + automated: assisted - id: gemini-code-assist-001 labels: diff --git a/packages/rangelink-vscode-extension/scripts/generate-qa-issue.sh b/packages/rangelink-vscode-extension/scripts/generate-qa-issue.sh index 12707bf26..492e180ed 100755 --- a/packages/rangelink-vscode-extension/scripts/generate-qa-issue.sh +++ b/packages/rangelink-vscode-extension/scripts/generate-qa-issue.sh @@ -154,7 +154,7 @@ for i in $(seq 0 $((TOTAL_GROUPS - 1))); do done # Cursor section — sub-checkboxes for each cursor-labeled TC -CURSOR_SECTION=$(echo "$GROUPS_JSON" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));const t=d.cursor_tcs;if(!t.length)process.exit(0);const l=['- [ ] **Cursor — IDE-Specific Tests** — \`pnpm package:vscode-extension:withInstall:both && cursor qa/fixtures/workspace\`:'];for(const x of t){const a=x.automated===false?'manual':x.automated;l.push(' - [ ] '+x.id+' ('+a+'): '+x.scenario);}process.stdout.write(l.join('\n'))") +CURSOR_SECTION=$(echo "$GROUPS_JSON" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));const t=d.cursor_tcs;if(!t.length)process.exit(0);const l=['- [ ] **Cursor — IDE-Specific Tests** — \`pnpm test:release:cursor\`:'];for(const x of t){const a=x.automated===false?'manual':x.automated;const r=x.nonAutomatableReason?', '+x.nonAutomatableReason:'';l.push(' - [ ] '+x.id+' ('+a+r+'): '+x.scenario);}process.stdout.write(l.join('\n'))") CURSOR_COUNT=$(echo "$GROUPS_JSON" | node -e "process.stdout.write(String(JSON.parse(require('fs').readFileSync(0,'utf8')).cursor_tcs.length))") if [[ "$CURSOR_COUNT" -gt 0 ]]; then GROUP_CHECKBOXES+=$'\n' @@ -162,7 +162,7 @@ if [[ "$CURSOR_COUNT" -gt 0 ]]; then fi # Ubuntu section — sub-checkboxes for each ubuntu-labeled TC -UBUNTU_SECTION=$(echo "$GROUPS_JSON" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));const t=d.ubuntu_tcs;if(!t.length)process.exit(0);const l=['- [ ] **Ubuntu — Ctrl+R Keybindings** — \`./scripts/qa-ubuntu-docker.sh\` (Docker):'];for(const x of t){const a=x.automated===false?'manual':x.automated;l.push(' - [ ] '+x.id+' ('+a+'): '+x.scenario);}process.stdout.write(l.join('\n'))") +UBUNTU_SECTION=$(echo "$GROUPS_JSON" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));const t=d.ubuntu_tcs;if(!t.length)process.exit(0);const l=['- [ ] **Ubuntu — Ctrl+R Keybindings** — \`pnpm test:release:ubuntu\`:'];for(const x of t){const a=x.automated===false?'manual':x.automated;const r=x.nonAutomatableReason?', '+x.nonAutomatableReason:'';l.push(' - [ ] '+x.id+' ('+a+r+'): '+x.scenario);}process.stdout.write(l.join('\n'))") UBUNTU_COUNT=$(echo "$GROUPS_JSON" | node -e "process.stdout.write(String(JSON.parse(require('fs').readFileSync(0,'utf8')).ubuntu_tcs.length))") if [[ "$UBUNTU_COUNT" -gt 0 ]]; then GROUP_CHECKBOXES+=$'\n' diff --git a/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh b/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh index 6c7bcc251..12651c863 100755 --- a/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh +++ b/packages/rangelink-vscode-extension/scripts/generate-qa-test-plan.sh @@ -83,6 +83,10 @@ HEADER="# RangeLink QA Test Cases — v${PUBLISHED_VERSION} → v${NEXT_VERSION} # assisted — covered by an [assisted]-tagged integration test # that pauses for a human UI action # false — fully manual; preconditions and steps are required +# non_automatable_reason: Required when \`automated: false\`. Why this TC cannot be +# automated (even assisted): +# platform-specific — requires Win/Linux; CI runs on macOS +# ide-specific — requires Cursor IDE; not in VS Code host # # For \`automated: true\` and \`automated: assisted\` entries the integration test in # src/__integration-tests__/suite/ is the canonical source of setup and actions — diff --git a/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh b/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh index 036e9c7be..61744e9c9 100755 --- a/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh +++ b/packages/rangelink-vscode-extension/scripts/generate-release-testing-instructions.sh @@ -128,7 +128,7 @@ git commit -m "Add QA test plan for v${NEXT_VERSION}" ## Phase 2: Create GitHub QA Issues -Generate the GitHub issue tracker (parent issue + per-section sub-issues) from the QA YAML. +Generate the GitHub issue tracker (a single issue with grouped checkboxes per feature domain) from the QA YAML. ### Dry run first @@ -144,9 +144,7 @@ Review the output to verify section groupings and TC counts look correct. pnpm generate:qa-issue:vscode-extension \`\`\` -Verify on GitHub: -- Parent issue titled "QA Checklist — v${NEXT_VERSION}" exists -- One sub-issue per feature section is linked to the parent +The script prints the created issue URL. --- @@ -181,11 +179,7 @@ pnpm generate:qa-issue:vscode-extension --local \`\`\` The generated checklist is at \`qa/output/qa-checklist-v${NEXT_VERSION}-.md\`. - -**Suggested order:** -1. **Ready-now TCs** — no terminal setup needed, test immediately -2. **Open 1+ terminals** and bind a destination (\`R-D\`) -3. **Terminal-dependent TCs** — require a bound destination +Each TC is annotated with its automation status and reason. Cursor and Ubuntu TCs are grouped into their own sections with the required run commands inline. --- diff --git a/packages/rangelink-vscode-extension/scripts/qa-cursor.sh b/packages/rangelink-vscode-extension/scripts/qa-cursor.sh new file mode 100755 index 000000000..1c9a3e484 --- /dev/null +++ b/packages/rangelink-vscode-extension/scripts/qa-cursor.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(dirname "$SCRIPT_DIR")" +MONOREPO_ROOT="$(git -C "$PACKAGE_DIR" rev-parse --show-toplevel)" +GUIDE_FILE="$PACKAGE_DIR/qa/output/cursor-qa-guide.txt" +mkdir -p "$(dirname "$GUIDE_FILE")" + +echo "" +echo "=============================================" +echo " Cursor Manual QA" +echo "=============================================" +echo "" + +# Extract Cursor TCs from the latest QA YAML and write a guide file. +node "$SCRIPT_DIR/resolve-qa-labels.js" --json | node -e " + const d = JSON.parse(require('fs').readFileSync(0, 'utf8')); + const tcs = d.cursor_tcs; + const lines = []; + lines.push('Cursor QA — TCs to verify'); + lines.push('==========================='); + lines.push(''); + if (!tcs.length) { + lines.push('No Cursor TCs found in QA YAML.'); + } else { + for (const tc of tcs) { + const status = tc.automated === false ? 'manual' : tc.automated; + const reason = tc.nonAutomatableReason ? ' (' + tc.nonAutomatableReason + ')' : ''; + lines.push('[' + status + reason + '] ' + tc.id); + lines.push(' ' + tc.scenario); + lines.push(''); + } + } + lines.push(''); + lines.push('Fixture workspace: packages/rangelink-vscode-extension/qa/fixtures/workspace'); + const content = lines.join('\n'); + console.log(content); + require('fs').writeFileSync('$GUIDE_FILE', content); +" + +if [[ ! -s "$GUIDE_FILE" ]]; then + echo "ERROR: Failed to write guide file: $GUIDE_FILE" >&2 + exit 1 +fi + +if grep -q "No Cursor TCs found" "$GUIDE_FILE"; then + echo "No Cursor TCs found — nothing to test." + exit 0 +fi + +cd "$MONOREPO_ROOT" +pnpm package:vscode-extension:withInstall:both + +cp "$GUIDE_FILE" "$PACKAGE_DIR/qa/fixtures/workspace/cursor-qa-guide.txt" +echo "" +echo "Guide: qa/fixtures/workspace/cursor-qa-guide.txt (also at qa/output/)" +echo "" +echo "Launching Cursor with fixture workspace..." +cursor "packages/rangelink-vscode-extension/qa/fixtures/workspace" diff --git a/packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh b/packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh new file mode 100755 index 000000000..1876cdc25 --- /dev/null +++ b/packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKAGE_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(git -C "$PACKAGE_DIR" rev-parse --show-toplevel)" +GIT_COMMON_DIR="$(git -C "$REPO_ROOT" rev-parse --git-common-dir)" +[[ "$GIT_COMMON_DIR" != /* ]] && GIT_COMMON_DIR="$REPO_ROOT/$GIT_COMMON_DIR" + +IMAGE="rangelink-qa-ubuntu" +CONTAINER="rangelink-qa-ubuntu-$(date +%s)" +DOCKERFILE="${PACKAGE_DIR}/docker/Dockerfile.ubuntu" +ENTRYPOINT="${PACKAGE_DIR}/docker/entrypoint.sh" +STAMP="${PACKAGE_DIR}/.docker-image-stamp" + +build() { + echo "==> Building Docker image: ${IMAGE}" + docker build -t "${IMAGE}" -f "$DOCKERFILE" "${PACKAGE_DIR}" + touch "$STAMP" + echo "==> Build complete" +} + +needs_rebuild() { + if ! docker image inspect "${IMAGE}" &>/dev/null; then + return 0 + fi + if [[ ! -f "$STAMP" ]]; then + return 0 + fi + [[ "$DOCKERFILE" -nt "$STAMP" || "$ENTRYPOINT" -nt "$STAMP" ]] +} + +print_ubuntu_tcs() { + echo "" + echo "=============================================" + echo " Ubuntu TCs to verify" + echo "=============================================" + echo "" + + node "$SCRIPT_DIR/resolve-qa-labels.js" --json 2>/dev/null | node -e " + const d = JSON.parse(require('fs').readFileSync(0, 'utf8')); + const tcs = d.ubuntu_tcs; + if (!tcs.length) { + console.log('No Ubuntu-specific TCs found.'); + } else { + for (const tc of tcs) { + const status = tc.automated === false ? 'manual' : tc.automated; + const reason = tc.nonAutomatableReason ? ' (' + tc.nonAutomatableReason + ')' : ''; + console.log(' [' + status + reason + '] ' + tc.id); + console.log(' ' + tc.scenario); + console.log(''); + } + } + " + + echo "TCs also written to ~/Desktop/qa-ubuntu-tests.txt inside the container." + echo "" +} + +run() { + # Remove any existing rangelink-qa-ubuntu containers (running, stopped, or crashed) + local existing + existing=$(docker ps -aq --filter "name=rangelink-qa-ubuntu" 2>/dev/null) + if [[ -n "$existing" ]]; then + echo "==> Removing existing container(s)..." + docker rm -f $existing >/dev/null 2>&1 + fi + + echo "==> Starting container: ${CONTAINER}" + echo " Repo mounted at /workspace" + echo " Git mounted at ${GIT_COMMON_DIR}" + echo "" + docker run \ + --name "${CONTAINER}" \ + -p 6080:6080 \ + --shm-size=2g \ + --rm \ + -v "${REPO_ROOT}:/workspace" \ + -v "${GIT_COMMON_DIR}:${GIT_COMMON_DIR}:ro" \ + -v rangelink-qa-node-modules:/workspace/node_modules \ + "${IMAGE}" +} + +case "${1:-run}" in + build) + build + ;; + run) + if needs_rebuild; then + echo "Dockerfile/entrypoint changed — rebuilding..." + build + fi + print_ubuntu_tcs + run + ;; + *) + echo "Usage: $0 [build|run]" >&2 + echo " build Build the Docker image" >&2 + echo " run Run the container (default; auto-rebuilds if Dockerfile changed)" >&2 + exit 1 + ;; +esac diff --git a/packages/rangelink-vscode-extension/scripts/resolve-qa-labels.js b/packages/rangelink-vscode-extension/scripts/resolve-qa-labels.js index 891039f23..d8a39fff6 100755 --- a/packages/rangelink-vscode-extension/scripts/resolve-qa-labels.js +++ b/packages/rangelink-vscode-extension/scripts/resolve-qa-labels.js @@ -150,7 +150,13 @@ for (const rawLine of lines) { const idMatch = line.match(/^\s+- id:\s*(.+)$/); if (idMatch) { finalizeCurrent(); - currentCase = { id: idMatch[1].trim(), automated: '', labels: [], feature: '' }; + currentCase = { + id: idMatch[1].trim(), + automated: '', + labels: [], + feature: '', + nonAutomatableReason: '', + }; continue; } @@ -168,6 +174,18 @@ for (const rawLine of lines) { continue; } + const reasonMatch = line.match(/^\s+non_automatable_reason:\s*(.+)$/); + if (reasonMatch) { + const raw = reasonMatch[1].trim(); + currentCase.nonAutomatableReason = + raw.length >= 2 && + ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) + ? raw.slice(1, -1) + : raw; + inLabels = false; + continue; + } + const featMatch = line.match(/^\s+feature:\s*(.+)$/); if (featMatch) { const rawFeature = featMatch[1].trim(); @@ -236,6 +254,7 @@ if (jsonOutput) { for (const prefix of sortedPrefixes) { const tcList = groups[prefix]; const featureCounts = {}; + const reasonCounts = {}; let assistedCount = 0; let manualCount = 0; let requiresExtensions = false; @@ -249,6 +268,7 @@ if (jsonOutput) { feature: tc.feature, scenario: tc.scenario || '', automated: tc.automated === 'false' ? false : tc.automated, + nonAutomatableReason: tc.nonAutomatableReason || undefined, }); continue; } @@ -259,6 +279,7 @@ if (jsonOutput) { feature: tc.feature, scenario: tc.scenario || '', automated: tc.automated === 'false' ? false : tc.automated, + nonAutomatableReason: tc.nonAutomatableReason || undefined, }); continue; } @@ -273,6 +294,8 @@ if (jsonOutput) { assistedCount++; } else if (tc.automated === 'false') { manualCount++; + const reason = tc.nonAutomatableReason || 'unspecified'; + reasonCounts[reason] = (reasonCounts[reason] || 0) + 1; } } @@ -298,6 +321,7 @@ if (jsonOutput) { manual: manualCount, total: nonAutomated, requires_extensions: requiresExtensions, + ...(Object.keys(reasonCounts).length > 0 ? { reasons: reasonCounts } : {}), }); } diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts index 252486680..ad66b96dd 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -432,6 +432,191 @@ standardSuite('Built-in AI Assistants', (ss) => { ss.log('✓ Warm paste: content delivered to Gemini Code Assist (cold + warm both PASS)'); }); + test('[assisted] github-copilot-chat-002: binding to GitHub Copilot Chat and sending a link delivers content to chat', async () => { + const fileUri = ss.createWorkspaceFile('ghc-002', 'line 1\nline 2\nline 3\n'); + await ss.openEditor(fileUri); + await ss.settle(); + + const logCapture = getLogCapture(); + logCapture.mark('before-ghc-002-bind'); + + await vscode.commands.executeCommand(CMD_BIND_TO_GITHUB_COPILOT_CHAT); + await ss.settle(); + + const bindLines = logCapture.getLinesSince('before-ghc-002-bind'); + assertStatusBarMsgLogged(bindLines, { + message: '✓ RangeLink: Bound to GitHub Copilot Chat', + }); + + const doc = await vscode.workspace.openTextDocument(fileUri); + const editor = await vscode.window.showTextDocument(doc); + editor.selection = new vscode.Selection(0, 0, 1, 6); + await ss.settle(); + + logCapture.mark('before-ghc-002-send'); + + await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); + await ss.settle(); + + const sendLines = logCapture.getLinesSince('before-ghc-002-send'); + + const pasteSuccessLog = sendLines.find( + (line) => + line.includes('ComposablePasteDestination.pasteLink') && line.includes('Pasted link'), + ); + assert.ok( + pasteSuccessLog, + 'Expected paste link success log after send (code-side paste fired)', + ); + + const clipboardPasteLog = sendLines.find( + (line) => + line.includes('VscodeAdapter.pasteClipboardToAiAssistant') && + line.includes('Clipboard paste succeeded'), + ); + assert.ok(clipboardPasteLog, 'Expected Clipboard paste succeeded log'); + + const verdict = await waitForHumanVerdict( + 'github-copilot-chat-002', + 'Did the RangeLink + selected code appear in GitHub Copilot Chat?', + [ + '1. The RangeLink send was fired automatically', + '2. Click PASS if the link + selected code appeared in GitHub Copilot Chat input', + '3. Click FAIL otherwise', + ], + ); + assert.strictEqual( + verdict, + 'pass', + 'Human reported the RangeLink did not appear in GitHub Copilot Chat (code-side paste logs fired — the paste dispatched but did not reach the chat input)', + ); + + ss.log('✓ Bind status confirmed + content delivered to GitHub Copilot Chat'); + }); + + test('[assisted] github-copilot-chat-003: cold panel paste — content arrives in GitHub Copilot Chat after first R-L since bind', async () => { + const fileUri = ss.createWorkspaceFile('ghc-003', 'line 1\nline 2\nline 3\n'); + const doc = await vscode.workspace.openTextDocument(fileUri); + const editor = await vscode.window.showTextDocument(doc); + editor.selection = new vscode.Selection(0, 0, 1, 6); + await ss.settle(); + + await vscode.commands.executeCommand(CMD_BIND_TO_GITHUB_COPILOT_CHAT); + await ss.settle(); + + const logCapture = getLogCapture(); + logCapture.mark('before-ghc-003'); + + await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); + await ss.settle(); + + const lines = logCapture.getLinesSince('before-ghc-003'); + + const pasteSuccessLog = lines.find( + (line) => + line.includes('ComposablePasteDestination.pasteLink') && line.includes('Pasted link'), + ); + assert.ok(pasteSuccessLog, 'Expected paste link success log after cold paste send'); + + const clipboardPasteLog = lines.find( + (line) => + line.includes('VscodeAdapter.pasteClipboardToAiAssistant') && + line.includes('Clipboard paste succeeded'), + ); + assert.ok(clipboardPasteLog, 'Expected Clipboard paste succeeded log'); + + const verdict = await waitForHumanVerdict( + 'github-copilot-chat-003', + 'Cold paste: did the RangeLink appear in GitHub Copilot Chat?', + [ + '1. Lines 1-2 of ghc-003 are already selected', + '2. The send was fired automatically (no keypress needed)', + '3. Click PASS if the RangeLink appeared in GitHub Copilot Chat input', + '4. Click FAIL otherwise', + ], + ); + assert.strictEqual( + verdict, + 'pass', + 'Human reported the RangeLink did not appear in GitHub Copilot Chat (code-side paste logs fired — the paste dispatched but did not reach the chat input)', + ); + + ss.log('✓ Cold paste: content delivered to GitHub Copilot Chat (verdict PASS)'); + }); + + test('[assisted] github-copilot-chat-004: warm panel paste — second R-L delivers content without cold-start refocus', async () => { + const fileUri = ss.createWorkspaceFile('ghc-004', 'line 1\nline 2\nline 3\nline 4\n'); + const doc = await vscode.workspace.openTextDocument(fileUri); + const editor = await vscode.window.showTextDocument(doc); + editor.selection = new vscode.Selection(0, 0, 1, 6); + await ss.settle(); + + await vscode.commands.executeCommand(CMD_BIND_TO_GITHUB_COPILOT_CHAT); + await ss.settle(); + + // First send (cold) — warms the panel + await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); + await ss.settle(); + + const coldVerdict = await waitForHumanVerdict( + 'github-copilot-chat-004-cold', + 'Cold send: did the RangeLink appear in GitHub Copilot Chat?', + [ + '1. Lines 1-2 of ghc-004 are selected; cold send was fired automatically', + '2. Click PASS if the RangeLink appeared in GitHub Copilot Chat input', + '3. Click FAIL otherwise', + ], + ); + assert.strictEqual( + coldVerdict, + 'pass', + 'Human reported the cold-send RangeLink did not appear in GitHub Copilot Chat', + ); + + // Select lines 3-4 for warm send + await vscode.window.showTextDocument(doc); + editor.selection = new vscode.Selection(2, 0, 3, 6); + await ss.settle(); + + const logCapture = getLogCapture(); + logCapture.mark('before-ghc-004-warm'); + + await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); + await ss.settle(); + + const lines = logCapture.getLinesSince('before-ghc-004-warm'); + + const pasteSuccessLog = lines.find( + (line) => + line.includes('ComposablePasteDestination.pasteLink') && line.includes('Pasted link'), + ); + assert.ok(pasteSuccessLog, 'Expected paste link success log on warm send'); + + const clipboardPasteLog = lines.find( + (line) => + line.includes('VscodeAdapter.pasteClipboardToAiAssistant') && + line.includes('Clipboard paste succeeded'), + ); + assert.ok(clipboardPasteLog, 'Expected Clipboard paste succeeded log on warm send'); + + const warmVerdict = await waitForHumanVerdict( + 'github-copilot-chat-004-warm', + 'Warm send: did the lines 3-4 RangeLink appear in GitHub Copilot Chat without refocus flicker?', + [ + '1. Lines 3-4 of ghc-004 are selected; warm send was fired automatically', + '2. Click PASS if the new RangeLink appeared AND the panel did not flicker/refocus', + '3. Click FAIL otherwise (no content, or visible flicker indicating cold-start path)', + ], + ); + assert.strictEqual( + warmVerdict, + 'pass', + 'Human reported the warm-send RangeLink did not appear cleanly in GitHub Copilot Chat', + ); + + ss.log('✓ Warm paste: content delivered to GitHub Copilot Chat (cold + warm both PASS)'); + }); + test('clipboard-preservation-011: cold paste to Claude Code — prior clipboard restored', async () => { const fileUri = ss.createWorkspaceFile('cp-011', 'line 1\nline 2\nline 3\n'); const editor = await ss.openEditor(fileUri); @@ -630,6 +815,67 @@ standardSuite('Built-in AI Assistants', (ss) => { ); ss.log('✓ clipboard-preservation-017: failed paste — RangeLink stays on clipboard'); }); + + test('[assisted] clipboard-preservation-018: two rapid R-L to Claude Code — last write wins, prior content restored once', async () => { + const fileUri = ss.createWorkspaceFile('cp-018', 'line 1\nline 2\nline 3\nline 4\n'); + const doc = await vscode.workspace.openTextDocument(fileUri); + const editor = await vscode.window.showTextDocument(doc); + + await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); + await ss.settle(); + + // Snapshot clipboard before rapid sends + await writeClipboardSentinel(); + const logCapture = getLogCapture(); + + // First send: lines 1-2 + editor.selection = new vscode.Selection(0, 0, 1, 6); + await ss.settle(); + + logCapture.mark('before-018-rapid'); + await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); + + // Second send: lines 3-4, fired immediately without waiting for first to complete + editor.selection = new vscode.Selection(2, 0, 3, 6); + await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); + await ss.settle(); + + const lines = logCapture.getLinesSince('before-018-rapid'); + + // Both sends should have fired paste commands + const pasteLogs = lines.filter( + (line) => + line.includes('ComposablePasteDestination.pasteLink') && line.includes('Pasted link'), + ); + assert.ok(pasteLogs.length >= 2, `Expected at least 2 paste logs, got ${pasteLogs.length}`); + + // Clipboard preservation should have run (exactly once restoration after last op) + assertClipboardPreservationRan(logCapture, 'before-018-rapid', 'R-L'); + + await assertClipboardRestored( + 'clipboard-preservation-018: rapid R-L to Claude Code — prior clipboard restored once', + ); + + const verdict = await waitForHumanVerdict( + 'clipboard-preservation-018', + 'Two rapid R-L: did both RangeLinks appear in Claude Code chat?', + [ + '1. Two rapid sends were fired automatically', + '2. Check Claude Code chat for BOTH RangeLinks (lines 1-2 and lines 3-4)', + '3. Click PASS if both links were delivered', + '4. Click FAIL otherwise (missing link or wrong content)', + ], + ); + assert.strictEqual( + verdict, + 'pass', + 'Human reported rapid R-L did not deliver both links or clipboard was corrupted', + ); + + ss.log( + '✓ clipboard-preservation-018: rapid R-L — both links delivered, clipboard restored once', + ); + }); }); standardSuite('Built-in AI Assistants — Destination Picker', (ss) => { diff --git a/tests/shell/resolve-qa-labels.bats b/tests/shell/resolve-qa-labels.bats new file mode 100644 index 000000000..6aa825b93 --- /dev/null +++ b/tests/shell/resolve-qa-labels.bats @@ -0,0 +1,778 @@ +#!/usr/bin/env bats + +load test_helper + +REAL_SCRIPT="$PROJECT_ROOT/packages/rangelink-vscode-extension/scripts/resolve-qa-labels.js" + +setup_fixture() { + FIXTURE_ROOT="$TEST_TEMP_DIR" + mkdir -p "$FIXTURE_ROOT/scripts" + mkdir -p "$FIXTURE_ROOT/qa" + cp "$REAL_SCRIPT" "$FIXTURE_ROOT/scripts/resolve-qa-labels.js" + SCRIPT="$FIXTURE_ROOT/scripts/resolve-qa-labels.js" +} + +write_yaml() { + cat > "$FIXTURE_ROOT/qa/$1" +} + +# ════════════════════════════════════════════════════════════════════ +# Argument parsing +# ════════════════════════════════════════════════════════════════════ + +@test "resolve-qa-labels: --help exits 0" { + run node "$REAL_SCRIPT" --help + [[ "$status" -eq 0 ]] + [[ "$output" =~ "Usage:" ]] +} + +@test "resolve-qa-labels: unknown option exits 1" { + run node "$REAL_SCRIPT" --bogus-flag + [[ "$status" -eq 1 ]] + [[ "$output" =~ "Unknown option:" ]] +} + +@test "resolve-qa-labels: --label without value (no next arg) exits 1" { + run node "$REAL_SCRIPT" --label + [[ "$status" -eq 1 ]] + [[ "$output" =~ "--label requires a value" ]] +} + +@test "resolve-qa-labels: --label without value (next arg is flag) exits 1" { + run node "$REAL_SCRIPT" --label --assisted + [[ "$status" -eq 1 ]] + [[ "$output" =~ "--label requires a value" ]] +} + +@test "resolve-qa-labels: --label with value accepted" { + # Use a nonexistent file — /dev/null is a valid read (empty), which parses + # successfully and exits 0. A nonexistent file proves arg parsing passed + # by reaching readFileSync and failing there. + local yml="$TEST_TEMP_DIR/nonexistent.yaml" + run node "$REAL_SCRIPT" --label cursor --yaml "$yml" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "Cannot read YAML" ]] +} + +@test "resolve-qa-labels: --exclude-label without value exits 1" { + run node "$REAL_SCRIPT" --exclude-label + [[ "$status" -eq 1 ]] + [[ "$output" =~ "--exclude-label requires a value" ]] +} + +@test "resolve-qa-labels: --format without value exits 1" { + run node "$REAL_SCRIPT" --format + [[ "$status" -eq 1 ]] + [[ "$output" =~ "--format requires a value" ]] +} + +@test "resolve-qa-labels: --format with invalid value exits 1" { + run node "$REAL_SCRIPT" --format tsv + [[ "$status" -eq 1 ]] + [[ "$output" =~ "--format must be" ]] +} + +@test "resolve-qa-labels: --yaml without value exits 1" { + run node "$REAL_SCRIPT" --yaml + [[ "$status" -eq 1 ]] + [[ "$output" =~ "--yaml requires a value" ]] +} + +@test "resolve-qa-labels: --assisted and --exclude-assisted mutually exclusive" { + run node "$REAL_SCRIPT" --assisted --exclude-assisted + [[ "$status" -eq 1 ]] + [[ "$output" =~ "mutually exclusive" ]] +} + +@test "resolve-qa-labels: --no-assisted synonym for --exclude-assisted works" { + yml="$TEST_TEMP_DIR/no-assisted-syn.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: assisted-001 + feature: Test + scenario: Assisted TC + automated: assisted + - id: auto-001 + feature: Test + scenario: Automated TC + automated: true +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --no-assisted + [[ "$status" -eq 0 ]] + [[ "$output" == "auto-001" ]] # only automated: true +} + +# ════════════════════════════════════════════════════════════════════ +# YAML discovery +# ════════════════════════════════════════════════════════════════════ + +@test "resolve-qa-labels: --yaml with nonexistent file exits 1" { + run node "$REAL_SCRIPT" --yarn /nonexistent/path.yaml --json + # Wrong: --yarn is unknown option; use --yaml + run node "$REAL_SCRIPT" --yaml /nonexistent/path.yaml + [[ "$status" -eq 1 ]] + [[ "$output" =~ "Cannot read YAML" ]] +} + +@test "resolve-qa-labels: explicit --yaml skips auto-discovery" { + yml="$TEST_TEMP_DIR/custom.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: custom-001 + feature: Custom + scenario: Custom path + automated: true +EOF + run node "$REAL_SCRIPT" --yaml "$yml" + [[ "$status" -eq 0 ]] + [[ "$output" == "custom-001" ]] +} + +@test "resolve-qa-labels: readdirSync fails exits 1" { + FIXTURE_ROOT="$TEST_TEMP_DIR" + mkdir -p "$FIXTURE_ROOT/scripts" + # Intentionally NOT creating qa/ directory + cp "$REAL_SCRIPT" "$FIXTURE_ROOT/scripts/resolve-qa-labels.js" + SCRIPT="$FIXTURE_ROOT/scripts/resolve-qa-labels.js" + + run node "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "Cannot read QA directory" ]] +} + +@test "resolve-qa-labels: no QA YAML files exits 1" { + setup_fixture + # qa/ directory exists but is empty (no yaml files) or has non-matching files + # Just make sure qa/ exists with no yaml files + run node "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" =~ "No QA YAML files found" ]] +} + +@test "resolve-qa-labels: unsuffixed preferred over suffixed same version" { + setup_fixture + write_yaml "qa-test-cases-v1.0.0-001.yaml" <<'EOF' +test_cases: + - id: old-001 + feature: Old + scenario: Suffixed + automated: true +EOF + write_yaml "qa-test-cases-v1.0.0.yaml" <<'EOF' +test_cases: + - id: latest-001 + feature: Latest + scenario: Unsuffixed + automated: true +EOF + run node "$SCRIPT" + [[ "$output" == "latest-001" ]] +} + +@test "resolve-qa-labels: highest version picked among unsuffixed" { + setup_fixture + write_yaml "qa-test-cases-v1.0.0.yaml" <<'EOF' +test_cases: + - id: old-001 + feature: Old + scenario: v1.0.0 + automated: true +EOF + write_yaml "qa-test-cases-v2.0.0.yaml" <<'EOF' +test_cases: + - id: new-001 + feature: New + scenario: v2.0.0 + automated: true +EOF + run node "$SCRIPT" + [[ "$output" == "new-001" ]] +} + +@test "resolve-qa-labels: highest suffixed picked when only suffixed exist" { + setup_fixture + write_yaml "qa-test-cases-v1.0.0-001.yaml" <<'EOF' +test_cases: + - id: first-001 + feature: First + scenario: 001 + automated: true +EOF + write_yaml "qa-test-cases-v1.0.0-005.yaml" <<'EOF' +test_cases: + - id: fifth-001 + feature: Fifth + scenario: 005 + automated: true +EOF + run node "$SCRIPT" + [[ "$output" == "fifth-001" ]] +} + +# ════════════════════════════════════════════════════════════════════ +# YAML parsing +# ════════════════════════════════════════════════════════════════════ + +@test "resolve-qa-labels: parses all field types with quoting variants" { + yml="$TEST_TEMP_DIR/parse-all.yaml" + cat > "$yml" <<'EOF' +# Comment before first TC should be skipped +test_cases: + - id: first-001 + feature: 'Single Quoted' + scenario: "Double Quoted" + automated: "true" + non_automatable_reason: 'Has reason' + + - id: second-002 + feature: Unquoted + scenario: Unquoted + automated: assisted + + - id: third-003 + feature: "Double Feature" + scenario: 'Double Scenario' + automated: false + labels: + - cursor + - requires-extensions +EOF + + run node "$REAL_SCRIPT" --yaml "$yml" --json + [[ "$status" -eq 0 ]] + + # Verify the JSON parses and contains expected values + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); + const groups = d.groups; + // Should have 2 groups: first and second (third is a prefix, along with first/second) + // first-001 → prefix 'first', second-002 → prefix 'second', third-003 → prefix 'third' + // All have nonAutomated > 0 (first-001 is true, wait no - automated: 'true' means automated: true) + // first-001: automated: \"true\" → parsed as 'true' → automated === 'true' → NOT automated in JSON terms... + // Actually in JSON output, first-001 has automated: 'true' so it stays in group, contributes to featureCounts, but NOT to assisted/manual. So nonAutomated = 0 for 'first' → SKIPPED. + // second-002: automated: 'assisted' → nonAutomated += 1 + // third-003: automated: 'false' → cursor TC label → goes to cursorTcs → skipped from group + // So only 'second' group should appear + if (groups.length !== 1) process.exit(1); + if (groups[0].prefix !== 'second') process.exit(2); + if (groups[0].assisted !== 1) process.exit(3); + if (groups[0].manual !== 0) process.exit(4); + // cursor_tcs should have third-003 + if (d.cursor_tcs.length !== 1) process.exit(5); + if (d.cursor_tcs[0].id !== 'third-003') process.exit(6); + if (d.cursor_tcs[0].automated !== false) process.exit(7); // boolean false + // requires-extensions from third-003 but group is skipped, so no requires_extensions in output +" <<< "$output" +} + +@test "resolve-qa-labels: parses single-quoted automated and labels" { + yml="$TEST_TEMP_DIR/single-quoted.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: quoted-001 + feature: Test + scenario: Test + automated: 'assisted' + labels: + - ubuntu +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + [[ "$status" -eq 0 ]] + echo "$output" | grep -q '"ubuntu_tcs"' +} + +@test "resolve-qa-labels: lines before first TC are skipped" { + yml="$TEST_TEMP_DIR/skip-lines.yaml" + cat > "$yml" <<'EOF' +# This is a comment line +# Another comment +test_cases: + - id: first-001 + feature: First + scenario: After comments + automated: true +EOF + run node "$REAL_SCRIPT" --yaml "$yml" + [[ "$status" -eq 0 ]] + [[ "$output" == "first-001" ]] +} + +@test "resolve-qa-labels: label mode exits on unrecognized key" { + # This tests branch: inLabels && non-label key causes exit + yml="$TEST_TEMP_DIR/label-exit.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: label-exit-001 + feature: Test + scenario: Test + automated: false + labels: + - cursor + unknown_extra_field: value +EOF + run node "$REAL_SCRIPT" --yaml "$yml" + [[ "$status" -eq 0 ]] + # If parsing succeeded, we get output. If it broke, we'd get an error. + [[ "$output" == "label-exit-001" ]] +} + +@test "resolve-qa-labels: lines within TC that dont match any field are silently skipped" { + yml="$TEST_TEMP_DIR/fallthrough.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: fall-001 + feature: Fall + scenario: Through + automated: true + garbage: should be silently skipped +EOF + run node "$REAL_SCRIPT" --yaml "$yml" + [[ "$status" -eq 0 ]] + [[ "$output" == "fall-001" ]] +} + +# ════════════════════════════════════════════════════════════════════ +# JSON output — group structure +# ════════════════════════════════════════════════════════════════════ + +@test "resolve-qa-labels: JSON groups by prefix, most common feature" { + yml="$TEST_TEMP_DIR/json-feature.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: multi-001 + feature: 'Feature A' + scenario: A1 + automated: false + non_automatable_reason: 'Reason 1' + + - id: multi-002 + feature: 'Feature A' + scenario: A2 + automated: false + non_automatable_reason: 'Reason 2' + + - id: multi-003 + feature: 'Feature B' + scenario: B1 + automated: false + non_automatable_reason: 'Reason 3' +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + [[ "$status" -eq 0 ]] + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); +if (d.groups.length !== 1) process.exit(1); +const g = d.groups[0]; +if (g.prefix !== 'multi') process.exit(2); +if (g.feature !== 'Feature A') process.exit(3); +if (g.assisted !== 0) process.exit(4); +if (g.manual !== 3) process.exit(5); +if (g.total !== 3) process.exit(6); +if (g.requires_extensions !== false) process.exit(7); +if (!g.reasons) process.exit(8); +if (g.reasons['Reason 1'] !== 1) process.exit(9); +if (g.reasons['Reason 2'] !== 1) process.exit(10); +if (g.reasons['Reason 3'] !== 1) process.exit(11); +" <<< "$output" +} + +@test "resolve-qa-labels: JSON skips groups with nonAutomated === 0" { + yml="$TEST_TEMP_DIR/json-skip-zero.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: auto-001 + feature: All Automated + scenario: Auto 1 + automated: true + + - id: auto-002 + feature: All Automated + scenario: Auto 2 + automated: true +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); + if (d.groups.length !== 0) process.exit(1); + if (d.total_assisted !== 0) process.exit(2); + if (d.total_manual !== 0) process.exit(3); +" <<< "$output" +} + +@test "resolve-qa-labels: JSON group without reasons omits reasons field" { + yml="$TEST_TEMP_DIR/json-no-reasons.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: assist-001 + feature: Only Assisted + scenario: Assisted 1 + automated: assisted +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); + if (d.groups.length !== 1) process.exit(1); + const g = d.groups[0]; + if (g.assisted !== 1) process.exit(2); + if (g.manual !== 0) process.exit(3); + if (g.total !== 1) process.exit(4); + // reasons field should NOT exist + if (g.reasons !== undefined) process.exit(5); +" <<< "$output" +} + +@test "resolve-qa-labels: JSON group with unspecified reason uses 'unspecified'" { + yml="$TEST_TEMP_DIR/json-unspecified.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: no-reason-001 + feature: No Reason + scenario: Missing reason + automated: false +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + echo "$output" | grep -q '"unspecified"' +} + +@test "resolve-qa-labels: JSON id without dash-digit pattern uses raw id as prefix" { + yml="$TEST_TEMP_DIR/json-bare-id.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: bare + feature: Bare ID + scenario: No dash digits + automated: false + non_automatable_reason: 'Testing' +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); + if (d.groups.length !== 1) process.exit(1); + if (d.groups[0].prefix !== 'bare') process.exit(2); + if (d.groups[0].total !== 1) process.exit(3); +" <<< "$output" +} + +@test "resolve-qa-labels: JSON requires-extensions label detected in group" { + yml="$TEST_TEMP_DIR/json-req-ext.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: ext-001 + feature: Ext Feature + scenario: Needs extensions + automated: false + non_automatable_reason: 'Reason' + labels: + - requires-extensions +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + echo "$output" | grep -q '"requires_extensions":true' +} + +@test "resolve-qa-labels: JSON automated:true with cursor label NOT extracted to cursorTcs" { + # cursor extraction only happens when automated !== 'true' + yml="$TEST_TEMP_DIR/json-cursor-auto.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: cursor-auto-001 + feature: Cursor Auto + scenario: Auto with cursor label + automated: true + labels: + - cursor +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); + if (d.cursor_tcs.length !== 0) process.exit(1); // NOT extracted + if (d.groups.length !== 0) process.exit(2); // nonAutomated = 0 → skipped +" <<< "$output" +} + +# ════════════════════════════════════════════════════════════════════ +# JSON output — cursor / ubuntu extraction +# ════════════════════════════════════════════════════════════════════ + +@test "resolve-qa-labels: JSON cursor and ubuntu TCs extracted with totals" { + yml="$TEST_TEMP_DIR/json-cursor-ubuntu.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: group-001 + feature: Group Feature + scenario: Normal manual + automated: false + non_automatable_reason: 'Normal reason' + + - id: group-002 + feature: Group Feature + scenario: Normal assisted + automated: assisted + + - id: cursor-001 + feature: Cursor Feature + scenario: Cursor manual + automated: false + non_automatable_reason: 'Needs cursor' + labels: + - cursor + + - id: ubuntu-001 + feature: Ubuntu Feature + scenario: Ubuntu assisted + automated: assisted + labels: + - ubuntu +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); + // group (prefix 'group') should have 2 non-automated TCs (manual + assisted) + // cursor-001 goes to cursor_tcs, ubuntu-001 goes to ubuntu_tcs + if (d.groups.length !== 1) process.exit(1); + const g = d.groups[0]; + if (g.prefix !== 'group') process.exit(2); + if (g.assisted !== 1) process.exit(3); + if (g.manual !== 1) process.exit(4); + if (g.total !== 2) process.exit(5); + + if (d.cursor_tcs.length !== 1) process.exit(6); + if (d.cursor_tcs[0].id !== 'cursor-001') process.exit(7); + if (d.cursor_tcs[0].automated !== false) process.exit(8); + + if (d.ubuntu_tcs.length !== 1) process.exit(9); + if (d.ubuntu_tcs[0].id !== 'ubuntu-001') process.exit(10); + if (d.ubuntu_tcs[0].automated !== 'assisted') process.exit(11); + + // Totals include group + cursor + ubuntu + // group: assisted=1, manual=1 + // cursor: cursor-001 (automated: false → boolean false) → manual +1 + // ubuntu: ubuntu-001 (automated: \"assisted\") → assisted +1 + if (d.total_assisted !== 2) process.exit(12); + if (d.total_manual !== 2) process.exit(13); +" <<< "$output" +} + +@test "resolve-qa-labels: JSON cursor TC with automated:assisted counts as assisted" { + yml="$TEST_TEMP_DIR/json-cursor-assisted.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: curs-assist-001 + feature: Cursor Assisted + scenario: Cursor assisted + automated: assisted + labels: + - cursor +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --json + node -e " +const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8')); + if (d.cursor_tcs.length !== 1) process.exit(1); + if (d.cursor_tcs[0].automated !== 'assisted') process.exit(2); + if (d.total_assisted !== 1) process.exit(3); // cursor assisted → assisted + if (d.total_manual !== 0) process.exit(4); +" <<< "$output" +} + +# ════════════════════════════════════════════════════════════════════ +# Filtering +# ════════════════════════════════════════════════════════════════════ + +setup_filter_yaml() { + FILTER_YAML="$TEST_TEMP_DIR/filter-fixture.yaml" + cat > "$FILTER_YAML" <<'EOF' +test_cases: + - id: alpha-001 + feature: Alpha + scenario: Auto no labels + automated: true + + - id: alpha-002 + feature: Alpha + scenario: Assisted ubuntu + automated: assisted + labels: + - ubuntu + + - id: alpha-003 + feature: Alpha + scenario: Manual cursor + automated: false + non_automatable_reason: 'Reason 1' + labels: + - cursor + + - id: beta-001 + feature: Beta + scenario: Auto cursor + automated: true + labels: + - cursor + + - id: beta-002 + feature: Beta + scenario: Manual no labels + automated: false + non_automatable_reason: 'Reason 2' +EOF +} + +@test "resolve-qa-labels: filter --label matches single label" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --label ubuntu + [[ "$status" -eq 0 ]] + [[ "$output" == "alpha-002" ]] +} + +@test "resolve-qa-labels: filter --label union across multiple TCs" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --label cursor + [[ "$status" -eq 0 ]] + # alpha-003 and beta-001 both have cursor label + # Output should be two lines (default lines format): + echo "$output" | grep -q "alpha-003" + echo "$output" | grep -q "beta-001" + # Count lines + lines=$(echo "$output" | wc -l | tr -d ' ') + [[ "$lines" -eq 2 ]] +} + +@test "resolve-qa-labels: filter multiple --label flags as union" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --label cursor --label ubuntu + [[ "$status" -eq 0 ]] + echo "$output" | grep -q "alpha-002" + echo "$output" | grep -q "alpha-003" + echo "$output" | grep -q "beta-001" + lines=$(echo "$output" | wc -l | tr -d ' ') + [[ "$lines" -eq 3 ]] +} + +@test "resolve-qa-labels: filter --exclude-label" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --exclude-label cursor + [[ "$status" -eq 0 ]] + # Should exclude alpha-003 and beta-001 + echo "$output" | grep -q "alpha-001" + echo "$output" | grep -q "alpha-002" + echo "$output" | grep -q "beta-002" + lines=$(echo "$output" | wc -l | tr -d ' ') + [[ "$lines" -eq 3 ]] +} + +@test "resolve-qa-labels: filter --automated-only" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --automated-only + [[ "$status" -eq 0 ]] + echo "$output" | grep -q "alpha-001" + echo "$output" | grep -q "beta-001" + lines=$(echo "$output" | wc -l | tr -d ' ') + [[ "$lines" -eq 2 ]] +} + +@test "resolve-qa-labels: filter --assisted" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --assisted + [[ "$status" -eq 0 ]] + [[ "$output" == "alpha-002" ]] +} + +@test "resolve-qa-labels: filter --exclude-assisted" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --exclude-assisted + [[ "$status" -eq 0 ]] + echo "$output" | grep -q "alpha-001" + echo "$output" | grep -q "alpha-003" + echo "$output" | grep -q "beta-001" + echo "$output" | grep -q "beta-002" + lines=$(echo "$output" | wc -l | tr -d ' ') + [[ "$lines" -eq 4 ]] +} + +@test "resolve-qa-labels: filter --label with --automated-only combined" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --label cursor --automated-only + [[ "$status" -eq 0 ]] + # alpha-003 has cursor but is automated: false → excluded by --automated-only + # beta-001 has cursor and is automated: true → included + [[ "$output" == "beta-001" ]] +} + +@test "resolve-qa-labels: filter empty results" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --label nonexistent + [[ "$status" -eq 0 ]] + [[ -z "$output" ]] +} + +# ════════════════════════════════════════════════════════════════════ +# Output format +# ════════════════════════════════════════════════════════════════════ + +@test "resolve-qa-labels: format csv outputs comma-separated" { + yml="$TEST_TEMP_DIR/format-csv.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: first-001 + feature: Test + scenario: First + automated: true + - id: second-001 + feature: Test + scenario: Second + automated: true +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --format csv + [[ "$status" -eq 0 ]] + [[ "$output" == "first-001, second-001" ]] +} + +@test "resolve-qa-labels: format lines outputs one per line (default)" { + yml="$TEST_TEMP_DIR/format-lines.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: first-001 + feature: Test + scenario: First + automated: true + - id: second-001 + feature: Test + scenario: Second + automated: true +EOF + run node "$REAL_SCRIPT" --yaml "$yml" + [[ "$status" -eq 0 ]] + lines=$(echo "$output" | wc -l | tr -d ' ') + [[ "$lines" -eq 2 ]] + first=$(echo "$output" | sed -n '1p') + second=$(echo "$output" | sed -n '2p') + [[ "$first" == "first-001" ]] + [[ "$second" == "second-001" ]] +} + +@test "resolve-qa-labels: format lines explicit" { + yml="$TEST_TEMP_DIR/format-lines-explicit.yaml" + cat > "$yml" <<'EOF' +test_cases: + - id: only-001 + feature: Test + scenario: Only + automated: true +EOF + run node "$REAL_SCRIPT" --yaml "$yml" --format lines + [[ "$status" -eq 0 ]] + [[ "$output" == "only-001" ]] +} + +@test "resolve-qa-labels: filter with format csv" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --automated-only --format csv + [[ "$status" -eq 0 ]] + [[ "$output" == "alpha-001, beta-001" ]] +} + +@test "resolve-qa-labels: empty results with format csv" { + setup_filter_yaml + run node "$REAL_SCRIPT" --yaml "$FILTER_YAML" --label nonexistent --format csv + [[ "$status" -eq 0 ]] + [[ -z "$output" ]] +} From 13fe9488f1b66902936cd24c965542b74dbcabfe Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Mon, 25 May 2026 22:54:06 -0400 Subject: [PATCH 2/2] [fix] Address CodeRabbit PR feedback: loopback binding and install failure detection Bind noVNC port to 127.0.0.1 so the auth-less VNC desktop is not exposed to the local network. Surface extension install failures in the entrypoint instead of swallowing them with || true. Ref: https://github.com/couimet/rangeLink/pull/602#pullrequestreview-4358894893 --- packages/rangelink-vscode-extension/docker/entrypoint.sh | 5 ++++- .../rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/rangelink-vscode-extension/docker/entrypoint.sh b/packages/rangelink-vscode-extension/docker/entrypoint.sh index ab3d57c86..d2b3d3231 100755 --- a/packages/rangelink-vscode-extension/docker/entrypoint.sh +++ b/packages/rangelink-vscode-extension/docker/entrypoint.sh @@ -67,7 +67,10 @@ tigervncserver :1 -geometry 1680x1050 -depth 24 -localhost yes -SecurityTypes No sleep 2 if [[ -n "$VSIX" ]]; then echo "==> Installing extension: $VSIX" - DISPLAY=:1 code --install-extension "$VSIX" 2>&1 || true + if ! DISPLAY=:1 code --install-extension "$VSIX" 2>&1; then + echo "ERROR: Failed to install extension: $VSIX" >&2 + exit 1 + fi fi # Launch VS Code with fixture workspace diff --git a/packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh b/packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh index 1876cdc25..622e74b41 100755 --- a/packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh +++ b/packages/rangelink-vscode-extension/scripts/qa-ubuntu-docker.sh @@ -72,7 +72,7 @@ run() { echo "" docker run \ --name "${CONTAINER}" \ - -p 6080:6080 \ + -p 127.0.0.1:6080:6080 \ --shm-size=2g \ --rm \ -v "${REPO_ROOT}:/workspace" \