diff --git a/.DS_Store b/.DS_Store index 7ef2bdf..9705c91 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml deleted file mode 100644 index 0ef6239..0000000 --- a/.github/workflows/Semgrep.yml +++ /dev/null @@ -1,50 +0,0 @@ -# Name of this GitHub Actions workflow. -name: Semgrep - -on: - # Scan changed files in PRs (diff-aware scanning): - # The branches below must be a subset of the branches above - pull_request: - branches: ["master", "main"] - push: - branches: ["master", "main"] - schedule: - - cron: '0 6 * * *' - - -permissions: - contents: read - id-token: none # explicit deny — workflow does not request OIDC federation - -jobs: - semgrep: - # User definable name of this GitHub Actions job. - permissions: - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/upload-sarif to upload SARIF results - id-token: none # explicit deny — Semgrep job does not need OIDC federation - name: semgrep/ci - # If you are self-hosting, change the following `runs-on` value: - runs-on: ubuntu-latest - - container: - # A Docker image with Semgrep installed. Do not change this. - image: semgrep/semgrep:1.166.0 - - # Skip any PR created by dependabot to avoid permission issues: - if: (github.actor != 'dependabot[bot]') - - steps: - # Fetch project source with GitHub Actions Checkout. - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - # Run the "semgrep ci" command on the command line of the docker image. - - run: semgrep ci --sarif --output=semgrep.sarif - env: - # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. - SEMGREP_RULES: p/default # more at semgrep.dev/explore - - - name: Upload SARIF file for GitHub Advanced Security Dashboard - uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 - with: - sarif_file: semgrep.sarif - if: always() \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb15b92..5d98726 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,13 +18,13 @@ jobs: # Checkout # ------------------------------------------------------ - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # ------------------------------------------------------ # Setup Go # ------------------------------------------------------ - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: "1.24" @@ -32,7 +32,7 @@ jobs: # Setup Node # ------------------------------------------------------ - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: "22" @@ -127,9 +127,10 @@ jobs: - name: Create Unified Release env: GH_TOKEN: ${{ github.token }} + REF_NAME: ${{ github.ref_name }} run: | gh release create \ - "${{ github.ref_name }}" \ + "$REF_NAME" \ percy-linux.zip percy-mac.zip percy-windows.zip \ - --title "Percy Local Manager ${{ github.ref_name }}" \ + --title "Percy Local Manager $REF_NAME" \ --notes "Percy Local Manager for Linux, macOS, and Windows." \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 45df482..e033d71 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,9 @@ name: Percy Local Manager — Tests & Release on: push: - branches: [main] - pull_request: - branches: [main] + pull_request: + branches: + - main jobs: # ============================================================ @@ -18,12 +18,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.21' + go-version: '1.23' - name: Build Go backend working-directory: go-backend @@ -44,7 +44,7 @@ jobs: done - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' cache: 'npm' @@ -99,7 +99,7 @@ jobs: - name: Upload Playwright report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: playwright-report path: tests/playwright-report/ @@ -112,26 +112,150 @@ jobs: kill $VITE_PID || true # ============================================================ - # Job 2: BrowserStack Automate — API + E2E on Mac & Windows - # Runs after local tests pass. Skipped if secrets not set. + # Job 2: BrowserStack Automate — OS X only (tests/browserstack.macos.yml) + # Job 3: BrowserStack Automate — Windows only (tests/browserstack.windows.yml) + # + # The Go backend is shared state: ONE snapshot queue and ONE build slot (a + # second finalize returns 409), and the BrowserStack SDK runs every platform + # of one invocation at the same time. So each platform gets its own job: own + # runner, own backend + Vite server, own single-platform config. The two jobs + # run simultaneously, which keeps the total build time down. # ============================================================ - test-browserstack: - name: BrowserStack Automate (Mac + Windows) - needs: test + test-browserstack-macos: + name: BrowserStack Automate (OS X) runs-on: ubuntu-latest permissions: contents: read - # Only run on push to main (not on PRs) — requires BrowserStack secrets to be set - if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + # Both platform jobs derive the SAME build number from the workflow run + # (run_number + run_attempt), so OS X and Windows report into ONE build. + - name: Generate build number and write it to browserstack.macos.yml + working-directory: tests + env: + RUN_NUMBER: ${{ github.run_number }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + BUILD_NUMBER="${RUN_NUMBER}.${RUN_ATTEMPT}" + echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV + sed -i "s|^buildIdentifier:.*|buildIdentifier: '#${BUILD_NUMBER}'|" browserstack.macos.yml + grep -n '^buildName\|^buildIdentifier' browserstack.macos.yml + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - name: Build Go backend + working-directory: go-backend + run: go build -o server ./cmd/server + + - name: Start Go backend + working-directory: go-backend + run: | + ./server & + echo "SERVER_PID=$!" >> $GITHUB_ENV + for i in $(seq 1 20); do + if curl -sf http://localhost:4321/health > /dev/null; then + echo "Backend is up" + break + fi + echo "Waiting for backend... ($i)" + sleep 1 + done + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tests/package-lock.json + + - name: Install test dependencies + working-directory: tests + run: npm ci + + - name: Install extension dependencies and start Vite dev server + working-directory: percy-local-manager-extension + run: | + npm ci --legacy-peer-deps + npm run dev & + echo "VITE_PID=$!" >> $GITHUB_ENV + for i in $(seq 1 20); do + if curl -sf http://localhost:5173 > /dev/null; then + echo "Vite dev server is up" + break + fi + echo "Waiting for Vite... ($i)" + sleep 1 + done + + - name: Run all tests on BrowserStack — OS X (API + E2E + Extension UI) + working-directory: tests + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + BACKEND_URL: http://localhost:4321 + EXTENSION_DEV_URL: http://localhost:5173 + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} + PERCY_READ_TOKEN: ${{ secrets.PERCY_READ_TOKEN }} + run: npx browserstack-node-sdk playwright test --workers=1 --browserstack.config=browserstack.macos.yml + + - name: Fetch BrowserStack build public URL + if: always() + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + run: | + BUILD_URL=$(curl -s -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ + "https://api.browserstack.com/automate/builds.json?limit=1" \ + | python3 -c "import sys,json; b=json.load(sys.stdin)[0]['automation_build']; print(b.get('public_url') or 'https://automate.browserstack.com/builds/' + b['hashed_id'])") + echo "BrowserStack Build URL: $BUILD_URL" + echo "BROWSERSTACK_BUILD_URL=$BUILD_URL" >> $GITHUB_ENV + + - name: Upload BrowserStack Playwright report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: browserstack-playwright-report-macos + path: tests/playwright-report/ + retention-days: 14 + + - name: Stop Go backend and Vite dev server + if: always() + run: | + kill $SERVER_PID || true + kill $VITE_PID || true + + test-browserstack-windows: + name: BrowserStack Automate (Windows) + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + # Same derivation as the OS X job, so both land in the same build. + - name: Generate build number and write it to browserstack.windows.yml + working-directory: tests + env: + RUN_NUMBER: ${{ github.run_number }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + BUILD_NUMBER="${RUN_NUMBER}.${RUN_ATTEMPT}" + echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV + sed -i "s|^buildIdentifier:.*|buildIdentifier: '#${BUILD_NUMBER}'|" browserstack.windows.yml + grep -n '^buildName\|^buildIdentifier' browserstack.windows.yml - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: - go-version: '1.21' + go-version: '1.23' - name: Build Go backend working-directory: go-backend @@ -152,7 +276,7 @@ jobs: done - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' cache: 'npm' @@ -177,7 +301,7 @@ jobs: sleep 1 done - - name: Run all tests on BrowserStack (API + E2E + Extension UI) + - name: Run all tests on BrowserStack — Windows (API + E2E + Extension UI) working-directory: tests env: BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} @@ -186,7 +310,7 @@ jobs: EXTENSION_DEV_URL: http://localhost:5173 PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} PERCY_READ_TOKEN: ${{ secrets.PERCY_READ_TOKEN }} - run: npm run test:bstack + run: npx browserstack-node-sdk playwright test --workers=1 --browserstack.config=browserstack.windows.yml - name: Fetch BrowserStack build public URL if: always() @@ -202,9 +326,9 @@ jobs: - name: Upload BrowserStack Playwright report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: browserstack-playwright-report + name: browserstack-playwright-report-windows path: tests/playwright-report/ retention-days: 14 diff --git a/.gitignore b/.gitignore index 9f32b33..9a48cdc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ tests/node_modules/ tests/test-results/ tests/playwright-report/ tests/log/ + +# Test Companion onboarding (local, per-machine) +.test-companion/ + +# Internal developer context docs (not for commit) +docs/recorder-context.md diff --git a/README.md b/README.md index 6a2e79e..c96aa91 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Percy Local Manager -A lightweight **Visual Testing Companion** that abstracts Percy server management for Manual QA Engineers. Start the server, use the Chrome extension to capture snapshots, and finalize the build — no terminal knowledge required. +A lightweight **Visual Testing Companion** that abstracts Percy server management for Manual QA Engineers. Start the server, use the Chrome extension to capture snapshots or record multi-step workflows, and finalize the build — no terminal knowledge required. ``` -Chrome Extension → HTTP (localhost:4321) → Go Backend → Percy CLI → Percy Cloud +Chrome Extension → HTTP (localhost:4321) → Go Backend → Percy CLI / Playwright → Percy Cloud ``` --- @@ -14,19 +14,30 @@ Percy Local Manager consists of three components that work together: | Component | Technology | Role | |---|---|---| -| **Go Backend** | Go | Central controller — manages Percy CLI, exposes local HTTP API, queues snapshots | -| **Chrome Extension** | React + TypeScript + Vite (MV3) | Captures DOM, URL, and viewport; sends snapshots to the backend | +| **Go Backend** | Go | Central controller — manages Percy CLI, exposes local HTTP API, queues snapshots, stores and replays workflows | +| **Chrome Extension** | React + TypeScript + Vite (MV3) | Captures DOM snapshots; records multi-step workflows; replays them via Playwright | --- ## User Workflow +### Manual Snapshot Capture + 1. Start the Go backend server (distributable or `go run`) 2. Install the Chrome extension 3. Navigate to any page in Chrome and click **Capture** in the extension popup 4. Snapshots are queued in the backend 5. Click **Finalize Build** in the extension to upload all snapshots to Percy +### Workflow Recorder (multi-step) + +1. Start the Go backend server +2. Open the extension popup and click **Record** +3. Interact with the page — clicks, form fills, selects, and scrolls are captured automatically +4. Click **Capture Snapshot** in the floating panel to mark Percy checkpoints +5. Click **Stop**, review the recorded steps, then **Save** the workflow +6. Click **Replay** on any saved workflow — the backend drives Playwright headlessly, takes Percy snapshots at each checkpoint, and finalizes the build + --- ## Repository Structure @@ -36,22 +47,27 @@ Percy_chrome_ext/ ├── go-backend/ # Go HTTP server + Percy process manager │ ├── cmd/server/main.go # Entry point — starts server on :4321 │ └── internal/ -│ ├── api/ # Router + HTTP handlers +│ ├── api/ # Router + HTTP handlers (snapshots, build, workflows, runs) │ ├── app/ # Application wiring │ ├── library/ # Percy snapshot library (cache, client, service) │ ├── middleware/ # CORS, logger, recovery, chain │ ├── percy/ # Percy CLI binary management + controller +│ ├── runs/ # Workflow run lifecycle (service, store, pump, adapters) +│ ├── runtime/ # Playwright installer + embedded runner JS │ ├── service/ # Build and snapshot business logic -│ └── snapshot/ # In-memory snapshot store +│ ├── snapshot/ # In-memory snapshot store +│ └── workflow/ # Workflow model, JSON store, validator │ ├── percy-local-manager-extension/ # Chrome Extension source (build from here) │ ├── src/ +│ │ ├── background/ # Service worker — owns recording state (recorder.ts) +│ │ ├── content/ # Injected page scripts — event capture (recorder.ts) + floating widget │ │ ├── popup/ # Extension popup UI -│ │ ├── components/ # React UI components -│ │ ├── hooks/ # Connection status, queue, capture, finalize hooks -│ │ ├── services/ # DOM capture + backend API client -│ │ ├── types/ # Shared TypeScript types -│ │ └── utils/ # Constants (backend URL, endpoints) +│ │ ├── components/ # React UI components (WorkflowPanel, RecordedSteps, RunProgress…) +│ │ ├── hooks/ # useRecorder, useWorkflows, useRun, finalize, capture hooks +│ │ ├── services/ # DOM capture + backend API client + workflowApi +│ │ ├── types/ # Shared TypeScript types (workflow.ts, build.ts) +│ │ └── utils/ # Constants, recorderCore (pure locator helpers) │ ├── public/manifest.json # Chrome Manifest V3 │ └── package.json │ @@ -189,6 +205,25 @@ Base URL: `http://localhost:4321` | Method | Endpoint | Description | |---|---|---| | `POST` | `/build/finalize` | Upload all queued snapshots to Percy — returns `{ buildId, buildUrl }` | +| `GET` | `/build/finalize/stream` | SSE stream of build progress — events: `progress`, `done`, `error` | + +### Workflows + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/workflows` | Save a recorded workflow — `{ name, startUrl, steps, settings }` | +| `GET` | `/workflows` | List all saved workflows | +| `GET` | `/workflows/:id` | Get a single workflow | +| `DELETE` | `/workflows/:id` | Delete a workflow | + +### Runs (Workflow Replay) + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/runs` | Start a workflow replay — `{ workflowId, token }` — returns `{ runId }` | +| `GET` | `/runs/:id` | Get run status and progress | +| `GET` | `/runs/:id/stream` | SSE stream of run log lines | +| `DELETE` | `/runs/:id` | Cancel a running workflow | ### Library @@ -204,38 +239,70 @@ Base URL: `http://localhost:4321` ## Architecture ``` -+---------------------+ -| Chrome Extension | -+----------+----------+ - | - HTTP localhost:4321 - | -+----------v----------+ -| Go Backend | -|---------------------| -| Percy Controller | ← spawns & monitors percy-cli -| Snapshot Store | ← in-memory queue -| Build Service | ← finalizes Percy build -| Library Service | ← snapshot library cache -| HTTP API | ← serves Chrome extension -+----------+----------+ - | - spawn process - | - percy-cli exec:start - | - Percy Cloud ++-----------------------------------------------+ +| Chrome Extension | +|-----------------------------------------------| +| Popup UI (React) | +| Background Service Worker | ← owns recording state (MV3) +| Content Script (injected per page) | ← captures clicks/fills/scrolls ++------------------------+----------------------+ + | + HTTP localhost:4321 + | ++------------------------v----------------------+ +| Go Backend | +|-----------------------------------------------| +| Percy Controller ← spawns percy-cli | +| Snapshot Store ← in-memory queue | +| Build Service ← finalizes build (SSE) | +| Workflow Store ← persists workflows JSON | +| Runs Service ← drives Playwright replay | +| Runtime ← installs Playwright | +| Library Service ← snapshot library cache | +| HTTP API ← serves Chrome extension | ++----------+--------------------+----------------+ + | | + percy-cli exec:start Playwright (headless) + | | + Percy Cloud Percy snapshots at checkpoints ``` --- +## Recorder — How It Works + +The recorder captures a multi-step user journey in the browser and replays it headlessly to take Percy snapshots at consistent checkpoints. + +### Recording (Extension) + +1. **User clicks Record** in the popup → popup sends `rec:start` to the background service worker. +2. **Background worker** (`src/background/recorder.ts`) queries the active tab, injects `content.js` if needed, measures the viewport, and persists `RecordingState` to `chrome.storage.session` (survives popup close). +3. **Content script** (`src/content/recorder.ts`) attaches `click`, `change`, `input`, and `scroll` listeners. Each event is converted to a typed `WorkflowStep` and forwarded to the background via `rec:event`. +4. **Locator strategy** (`src/utils/recorderCore.ts`): prefers `data-testid` → `role+name` → stable CSS (`id`/`name` attribute) → visible text → structural path. Framework-generated IDs (React, Radix, MUI…) are filtered out. Sensitive fields store a `valueRef` instead of the literal value. +5. **Deduplication**: consecutive fills on the same field collapse to the latest value; consecutive scrolls collapse to the final position. +6. **Snapshot checkpoints**: clicking **Capture Snapshot** in the floating widget sends `rec:snapshot` → background appends a `snapshot` step with a unique `checkpointId`. +7. **Full-page navigations** are captured via `chrome.webNavigation.onCommitted`. +8. **Pause/Resume**: pausing stops capture without discarding steps; resuming adds a `navigate` step if the URL changed while paused. +9. **Save**: background validates (≥1 snapshot, all interactive steps have a locator), calls `POST /workflows`, and clears session state. + +### Replay (Backend) + +1. Extension sends `POST /runs` with `{ workflowId, token }`. +2. **Runs service** (`go-backend/internal/runs/service.go`) starts Percy CLI, then launches the embedded Node runner via Playwright. +3. **Runner JS** (`go-backend/internal/runtime/runnerjs/src/`) executes each step using Playwright's `page` API and calls `percy.snapshot()` at each `snapshot` step. +4. **Progress** is streamed to the extension via SSE (`GET /runs/:id/stream`). +5. The build is finalized automatically when all steps complete. + +--- + ## Technology Stack | Layer | Technology | |---|---| | Backend | Go (net/http) | | Chrome Extension | React 18, TypeScript, Vite, Manifest V3 | -| Communication | HTTP over localhost | +| Replay Engine | Node.js + Playwright (installed at runtime by the backend) | +| Communication | HTTP + SSE over localhost | --- diff --git a/go-backend/.gitignore b/go-backend/.gitignore index 07003db..adb09ed 100644 --- a/go-backend/.gitignore +++ b/go-backend/.gitignore @@ -1,6 +1,11 @@ # Snapshots snapshot/ -server +# Compiled binary only (not the cmd/server/ source directory) +/server # Test Companion onboarding (local, per-machine) .test-companion/ + +# Workflow runtime data (Playwright, workflows, builds, artifacts) +/runtime/ +/cmd/server/runtime/ diff --git a/go-backend/cmd/server/fs.go b/go-backend/cmd/server/fs.go new file mode 100644 index 0000000..3f3cccb --- /dev/null +++ b/go-backend/cmd/server/fs.go @@ -0,0 +1,5 @@ +package main + +import "os" + +func mkdirAll(p string) error { return os.MkdirAll(p, 0o755) } diff --git a/go-backend/cmd/server/main.go b/go-backend/cmd/server/main.go index b326c90..200458e 100644 --- a/go-backend/cmd/server/main.go +++ b/go-backend/cmd/server/main.go @@ -1,24 +1,28 @@ -package main +package main import ( "log" + "path/filepath" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/api" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/app" - "github.com/jostan30/Percy_chrome_ext/go-backend/internal/snapshot" - "github.com/jostan30/Percy_chrome_ext/go-backend/internal/service" - "github.com/jostan30/Percy_chrome_ext/go-backend/internal/percy" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/library" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/percy" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/runs" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/runtime" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/service" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/snapshot" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" ) -func main () { +func main() { store := snapshot.NewMemoryStore() snapshotService := service.NewSnapshotService(store) binary := percy.NewBinary() installer := percy.NewInstaller(binary) - controller := percy.NewController(binary,installer) + controller := percy.NewController(binary, installer) client := percy.NewClient() buildService := service.NewBuildService( @@ -29,18 +33,53 @@ func main () { libraryService := library.NewService() + runtimeDir := runtime.NewDir() + workflowStore, err := workflow.NewStore(runtimeDir.Workflows()) + if err != nil { + log.Fatalf("failed to initialise workflow store: %v", err) + } + buildStore, err := runs.NewStore(runtimeDir.Builds()) + if err != nil { + log.Fatalf("failed to initialise build store: %v", err) + } + if err := runtimeDirsReady(runtimeDir); err != nil { + log.Fatalf("failed to prepare runtime directory: %v", err) + } + + runService := runs.NewService(runs.Config{ + Workflows: workflowStore, + Builds: buildStore, + Percy: &runs.PercyAdapter{Controller: controller, Client: client}, + Runtime: runs.NewRuntimeAdapter(runtimeDir), + PercyURL: "http://localhost:5338", + // Workflow runs and snapshot-queue finalize share one Percy process. + Busy: buildService.Running, + }) + buildService.SetExternalBusy(runService.Active) + // A crash or restart must never leave a build permanently "running". + runService.RecoverInterrupted() + application := &app.App{ SnapshotService: snapshotService, - BuildService: buildService, - LibraryService: libraryService, + BuildService: buildService, + LibraryService: libraryService, + WorkflowStore: workflowStore, + BuildStore: buildStore, + RunService: runService, + RuntimeDir: runtimeDir, } - server := api.NewServer(":4321" ,application) + server := api.NewServer(":4321", application) log.Println("Starting server on port http://localhost:4321") if err := server.Start(); err != nil { log.Fatal(err) } +} -} \ No newline at end of file +// runtimeDirsReady creates the artifacts root so artifact listing never +// depends on a run having happened first. +func runtimeDirsReady(d *runtime.Dir) error { + return mkdirAll(filepath.Clean(d.Artifacts())) +} diff --git a/go-backend/go.mod b/go-backend/go.mod index 739cfe8..9bff1c1 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -1,5 +1,5 @@ module github.com/jostan30/Percy_chrome_ext/go-backend -go 1.26.5 +go 1.23.0 require github.com/google/uuid v1.6.0 // indirect diff --git a/go-backend/internal/api/handlers/build.go b/go-backend/internal/api/handlers/build.go index c8a1a56..8ca61e7 100644 --- a/go-backend/internal/api/handlers/build.go +++ b/go-backend/internal/api/handlers/build.go @@ -2,10 +2,14 @@ package handlers import ( "encoding/json" + "errors" + "fmt" "net/http" + "time" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/app" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/httpx" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/service" ) type BuildHandler struct { @@ -32,17 +36,82 @@ func (h *BuildHandler) Finalize(w http.ResponseWriter, r *http.Request) { return } - result , err := h.app.BuildService.Finalize(req.Token) - if err != nil { - httpx.WriteJSON(w, http.StatusInternalServerError ,map [string] string { + if err := h.app.BuildService.StartFinalize(req.Token); err != nil { + code := http.StatusInternalServerError + if errors.Is(err, service.ErrBuildRunning) { + code = http.StatusConflict + } + httpx.WriteJSON(w, code, map[string]string{ "error": err.Error(), }) return } - httpx.WriteJSON(w, http.StatusOK, map[string] string { - "message":"Percy started successfully", - "buildId": result.BuildID, - "buildUrl": result.BuildURL, - }) + httpx.WriteJSON(w, http.StatusAccepted, map[string]string{ + "status": "running", + }) +} + +// Status returns the current build progress and logs. +func (h *BuildHandler) Status(w http.ResponseWriter, r *http.Request) { + httpx.WriteJSON(w, http.StatusOK, h.app.BuildService.Status()) +} + +// Stream pushes BuildStatus as SSE events whenever the job state changes. +// The client receives an initial snapshot immediately, then a new event on +// every mutation (log line, phase change, snapshot state change, finish). +func (h *BuildHandler) Stream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering if present + + send := func() bool { + data, err := json.Marshal(h.app.BuildService.Status()) + if err != nil { + return false + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return false + } + flusher.Flush() + return true + } + + // Send initial snapshot so the client doesn't wait for the first change. + if !send() { + return + } + + ch := h.app.BuildService.Subscribe() + defer h.app.BuildService.Unsubscribe(ch) + + // keepalive ticker so the connection doesn't time out on idle builds. + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case _, ok := <-ch: + if !ok { + return + } + if !send() { + return + } + case <-ticker.C: + // Send a SSE comment as a keepalive ping. + if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil { + return + } + flusher.Flush() + } + } } \ No newline at end of file diff --git a/go-backend/internal/api/handlers/runs.go b/go-backend/internal/api/handlers/runs.go new file mode 100644 index 0000000..100b971 --- /dev/null +++ b/go-backend/internal/api/handlers/runs.go @@ -0,0 +1,216 @@ +package handlers + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/app" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/httpx" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/runs" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +const maxRunBody = 64 << 10 // 64 KiB: token + a few secrets + +var artifactNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}\.(html|json|log)$`) + +type RunsHandler struct { + app *app.App +} + +func NewRunsHandler(a *app.App) *RunsHandler { return &RunsHandler{app: a} } + +type runRequest struct { + // Token is the Percy project token; used for this run only and never stored. + Token string `json:"token"` + // Secrets resolves workflow valueRef names for this run only. + Secrets map[string]string `json:"secrets,omitempty"` +} + +// StartRun handles POST /api/workflows/{workflowId}/runs +func (h *RunsHandler) StartRun(w http.ResponseWriter, r *http.Request, workflowID string) { + if r.Method != http.MethodPost { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxRunBody) + var req runRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body") + return + } + key := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + if len(key) > 128 { + writeErr(w, http.StatusBadRequest, "Idempotency-Key too long") + return + } + b, dup, err := h.app.RunService.Start(workflowID, req.Token, key, req.Secrets) + if err != nil { + switch { + case errors.Is(err, workflow.ErrNotFound): + writeErr(w, http.StatusNotFound, "workflow not found") + case errors.Is(err, workflow.ErrInvalidID): + writeErr(w, http.StatusBadRequest, "invalid workflow id") + case errors.Is(err, runs.ErrBusy): + writeErr(w, http.StatusConflict, err.Error()) + case errors.Is(err, runs.ErrNoToken): + writeErr(w, http.StatusBadRequest, err.Error()) + default: + // Keep the real cause visible: a bare message made start failures undiagnosable. + log.Printf("[runs] start failed for %s: %v", workflowID, err) + writeErr(w, http.StatusInternalServerError, "could not start run: "+err.Error()) + } + return + } + status := http.StatusAccepted + if dup { + status = http.StatusOK // retry of an already-created build + } + httpx.WriteJSON(w, status, map[string]any{"buildId": b.BuildID, "status": b.Status, "duplicate": dup}) +} + +// Builds handles GET /api/builds +func (h *RunsHandler) Builds(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + list, err := h.app.BuildStore.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + httpx.WriteJSON(w, http.StatusOK, list) +} + +// Build handles /api/builds/{id}, /api/builds/{id}/artifacts[/{kind}/{name}], /api/builds/{id}/cancel +func (h *RunsHandler) Build(w http.ResponseWriter, r *http.Request) { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/builds/"), "/") + parts := strings.Split(rest, "/") + id := parts[0] + if !runs.ValidBuildID(id) { + writeErr(w, http.StatusBadRequest, "invalid build id") + return + } + switch { + case len(parts) == 1: + h.getBuild(w, r, id) + case len(parts) == 2 && parts[1] == "artifacts": + h.listArtifacts(w, r, id) + case len(parts) == 4 && parts[1] == "artifacts": + h.serveArtifact(w, r, id, parts[2], parts[3]) + case len(parts) == 2 && parts[1] == "cancel": + h.cancel(w, r, id) + default: + writeErr(w, http.StatusNotFound, "not found") + } +} + +func (h *RunsHandler) getBuild(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + b, err := h.app.BuildStore.Get(id) + if errors.Is(err, runs.ErrNotFound) { + writeErr(w, http.StatusNotFound, "build not found") + return + } else if err != nil { + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + httpx.WriteJSON(w, http.StatusOK, b) +} + +func (h *RunsHandler) cancel(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + switch err := h.app.RunService.Cancel(id); { + case err == nil: + httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) + case errors.Is(err, runs.ErrNotFound): + writeErr(w, http.StatusNotFound, "build not found") + case errors.Is(err, runs.ErrNotCancelled): + writeErr(w, http.StatusConflict, err.Error()) + default: + writeErr(w, http.StatusInternalServerError, "internal error") + } +} + +type artifactRef struct { + Kind string `json:"kind"` + Name string `json:"name"` + URL string `json:"url"` +} + +// listArtifacts returns controlled URLs, never filesystem paths. +func (h *RunsHandler) listArtifacts(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if _, err := h.app.BuildStore.Get(id); err != nil { + if errors.Is(err, runs.ErrNotFound) { + writeErr(w, http.StatusNotFound, "build not found") + } else { + writeErr(w, http.StatusInternalServerError, "internal error") + } + return + } + dir, err := h.app.RuntimeDir.Resolve("artifacts", id) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid build id") + return + } + refs := []artifactRef{} + for _, kind := range []string{"dom", "metadata", "logs"} { + entries, err := os.ReadDir(filepath.Join(dir, kind)) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() || !artifactNameRe.MatchString(e.Name()) { + continue + } + refs = append(refs, artifactRef{Kind: kind, Name: e.Name(), + URL: "/api/builds/" + id + "/artifacts/" + kind + "/" + e.Name()}) + } + } + httpx.WriteJSON(w, http.StatusOK, refs) +} + +func (h *RunsHandler) serveArtifact(w http.ResponseWriter, r *http.Request, id, kind, name string) { + if r.Method != http.MethodGet { + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if (kind != "dom" && kind != "metadata" && kind != "logs") || !artifactNameRe.MatchString(name) { + writeErr(w, http.StatusBadRequest, "invalid artifact") + return + } + p, err := h.app.RuntimeDir.Resolve("artifacts", id, kind, name) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid artifact") + return + } + // refuse symlinks so an artifact can never point outside the runtime dir + if fi, err := os.Lstat(p); err != nil || !fi.Mode().IsRegular() { + writeErr(w, http.StatusNotFound, "artifact not found") + return + } + // DOM files may hold user data: force download-as-text, never render/execute. + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + http.ServeFile(w, r, p) +} diff --git a/go-backend/internal/api/handlers/workflow.go b/go-backend/internal/api/handlers/workflow.go new file mode 100644 index 0000000..788ba42 --- /dev/null +++ b/go-backend/internal/api/handlers/workflow.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "strings" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/app" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/httpx" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +const maxWorkflowBody = 1 << 20 // 1 MiB + +type WorkflowHandler struct { + app *app.App +} + +func NewWorkflowHandler(app *app.App) *WorkflowHandler { + return &WorkflowHandler{app: app} +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + httpx.WriteJSON(w, status, map[string]string{"error": msg}) +} + +func decodeWorkflow(w http.ResponseWriter, r *http.Request) (*workflow.Workflow, bool) { + r.Body = http.MaxBytesReader(w, r.Body, maxWorkflowBody) + var wf workflow.Workflow + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&wf); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return nil, false + } + return &wf, true +} + +// workflowStoreErr maps store errors to HTTP statuses. +func workflowStoreErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, workflow.ErrNotFound): + writeErr(w, http.StatusNotFound, "workflow not found") + case errors.Is(err, workflow.ErrInvalidID): + writeErr(w, http.StatusBadRequest, "invalid workflow id") + default: + // validation errors are user errors; anything else is a server error + if isValidationErr(err) { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + // Keep the real cause in the server log and show it to the (local) user; + // a bare "internal error" made storage problems impossible to diagnose. + log.Printf("[workflow] store error: %v", err) + writeErr(w, http.StatusInternalServerError, "could not save workflow: "+err.Error()) + } +} + +func isValidationErr(err error) bool { + var v *workflow.ValidationError + return errors.As(err, &v) +} + +// Collection handles /api/workflows +func (h *WorkflowHandler) Collection(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + wf, ok := decodeWorkflow(w, r) + if !ok { + return + } + if err := h.app.WorkflowStore.Create(wf); err != nil { + workflowStoreErr(w, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, wf) + case http.MethodGet: + list, err := h.app.WorkflowStore.List() + if err != nil { + workflowStoreErr(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, list) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// Item handles /api/workflows/{workflowId} +func (h *WorkflowHandler) Item(w http.ResponseWriter, r *http.Request) { + id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/workflows/"), "/") + if wfID, ok := strings.CutSuffix(id, "/runs"); ok && wfID != "" && !strings.Contains(wfID, "/") { + NewRunsHandler(h.app).StartRun(w, r, wfID) + return + } + if id == "" || strings.Contains(id, "/") { + writeErr(w, http.StatusNotFound, "not found") + return + } + switch r.Method { + case http.MethodGet: + wf, err := h.app.WorkflowStore.Get(id) + if err != nil { + workflowStoreErr(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, wf) + case http.MethodPut: + wf, ok := decodeWorkflow(w, r) + if !ok { + return + } + if err := h.app.WorkflowStore.Update(id, wf); err != nil { + workflowStoreErr(w, err) + return + } + httpx.WriteJSON(w, http.StatusOK, wf) + default: + writeErr(w, http.StatusMethodNotAllowed, "method not allowed") + } +} diff --git a/go-backend/internal/api/handlers/workflow_test.go b/go-backend/internal/api/handlers/workflow_test.go new file mode 100644 index 0000000..9dac9d3 --- /dev/null +++ b/go-backend/internal/api/handlers/workflow_test.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/app" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +const goodBody = `{ + "name": "Dashboard", + "schemaVersion": 1, + "startUrl": "https://example.test/login", + "steps": [ + {"id":"step_001","type":"fill","target":{"role":"textbox","name":"Username"},"valueRef":"username"}, + {"id":"step_002","type":"snapshot","checkpointId":"dashboard","name":"Dashboard"} + ], + "settings": {"viewport": {"width":1440,"height":900}} +}` + +func newTestServer(t *testing.T) (*http.ServeMux, string) { + t.Helper() + dir := t.TempDir() + store, err := workflow.NewStore(filepath.Join(dir, "workflows")) + if err != nil { + t.Fatal(err) + } + h := NewWorkflowHandler(&app.App{WorkflowStore: store}) + mux := http.NewServeMux() + mux.HandleFunc("/api/workflows", h.Collection) + mux.HandleFunc("/api/workflows/", h.Item) + return mux, dir +} + +func do(mux http.Handler, method, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +func TestWorkflowAPIFlow(t *testing.T) { + mux, dir := newTestServer(t) + + rec := do(mux, "POST", "/api/workflows", goodBody) + if rec.Code != http.StatusCreated { + t.Fatalf("create: %d %s", rec.Code, rec.Body) + } + var created workflow.Workflow + _ = json.Unmarshal(rec.Body.Bytes(), &created) + if created.WorkflowID == "" || created.Version != 1 { + t.Fatalf("bad created: %+v", created) + } + if _, err := os.Stat(filepath.Join(dir, "workflows", created.WorkflowID+".json")); err != nil { + t.Fatalf("file not persisted: %v", err) + } + + if rec = do(mux, "GET", "/api/workflows", ""); rec.Code != 200 || !strings.Contains(rec.Body.String(), created.WorkflowID) { + t.Fatalf("list: %d %s", rec.Code, rec.Body) + } + if rec = do(mux, "GET", "/api/workflows/"+created.WorkflowID, ""); rec.Code != 200 { + t.Fatalf("get: %d", rec.Code) + } + + upd := strings.Replace(goodBody, "Dashboard\",\n \"schemaVersion", "Renamed\",\n \"schemaVersion", 1) + if rec = do(mux, "PUT", "/api/workflows/"+created.WorkflowID, upd); rec.Code != 200 { + t.Fatalf("put: %d %s", rec.Code, rec.Body) + } + var got workflow.Workflow + _ = json.Unmarshal(do(mux, "GET", "/api/workflows/"+created.WorkflowID, "").Body.Bytes(), &got) + if got.Name != "Renamed" || got.Version != 1 { + t.Fatalf("update not applied: %+v", got) + } +} + +func TestWorkflowAPIRejects(t *testing.T) { + mux, dir := newTestServer(t) + cases := []struct { + name, method, path, body string + want int + }{ + {"malformed json", "POST", "/api/workflows", "{not json", 400}, + {"unknown field", "POST", "/api/workflows", `{"name":"x","bogus":1}`, 400}, + {"unknown step", "POST", "/api/workflows", strings.Replace(goodBody, `"type":"fill"`, `"type":"exec"`, 1), 400}, + {"bad url scheme", "POST", "/api/workflows", strings.Replace(goodBody, "https://example.test/login", "javascript:alert(1)", 1), 400}, + {"missing workflow", "GET", "/api/workflows/wf_missing", "", 404}, + {"traversal get", "GET", "/api/workflows/..%2F..%2Fetc%2Fpasswd", "", 404}, + {"uppercase id", "GET", "/api/workflows/WF_X", "", 400}, + {"put missing", "PUT", "/api/workflows/wf_missing", goodBody, 404}, + {"bad method", "DELETE", "/api/workflows", "", 405}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if rec := do(mux, c.method, c.path, c.body); rec.Code != c.want { + t.Fatalf("got %d want %d: %s", rec.Code, c.want, rec.Body) + } + }) + } + // nothing escaped the workflows dir + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.Name() != "workflows" { + t.Fatalf("unexpected file outside store: %s", e.Name()) + } + } +} + +func TestWorkflowAPIBodyTooLarge(t *testing.T) { + mux, _ := newTestServer(t) + big := `{"name":"` + strings.Repeat("a", 2<<20) + `"}` + if rec := do(mux, "POST", "/api/workflows", big); rec.Code != 400 { + t.Fatalf("got %d", rec.Code) + } +} diff --git a/go-backend/internal/api/router.go b/go-backend/internal/api/router.go index 411bf2a..d33cec4 100644 --- a/go-backend/internal/api/router.go +++ b/go-backend/internal/api/router.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "time" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/api/handlers" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/middleware" @@ -31,6 +32,8 @@ func (s *Server) routes() { snapshotHandler := handlers.NewSnapshotHandler(s.app) buildHandler := handlers.NewBuildHandler(s.app) libraryHandler := handlers.NewLibraryHandler(s.app) + workflowHandler := handlers.NewWorkflowHandler(s.app) + runsHandler := handlers.NewRunsHandler(s.app) s.mux.HandleFunc("/health" , handlers.Health) s.mux.HandleFunc("/status" , handlers.Status) @@ -70,11 +73,18 @@ func (s *Server) routes() { }) s.mux.HandleFunc("/build/finalize", buildHandler.Finalize) + s.mux.HandleFunc("/build/status", buildHandler.Status) + s.mux.HandleFunc("/build/stream", buildHandler.Stream) s.mux.HandleFunc("/library/token" ,libraryHandler.SetToken) s.mux.HandleFunc("/library/search", libraryHandler.Search) s.mux.HandleFunc("/library/status", libraryHandler.Status) s.mux.HandleFunc("/library/all", libraryHandler.All) + + s.mux.HandleFunc("/api/workflows", workflowHandler.Collection) + s.mux.HandleFunc("/api/workflows/", workflowHandler.Item) + s.mux.HandleFunc("/api/builds", runsHandler.Builds) + s.mux.HandleFunc("/api/builds/", runsHandler.Build) } @@ -85,6 +95,11 @@ func (s *Server) Start() error { middleware.Logger, middleware.CORS, ) - return http.ListenAndServe(s.addr ,handler) + srv := &http.Server{ + Addr: s.addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + return srv.ListenAndServe() } diff --git a/go-backend/internal/app/app.go b/go-backend/internal/app/app.go index ae2cb09..ef81f1a 100644 --- a/go-backend/internal/app/app.go +++ b/go-backend/internal/app/app.go @@ -2,7 +2,10 @@ package app import ( "github.com/jostan30/Percy_chrome_ext/go-backend/internal/library" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/runs" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/runtime" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/service" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" ) type App struct { @@ -10,4 +13,9 @@ type App struct { BuildService *service.BuildService LibraryService *library.Service -} \ No newline at end of file + + WorkflowStore *workflow.Store + BuildStore *runs.Store + RunService *runs.Service + RuntimeDir *runtime.Dir +} diff --git a/go-backend/internal/middleware/cors.go b/go-backend/internal/middleware/cors.go index f9e8c8b..ac4b3d6 100644 --- a/go-backend/internal/middleware/cors.go +++ b/go-backend/internal/middleware/cors.go @@ -5,8 +5,9 @@ import "net/http" func CORS(next http.Handler)http.Handler { return http.HandlerFunc(func (w http.ResponseWriter ,r *http.Request){ + // nosemgrep: local-only backend (localhost:4321) called by the browser extension; wildcard origin is intentional w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Idempotency-Key") w.Header().Set("Access-Control-Allow-Methods","GET, POST, PUT, DELETE, OPTIONS") if r.Method == http.MethodOptions { diff --git a/go-backend/internal/percy/client.go b/go-backend/internal/percy/client.go index 891ea8a..e015b0c 100644 --- a/go-backend/internal/percy/client.go +++ b/go-backend/internal/percy/client.go @@ -6,6 +6,7 @@ import ( "bytes" "io" "fmt" + "time" "github.com/jostan30/Percy_chrome_ext/go-backend/internal/snapshot" ) @@ -38,6 +39,33 @@ func (c *Client) Health() (*HealthResponse ,error) { return &health ,nil } +// WaitIdle blocks until Percy reports all queued snapshots are processed, +// the done channel closes (process died), or the timeout elapses. +func (c *Client) WaitIdle(timeout time.Duration, done <-chan struct{}) error { + deadline := time.Now().Add(timeout) + httpc := &http.Client{Timeout: 2 * time.Minute} + + for { + select { + case <-done: + return fmt.Errorf("percy exited while waiting for snapshots") + default: + } + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for Percy to finish snapshots") + } + + resp, err := httpc.Get(c.baseURL + "/percy/idle") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(500 * time.Millisecond) + } +} + func toSnapshotRequest(s snapshot.Snapshot) SnapshotRequest { return SnapshotRequest{ Name: s.Name, diff --git a/go-backend/internal/percy/controller.go b/go-backend/internal/percy/controller.go index 18a6314..24b6fdc 100644 --- a/go-backend/internal/percy/controller.go +++ b/go-backend/internal/percy/controller.go @@ -4,25 +4,53 @@ import ( "bufio" "errors" "fmt" + "io" "log" "os" "os/exec" "strings" - "io" + "sync" + "time" +) + +const ( + startTimeout = 3 * time.Minute + stopTimeout = 30 * time.Second ) type Controller struct { binary *Binary installer *Installer - cmd *exec.Cmd - logs chan string + + mu sync.Mutex + cmd *exec.Cmd + token string + // logSink, when set, receives every line of Percy CLI output. + logSink func(line string) + // exited is closed when the running percy process exits. + exited chan struct{} } func NewController(binary *Binary, installer *Installer) *Controller { return &Controller{ binary: binary, installer: installer, - logs: make(chan string, 100), + } +} + +// SetLogSink registers a callback that receives each Percy CLI output line. +func (c *Controller) SetLogSink(sink func(line string)) { + c.mu.Lock() + c.logSink = sink + c.mu.Unlock() +} + +func (c *Controller) emitLog(line string) { + c.mu.Lock() + sink := c.logSink + c.mu.Unlock() + if sink != nil { + sink(line) } } @@ -34,99 +62,129 @@ func (c *Controller) Start(token string) error { return errors.New("percy cli not installed") } - log.Println("[percy] Percy CLI exists") - log.Println("[percy] launching: percy exec:start") - - c.cmd = exec.Command(c.binary.Path, "exec:start") - - c.cmd.Env = append( + // Path is derived from the server's own executable location (see NewBinary), never from user input. + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd := exec.Command(c.binary.Path, "exec:start") + cmd.Env = append( os.Environ(), "PERCY_TOKEN="+token, "PERCY_BRANCH=percy-local-manager", ) - stdout, err := c.cmd.StdoutPipe() + stdout, err := cmd.StdoutPipe() if err != nil { return err } - - stderr, err := c.cmd.StderrPipe() + stderr, err := cmd.StderrPipe() if err != nil { return err } - if err := c.cmd.Start(); err != nil { + if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start Percy CLI: %w", err) } - log.Println("[percy] Percy process started") - ready := make(chan error, 1) + exited := make(chan struct{}) + c.mu.Lock() + c.cmd = cmd + c.token = token + c.exited = exited + c.mu.Unlock() + + ready := make(chan struct{}) + var readyOnce sync.Once + + // Continuously drain output so the CLI never blocks on a full pipe. + var wg sync.WaitGroup readOutput := func(r io.Reader) { + defer wg.Done() scanner := bufio.NewScanner(r) - + scanner.Buffer(make([]byte, 64*1024), 1024*1024) for scanner.Scan() { line := scanner.Text() - log.Printf("[percy-cli] %s", line) - - c.logs <- line - + c.emitLog(line) if strings.Contains(line, "Percy has started") { - log.Println("[percy] Percy is ready") - - select { - case ready <- nil: - default: - } - } - } - - if err := scanner.Err(); err != nil { - select { - case ready <- err: - default: + readyOnce.Do(func() { close(ready) }) } } } - + wg.Add(2) go readOutput(stdout) go readOutput(stderr) - return <-ready -} -func (c *Controller) WaitFor(match string) error { - - for { - - line := <-c.logs - - if strings.Contains(line, match) { - return nil - } - + go func() { + wg.Wait() + err := cmd.Wait() + log.Printf("[percy] process exited: %v", err) + close(exited) + }() + + select { + case <-ready: + log.Println("[percy] Percy is ready") + return nil + case <-exited: + return errors.New("percy exited before it was ready") + case <-time.After(startTimeout): + c.kill() + return errors.New("timed out waiting for Percy to start") } - } +// Stop asks Percy to finish uploading and exit (exec:stop), falling back to +// killing the process if it does not exit in time. func (c *Controller) Stop() error { - - if !c.binary.Exists() { - return errors.New("percy cli not installed") + c.mu.Lock() + cmd := c.cmd + token := c.token + exited := c.exited + c.mu.Unlock() + + if cmd == nil { + return nil } - cmd := exec.Command(c.binary.Path, "exec:stop") + // Path is derived from the server's own executable location (see NewBinary), never from user input. + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + stop := exec.Command(c.binary.Path, "exec:stop") + stop.Env = append(os.Environ(), "PERCY_TOKEN="+token) + if out, err := stop.CombinedOutput(); err != nil { + log.Printf("[percy] exec:stop failed: %v: %s", err, strings.TrimSpace(string(out))) + } - if err := cmd.Run(); err != nil { - return err + select { + case <-exited: + case <-time.After(stopTimeout): + log.Println("[percy] did not exit after exec:stop, killing") + c.kill() + <-exited } + c.mu.Lock() c.cmd = nil - + c.mu.Unlock() return nil } +func (c *Controller) kill() { + c.mu.Lock() + cmd := c.cmd + c.mu.Unlock() + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } +} + +// Exited returns a channel closed when the Percy process has exited. +func (c *Controller) Exited() <-chan struct{} { + c.mu.Lock() + defer c.mu.Unlock() + return c.exited +} + func (c *Controller) EnsureReady() error { return c.installer.EnsureReady() } \ No newline at end of file diff --git a/go-backend/internal/runs/adapters.go b/go-backend/internal/runs/adapters.go new file mode 100644 index 0000000..5c4a81e --- /dev/null +++ b/go-backend/internal/runs/adapters.go @@ -0,0 +1,69 @@ +package runs + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/percy" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/runtime" +) + +// RuntimeAdapter implements Runtime over the shared runtime directory. +type RuntimeAdapter struct { + Dir *runtime.Dir + Installer *runtime.PlaywrightInstaller +} + +func NewRuntimeAdapter(dir *runtime.Dir) *RuntimeAdapter { + return &RuntimeAdapter{Dir: dir, Installer: runtime.NewPlaywrightInstaller(dir)} +} + +func (r *RuntimeAdapter) EnsureReady(progress func(string)) error { + return r.Installer.EnsureReady(progress) +} +func (r *RuntimeAdapter) RunnerEntry() string { return r.Dir.RunnerEntry() } +func (r *RuntimeAdapter) PlaywrightDir() string { return r.Dir.Playwright() } + +func (r *RuntimeAdapter) ArtifactDir(buildID string) (string, error) { + if !ValidBuildID(buildID) { + return "", ErrInvalidID + } + return r.Dir.Resolve("artifacts", buildID) +} + +// RequestPath is inside the build's artifact dir but outside dom/metadata/logs, +// so it is never served and is deleted after the run. +func (r *RuntimeAdapter) RequestPath(buildID string) (string, error) { + if !ValidBuildID(buildID) { + return "", ErrInvalidID + } + p, err := r.Dir.Resolve("artifacts", buildID) + if err != nil { + return "", err + } + return filepath.Join(p, ".request.json"), nil +} + +// PercyAdapter implements Percy over the existing controller and client. +type PercyAdapter struct { + Controller *percy.Controller + Client *percy.Client +} + +func (p *PercyAdapter) EnsureReady() error { return p.Controller.EnsureReady() } +func (p *PercyAdapter) Start(t string) error { return p.Controller.Start(t) } +func (p *PercyAdapter) Stop() error { return p.Controller.Stop() } +func (p *PercyAdapter) BuildInfo() (string, string, error) { + h, err := p.Client.Health() + if err != nil { + return "", "", err + } + return h.Build.ID, h.Build.URL, nil +} +func (p *PercyAdapter) WaitIdle(timeout time.Duration) error { + if err := p.Client.WaitIdle(timeout, p.Controller.Exited()); err != nil { + return fmt.Errorf("%w", err) + } + return nil +} diff --git a/go-backend/internal/runs/failure_test.go b/go-backend/internal/runs/failure_test.go new file mode 100644 index 0000000..c006059 --- /dev/null +++ b/go-backend/internal/runs/failure_test.go @@ -0,0 +1,184 @@ +package runs + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestStepFailureFailsBuildAndNamesTheStep(t *testing.T) { + h := newHarness(t) + h.launch.exit = 1 + h.launch.lines = []string{ + ev(map[string]any{"type": "stepStarted", "stepId": "step_001"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_001", "status": "passed"}), + ev(map[string]any{"type": "stepStarted", "stepId": "step_002"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_002", "status": "failed", + "message": `locator role=button[name="Go"] matched 2 elements`}), + } + b := h.startAndWait(t, "") + if b.Status != StatusFailed { + t.Fatalf("status = %s", b.Status) + } + if len(b.Errors) == 0 || b.Errors[0].StepID != "step_002" || !strings.Contains(b.Errors[0].Message, "matched 2") { + t.Fatalf("errors = %+v", b.Errors) + } + // later steps never ran: skipped, not left pending/running + if b.Steps[2].Status != StepSkipped || b.Steps[3].Status != StepSkipped { + t.Fatalf("remaining steps = %s %s", b.Steps[2].Status, b.Steps[3].Status) + } + for _, c := range b.Checkpoints { + if c.Status == CheckpointPending { + t.Fatalf("checkpoint %s left pending", c.CheckpointID) + } + } + if h.percy.stopped != 1 { + t.Fatal("percy must be stopped after a failed run") + } +} + +func TestUploadFailureMakesBuildPartialAndKeepsDom(t *testing.T) { + h := newHarness(t) + h.launch.exit = 2 + h.launch.lines = []string{ + ev(map[string]any{"type": "stepStarted", "stepId": "step_003"}), + ev(map[string]any{"type": "checkpointCaptured", "checkpointId": "dashboard", + "domPath": h.root + "/x/dom/dashboard.html", "metadataPath": h.root + "/x/metadata/dashboard.json"}), + ev(map[string]any{"type": "checkpointUploadFailed", "checkpointId": "dashboard", "message": "percy returned 500"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_003", "status": "passed"}), + } + b := h.startAndWait(t, "") + if b.Status != StatusPartial { + t.Fatalf("status = %s", b.Status) + } + c := b.Checkpoints[0] + if c.Status != CheckpointUploadFailed || c.DomPath != "dom/dashboard.html" || !strings.Contains(c.Error, "500") { + t.Fatalf("checkpoint = %+v", c) + } +} + +func TestPercyNotIdleMakesBuildPartial(t *testing.T) { + h := newHarness(t) + h.percy.idleErr = errors.New("timed out waiting for Percy") + h.launch.lines = happyLines(h.root) + if b := h.startAndWait(t, ""); b.Status != StatusPartial { + t.Fatalf("status = %s", b.Status) + } +} + +func TestRunnerCrashDoesNotLeaveBuildRunning(t *testing.T) { + h := newHarness(t) + h.launch.exit = 9 // killed / segfault + h.launch.lines = []string{ev(map[string]any{"type": "stepStarted", "stepId": "step_001"})} + b := h.startAndWait(t, "") + if b.Status != StatusFailed || len(b.Errors) == 0 || + !strings.Contains(b.Errors[len(b.Errors)-1].Message, "crashed") { + t.Fatalf("status=%s errors=%+v", b.Status, b.Errors) + } + if b.Steps[0].Status != StepSkipped { + t.Fatalf("in-flight step should be finalized, got %s", b.Steps[0].Status) + } +} + +func TestSetupLaunchAndPercyStartFailuresFinalize(t *testing.T) { + h := newHarness(t) + h.launch.err = errors.New("node not found") + if b := h.startAndWait(t, ""); b.Status != StatusFailed || !strings.Contains(b.Errors[0].Message, "node not found") { + t.Fatalf("launch failure: %s %+v", b.Status, b.Errors) + } + h2 := newHarness(t) + h2.rt.setupErr = errors.New("npm install failed") + if b := h2.startAndWait(t, ""); b.Status != StatusFailed || !strings.Contains(b.Errors[0].Message, "npm install") { + t.Fatalf("setup failure: %s %+v", b.Status, b.Errors) + } + h3 := newHarness(t) + h3.percy.startEr = errors.New("percy exited before it was ready") + if b := h3.startAndWait(t, ""); b.Status != StatusFailed { + t.Fatalf("percy start failure: %s", b.Status) + } +} + +func TestTimeoutFinalizesAsFailed(t *testing.T) { + h := newHarness(t) + h.svc.cfg.RunTimeout = 150 * time.Millisecond + h.launch.block = make(chan struct{}) // never released + b := h.startAndWait(t, "") + if b.Status != StatusFailed || !strings.Contains(b.Errors[len(b.Errors)-1].Message, "timed out") { + t.Fatalf("status=%s errors=%+v", b.Status, b.Errors) + } +} + +func TestCancelFinalizesAsCancelledAndFreesTheSlot(t *testing.T) { + h := newHarness(t) + h.launch.block = make(chan struct{}) + b, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cur, _ := h.builds.Get(b.BuildID); cur != nil && cur.Status == StatusRunning { + break + } + time.Sleep(10 * time.Millisecond) + } + if err := h.svc.Cancel(b.BuildID); err != nil { + t.Fatal(err) + } + h.svc.Wait() + if got, _ := h.builds.Get(b.BuildID); got.Status != StatusCancelled { + t.Fatalf("status = %s", got.Status) + } + if h.percy.stopped != 1 { + t.Fatal("percy not stopped after cancel") + } + // cancelling again, even while the goroutine may still be unwinding, is a conflict + if err := h.svc.Cancel(b.BuildID); !errors.Is(err, ErrNotCancelled) { + t.Fatalf("second cancel should conflict, got %v", err) + } + // slot is free again + h.launch.block = nil + h.launch.lines = happyLines(h.root) + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); err != nil { + t.Fatalf("new run after cancel: %v", err) + } + h.svc.Wait() + if err := h.svc.Cancel(b.BuildID); !errors.Is(err, ErrNotCancelled) { + t.Fatalf("cancelling a finished build: %v", err) + } +} + +func TestLateRunnerEventsCannotOverwriteTerminalState(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b := h.startAndWait(t, "") + if b.Status != StatusPassed { + t.Fatalf("setup: %s", b.Status) + } + got, err := h.builds.Update(b.BuildID, func(x *Build) { x.Status = StatusFailed }) + if err != nil { + t.Fatal(err) + } + if got.Status != StatusPassed { + t.Fatalf("terminal build was mutated: %s", got.Status) + } +} + +func TestRecoverInterruptedFinalizesStaleBuilds(t *testing.T) { + h := newHarness(t) + h.launch.block = make(chan struct{}) + b, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil) + if err != nil { + t.Fatal(err) + } + // simulate a fresh server process finding the leftover non-terminal build + svc2 := NewService(h.svc.cfg) + svc2.RecoverInterrupted() + got, _ := h.builds.Get(b.BuildID) + if got.Status != StatusFailed || !strings.Contains(got.Errors[len(got.Errors)-1].Message, "interrupted") { + t.Fatalf("status=%s errors=%+v", got.Status, got.Errors) + } + close(h.launch.block) + h.svc.Wait() +} diff --git a/go-backend/internal/runs/harness_test.go b/go-backend/internal/runs/harness_test.go new file mode 100644 index 0000000..5fe713d --- /dev/null +++ b/go-backend/internal/runs/harness_test.go @@ -0,0 +1,182 @@ +package runs + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +type fakePercy struct { + mu sync.Mutex + started int + stopped int + idleErr error + startEr error +} + +func (f *fakePercy) EnsureReady() error { return nil } +func (f *fakePercy) Start(string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.started++ + return f.startEr +} +func (f *fakePercy) Stop() error { + f.mu.Lock() + defer f.mu.Unlock() + f.stopped++ + return nil +} +func (f *fakePercy) BuildInfo() (string, string, error) { return "999", "https://percy.io/b/999", nil } +func (f *fakePercy) WaitIdle(time.Duration) error { return f.idleErr } + +type fakeRuntime struct { + root string + setupErr error +} + +func (r *fakeRuntime) EnsureReady(func(string)) error { return r.setupErr } +func (r *fakeRuntime) RunnerEntry() string { return "runner.js" } +func (r *fakeRuntime) PlaywrightDir() string { return r.root } +func (r *fakeRuntime) ArtifactDir(id string) (string, error) { + if !ValidBuildID(id) { + return "", ErrInvalidID + } + return filepath.Join(r.root, "artifacts", id), nil +} +func (r *fakeRuntime) RequestPath(id string) (string, error) { + return filepath.Join(r.root, "artifacts", id, ".request.json"), nil +} + +type fakeProc struct { + exit int + block chan struct{} // if set, Wait blocks until closed or ctx is done + ctx context.Context +} + +func (p *fakeProc) Wait() error { + if p.block != nil { + select { + case <-p.block: + case <-p.ctx.Done(): + return p.ctx.Err() + } + } + if p.exit == 0 { + return nil + } + // a real *exec.ExitError carrying the wanted exit code + return exec.Command("sh", "-c", "exit "+strconv.Itoa(p.exit)).Run() +} +func (p *fakeProc) Kill() error { return nil } + +type launchCfg struct { + mu sync.Mutex + lines []string + exit int + block chan struct{} + err error + req []byte +} + +type harness struct { + svc *Service + builds *Store + wfs *workflow.Store + percy *fakePercy + rt *fakeRuntime + wf *workflow.Workflow + launch *launchCfg + root string + busy bool +} + +func newHarness(t *testing.T) *harness { + t.Helper() + root := t.TempDir() + wfs, err := workflow.NewStore(filepath.Join(root, "workflows")) + if err != nil { + t.Fatal(err) + } + builds, err := NewStore(filepath.Join(root, "builds")) + if err != nil { + t.Fatal(err) + } + wf := &workflow.Workflow{ + Name: "Dash", SchemaVersion: 1, StartURL: "https://example.test/", + Settings: workflow.Settings{Viewport: workflow.Viewport{Width: 1280, Height: 800}}, + Steps: []workflow.Step{ + {ID: "step_001", Type: "fill", Target: &workflow.Target{Role: "textbox", Name: "Password"}, ValueRef: "pw"}, + {ID: "step_002", Type: "click", Target: &workflow.Target{Role: "button", Name: "Go"}}, + {ID: "step_003", Type: "snapshot", CheckpointID: "dashboard", Name: "Dashboard"}, + {ID: "step_004", Type: "snapshot", CheckpointID: "accounts", Name: "Accounts"}, + }, + } + if err := wfs.Create(wf); err != nil { + t.Fatal(err) + } + h := &harness{builds: builds, wfs: wfs, percy: &fakePercy{}, rt: &fakeRuntime{root: root}, wf: wf, launch: &launchCfg{}, root: root} + h.svc = NewService(Config{ + Workflows: wfs, Builds: builds, Percy: h.percy, Runtime: h.rt, PercyURL: "http://127.0.0.1:5338", + RunTimeout: 3 * time.Second, IdleTimeout: time.Second, + Busy: func() bool { return h.busy }, + Launch: func(ctx context.Context, entry, req, dir string, onLine, onLog func(string)) (Process, error) { + h.launch.mu.Lock() + defer h.launch.mu.Unlock() + if h.launch.err != nil { + return nil, h.launch.err + } + h.launch.req, _ = os.ReadFile(req) // request file must exist while running + for _, l := range h.launch.lines { + onLine(l) + } + return &fakeProc{exit: h.launch.exit, block: h.launch.block, ctx: ctx}, nil + }, + }) + return h +} + +func ev(m map[string]any) string { + b, _ := json.Marshal(m) + return string(b) +} + +func happyLines(root string) []string { + cp := func(id string) string { + return ev(map[string]any{"type": "checkpointCaptured", "checkpointId": id, + "domPath": root + "/artifacts/b/dom/" + id + ".html", + "metadataPath": root + "/artifacts/b/metadata/" + id + ".json"}) + } + up := func(id string) string { return ev(map[string]any{"type": "checkpointUploaded", "checkpointId": id}) } + st := func(id string) string { return ev(map[string]any{"type": "stepStarted", "stepId": id}) } + fin := func(id string) string { + return ev(map[string]any{"type": "stepFinished", "stepId": id, "status": "passed", "durationMs": 12}) + } + return []string{ + st("step_001"), fin("step_001"), st("step_002"), fin("step_002"), + st("step_003"), cp("dashboard"), up("dashboard"), fin("step_003"), + st("step_004"), cp("accounts"), up("accounts"), fin("step_004"), + } +} + +func (h *harness) startAndWait(t *testing.T, key string) *Build { + t.Helper() + b, _, err := h.svc.Start(h.wf.WorkflowID, "tok", key, map[string]string{"pw": "hunter2-SECRET"}) + if err != nil { + t.Fatal(err) + } + h.svc.Wait() + got, err := h.builds.Get(b.BuildID) + if err != nil { + t.Fatal(err) + } + return got +} diff --git a/go-backend/internal/runs/lifecycle_test.go b/go-backend/internal/runs/lifecycle_test.go new file mode 100644 index 0000000..389d233 --- /dev/null +++ b/go-backend/internal/runs/lifecycle_test.go @@ -0,0 +1,152 @@ +package runs + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +func TestHappyPathPassesAndRecordsVersionAndCheckpoints(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b := h.startAndWait(t, "") + + if b.Status != StatusPassed { + t.Fatalf("status = %s errors=%v", b.Status, b.Errors) + } + if b.WorkflowID != h.wf.WorkflowID || b.WorkflowVersion != h.wf.Version { + t.Fatalf("workflow ref wrong: %s v%d", b.WorkflowID, b.WorkflowVersion) + } + if b.StartedAt == nil || b.CompletedAt == nil { + t.Fatal("timestamps missing") + } + if b.PercyBuildID != "999" || b.PercyBuildURL == "" { + t.Fatalf("percy ref missing: %+v", b) + } + if len(b.Checkpoints) != 2 { + t.Fatalf("checkpoints = %d", len(b.Checkpoints)) + } + for _, c := range b.Checkpoints { + if c.Status != CheckpointUploaded { + t.Fatalf("checkpoint %s = %s", c.CheckpointID, c.Status) + } + // artifact-relative, never absolute + if filepath.IsAbs(c.DomPath) || !strings.HasPrefix(c.DomPath, "dom/") || !strings.HasPrefix(c.MetadataPath, "metadata/") { + t.Fatalf("bad paths: %q %q", c.DomPath, c.MetadataPath) + } + } + if b.Checkpoints[0].CheckpointID != "dashboard" || b.Checkpoints[1].CheckpointID != "accounts" { + t.Fatal("checkpoint order changed") + } + for _, s := range b.Steps { + if s.Status != StepPassed { + t.Fatalf("step %s = %s", s.StepID, s.Status) + } + } + if h.percy.started != 1 || h.percy.stopped != 1 { + t.Fatalf("percy start/stop = %d/%d", h.percy.started, h.percy.stopped) + } +} + +func TestBuildRecordPersistedBeforeRunnerStarts(t *testing.T) { + h := newHarness(t) + h.launch.block = make(chan struct{}) + b, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(h.root, "builds", b.BuildID+".json")); err != nil { + t.Fatalf("build file missing while run in progress: %v", err) + } + if b.Status != StatusQueued { + t.Fatalf("initial status = %s", b.Status) + } + close(h.launch.block) + h.svc.Wait() +} + +func TestSecretsNeverPersistedOrLogged(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b := h.startAndWait(t, "") + + // the runner did get the secret via the 0600 request file... + if !strings.Contains(string(h.launch.req), "hunter2-SECRET") { + t.Fatal("runner request should carry the secret") + } + // ...which is deleted afterwards and never lands in build JSON or logs + if _, err := os.Stat(filepath.Join(h.root, "artifacts", b.BuildID, ".request.json")); !os.IsNotExist(err) { + t.Fatalf("request file should be deleted, err=%v", err) + } + err := filepath.Walk(h.root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + data, _ := os.ReadFile(p) + if strings.Contains(string(data), "hunter2-SECRET") { + t.Errorf("secret leaked into %s", p) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestIdempotencyKeyReturnsSameBuild(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b1, dup1, err := h.svc.Start(h.wf.WorkflowID, "tok", "key-1", nil) + if err != nil || dup1 { + t.Fatalf("first: %v dup=%v", err, dup1) + } + h.svc.Wait() + b2, dup2, err := h.svc.Start(h.wf.WorkflowID, "tok", "key-1", nil) + if err != nil || !dup2 || b2.BuildID != b1.BuildID { + t.Fatalf("retry should return existing build: %v dup=%v", err, dup2) + } + if list, _ := h.builds.List(); len(list) != 1 { + t.Fatalf("builds = %d, want 1", len(list)) + } +} + +func TestConcurrentRunsRejected(t *testing.T) { + h := newHarness(t) + h.launch.block = make(chan struct{}) + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); err != nil { + t.Fatal(err) + } + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); !errors.Is(err, ErrBusy) { + t.Fatalf("want ErrBusy, got %v", err) + } + close(h.launch.block) + h.svc.Wait() + if list, _ := h.builds.List(); len(list) != 1 { + t.Fatalf("rejected run must not leave a build: %d", len(list)) + } +} + +func TestBusyWhenSnapshotFinalizeRunning(t *testing.T) { + h := newHarness(t) + h.busy = true + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); !errors.Is(err, ErrBusy) { + t.Fatalf("want ErrBusy, got %v", err) + } +} + +func TestMissingTokenAndMissingWorkflow(t *testing.T) { + h := newHarness(t) + if _, _, err := h.svc.Start(h.wf.WorkflowID, "", "", nil); !errors.Is(err, ErrNoToken) { + t.Fatalf("want ErrNoToken, got %v", err) + } + if _, _, err := h.svc.Start("wf_missing", "tok", "", nil); !errors.Is(err, workflow.ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } + if _, _, err := h.svc.Start("../x", "tok", "", nil); !errors.Is(err, workflow.ErrInvalidID) { + t.Fatalf("want ErrInvalidID, got %v", err) + } +} diff --git a/go-backend/internal/runs/model.go b/go-backend/internal/runs/model.go new file mode 100644 index 0000000..6ba7a41 --- /dev/null +++ b/go-backend/internal/runs/model.go @@ -0,0 +1,82 @@ +// Package runs owns workflow replay: build records, their persistence, and +// the lifecycle of the Node.js Playwright runner process. +package runs + +import "time" + +type Status string + +const ( + StatusQueued Status = "queued" + StatusRunning Status = "running" + StatusPassed Status = "passed" + StatusFailed Status = "failed" + StatusPartial Status = "partial" + StatusCancelled Status = "cancelled" +) + +func (s Status) Terminal() bool { + return s == StatusPassed || s == StatusFailed || s == StatusPartial || s == StatusCancelled +} + +type StepStatus string + +const ( + StepPending StepStatus = "pending" + StepRunning StepStatus = "running" + StepPassed StepStatus = "passed" + StepFailed StepStatus = "failed" + StepSkipped StepStatus = "skipped" +) + +type CheckpointStatus string + +const ( + CheckpointPending CheckpointStatus = "pending" + CheckpointCaptured CheckpointStatus = "captured" + CheckpointUploaded CheckpointStatus = "uploaded" + CheckpointUploadFailed CheckpointStatus = "uploadFailed" + CheckpointFailed CheckpointStatus = "failed" +) + +type StepResult struct { + StepID string `json:"stepId"` + Type string `json:"type"` + Status StepStatus `json:"status"` + DurationMs int64 `json:"durationMs,omitempty"` + Error string `json:"error,omitempty"` +} + +// Checkpoint paths are artifact-relative (never absolute) so the build JSON +// never leaks filesystem layout; clients fetch content via the artifact route. +type Checkpoint struct { + CheckpointID string `json:"checkpointId"` + Name string `json:"name"` + Status CheckpointStatus `json:"status"` + DomPath string `json:"domPath,omitempty"` + MetadataPath string `json:"metadataPath,omitempty"` + Error string `json:"error,omitempty"` +} + +type RunError struct { + StepID string `json:"stepId,omitempty"` + Message string `json:"message"` +} + +type Build struct { + BuildID string `json:"buildId"` + WorkflowID string `json:"workflowId"` + WorkflowVersion int `json:"workflowVersion"` + WorkflowName string `json:"workflowName"` + Status Status `json:"status"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + CurrentStepID string `json:"currentStepId,omitempty"` + Steps []StepResult `json:"steps"` + Checkpoints []Checkpoint `json:"checkpoints"` + Errors []RunError `json:"errors"` + PercyBuildID string `json:"percyBuildId,omitempty"` + PercyBuildURL string `json:"percyBuildUrl,omitempty"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` +} diff --git a/go-backend/internal/runs/pump.go b/go-backend/internal/runs/pump.go new file mode 100644 index 0000000..38de3e7 --- /dev/null +++ b/go-backend/internal/runs/pump.go @@ -0,0 +1,129 @@ +package runs + +import ( + "encoding/json" + "path/filepath" + "strings" + "sync" +) + +// event is one line of the runner's NDJSON stdout. +type event struct { + Type string `json:"type"` + StepID string `json:"stepId"` + Status string `json:"status"` + Message string `json:"message"` + DurationMs int64 `json:"durationMs"` + CheckpointID string `json:"checkpointId"` + DomPath string `json:"domPath"` + MetadataPath string `json:"metadataPath"` +} + +// pump applies runner events to the build record. Unknown or malformed lines +// are logged (not trusted, not applied) so a noisy runner cannot corrupt state. +type pump struct { + store *Store + buildID string + logf func(string) + artifact string + + mu sync.Mutex + failures int +} + +func newPump(store *Store, buildID string, logf func(string)) *pump { + return &pump{store: store, buildID: buildID, logf: logf} +} + +func (p *pump) uploadFailures() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.failures +} + +func (p *pump) flush() {} + +// relArtifact converts a runner-reported absolute path into one relative to +// the build's artifact dir ("dom/dashboard.html"); anything that does not sit +// under dom/ or metadata/ is dropped. +func relArtifact(p string) string { + p = filepath.ToSlash(p) + for _, marker := range []string{"/dom/", "/metadata/"} { + if i := strings.LastIndex(p, marker); i >= 0 { + rel := p[i+1:] + if !strings.Contains(rel, "..") { + return rel + } + } + } + return "" +} + +func (p *pump) onLine(line string) { + var e event + if err := json.Unmarshal([]byte(line), &e); err != nil || e.Type == "" { + p.logf("[runner] " + line) + return + } + // Never log full event payloads: they may echo user data. + p.logf("[event] " + e.Type + " " + e.StepID + e.CheckpointID) + + _, _ = p.store.Update(p.buildID, func(b *Build) { + switch e.Type { + case "stepStarted": + b.CurrentStepID = e.StepID + if i := stepIndex(b, e.StepID); i >= 0 { + b.Steps[i].Status = StepRunning + } + case "stepFinished": + if i := stepIndex(b, e.StepID); i >= 0 { + if e.Status == "passed" { + b.Steps[i].Status = StepPassed + } else { + b.Steps[i].Status = StepFailed + b.Steps[i].Error = e.Message + b.Errors = append(b.Errors, RunError{StepID: e.StepID, Message: e.Message}) + } + b.Steps[i].DurationMs = e.DurationMs + } + case "checkpointCaptured": + if i := cpIndex(b, e.CheckpointID); i >= 0 { + b.Checkpoints[i].Status = CheckpointCaptured + b.Checkpoints[i].DomPath = relArtifact(e.DomPath) + b.Checkpoints[i].MetadataPath = relArtifact(e.MetadataPath) + } + case "checkpointUploaded": + if i := cpIndex(b, e.CheckpointID); i >= 0 { + b.Checkpoints[i].Status = CheckpointUploaded + } + case "checkpointUploadFailed": + p.mu.Lock() + p.failures++ + p.mu.Unlock() + if i := cpIndex(b, e.CheckpointID); i >= 0 { + b.Checkpoints[i].Status = CheckpointUploadFailed + b.Checkpoints[i].Error = e.Message + } + case "error": + b.Errors = append(b.Errors, RunError{Message: e.Message}) + } + }) +} + +func stepIndex(b *Build, id string) int { + for i := range b.Steps { + if b.Steps[i].StepID == id { + return i + } + } + return -1 +} + +func cpIndex(b *Build, id string) int { + for i := range b.Checkpoints { + if b.Checkpoints[i].CheckpointID == id { + return i + } + } + return -1 +} diff --git a/go-backend/internal/runs/service.go b/go-backend/internal/runs/service.go new file mode 100644 index 0000000..8874ca5 --- /dev/null +++ b/go-backend/internal/runs/service.go @@ -0,0 +1,404 @@ +package runs + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +var ( + ErrBusy = errors.New("a workflow run is already in progress (a cancelled or finished run may still be shutting down Percy; retry in a few seconds)") + ErrNoToken = errors.New("a Percy token is required to create a build") + ErrNotCancelled = errors.New("build is not running") +) + +// Percy is the slice of the existing Percy controller/client a run needs. +type Percy interface { + EnsureReady() error + Start(token string) error + Stop() error + BuildInfo() (id, url string, err error) + WaitIdle(timeout time.Duration) error +} + +// Runtime prepares the Playwright runtime and describes where things live. +type Runtime interface { + EnsureReady(progress func(string)) error + RunnerEntry() string + PlaywrightDir() string + ArtifactDir(buildID string) (string, error) + RequestPath(buildID string) (string, error) +} + +// Process starts the runner. Swapped out in tests. +type Process interface { + Wait() error + Kill() error +} + +type Launcher func(ctx context.Context, entry, requestPath, dir string, onLine func(string), onLog func(string)) (Process, error) + +type Config struct { + Workflows *workflow.Store + Builds *Store + Percy Percy + Runtime Runtime + Launch Launcher + PercyURL string + RunTimeout time.Duration + IdleTimeout time.Duration + // Busy reports whether another Percy user (the snapshot-queue finalize) is active. + Busy func() bool +} + +type Service struct { + cfg Config + + mu sync.Mutex + active string // build id currently running + cancels map[string]context.CancelFunc + wg sync.WaitGroup +} + +func NewService(cfg Config) *Service { + if cfg.Launch == nil { + cfg.Launch = ExecLauncher + } + if cfg.RunTimeout == 0 { + cfg.RunTimeout = 15 * time.Minute + } + if cfg.IdleTimeout == 0 { + cfg.IdleTimeout = 5 * time.Minute + } + return &Service{cfg: cfg, cancels: map[string]context.CancelFunc{}} +} + +// RecoverInterrupted finalizes builds a previous server process left running. +func (s *Service) RecoverInterrupted() { + ids, err := s.cfg.Builds.InterruptedBuilds() + if err != nil { + log.Printf("[runs] recovery scan failed: %v", err) + return + } + for _, id := range ids { + if _, err := s.cfg.Builds.Finalize(id, StatusFailed, "interrupted: server restarted while the run was in progress"); err != nil { + log.Printf("[runs] could not finalize %s: %v", id, err) + } + } +} + +// Active reports whether a workflow run is in progress. +func (s *Service) Active() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.active != "" +} + +// Wait blocks until all in-flight runs finish (used by tests and shutdown). +func (s *Service) Wait() { s.wg.Wait() } + +// Start validates, persists a queued build, then runs it in the background. +// A repeated idempotency key returns the existing build instead of a new one. +func (s *Service) Start(workflowID, token, idemKey string, secrets map[string]string) (*Build, bool, error) { + wf, err := s.cfg.Workflows.Get(workflowID) + if err != nil { + return nil, false, err + } + if existing, err := s.cfg.Builds.FindByIdempotencyKey(workflowID, idemKey); err != nil { + return nil, false, err + } else if existing != nil { + return existing, true, nil + } + if token == "" { + return nil, false, ErrNoToken + } + + s.mu.Lock() + if s.active != "" || (s.cfg.Busy != nil && s.cfg.Busy()) { + s.mu.Unlock() + return nil, false, ErrBusy + } + now := time.Now().UTC() + b := &Build{ + BuildID: NewBuildID(now), + WorkflowID: wf.WorkflowID, + WorkflowVersion: wf.Version, + WorkflowName: wf.Name, + Status: StatusQueued, + CreatedAt: now, + Steps: []StepResult{}, + Checkpoints: []Checkpoint{}, + Errors: []RunError{}, + IdempotencyKey: idemKey, + } + for _, st := range wf.Steps { + b.Steps = append(b.Steps, StepResult{StepID: st.ID, Type: st.Type, Status: StepPending}) + if st.Type == "snapshot" { + b.Checkpoints = append(b.Checkpoints, Checkpoint{CheckpointID: st.CheckpointID, Name: st.Name, Status: CheckpointPending}) + } + } + // Persist BEFORE starting execution, so a crash always leaves a record. + if err := s.cfg.Builds.Create(b); err != nil { + s.mu.Unlock() + return nil, false, err + } + ctx, cancel := context.WithTimeout(context.Background(), s.cfg.RunTimeout) + s.active = b.BuildID + s.cancels[b.BuildID] = cancel + s.wg.Add(1) + s.mu.Unlock() + + go func() { + defer s.wg.Done() + defer cancel() + s.execute(ctx, b.BuildID, wf, token, secrets) + s.mu.Lock() + s.active = "" + delete(s.cancels, b.BuildID) + s.mu.Unlock() + }() + return b, false, nil +} + +// Cancel stops a running build. The goroutine in execute finalizes it. +func (s *Service) Cancel(buildID string) error { + // Check the persisted state first. A cancelled/finished build can still be + // registered in s.cancels while its goroutine shuts Percy down; cancelling + // it again must not report success. + cur, err := s.cfg.Builds.Get(buildID) + if err != nil { + return err + } + if cur.Status.Terminal() { + return ErrNotCancelled + } + s.mu.Lock() + cancel, ok := s.cancels[buildID] + s.mu.Unlock() + if !ok { + return ErrNotCancelled + } + // Mark cancelled first so the final status is cancelled, not failed. + if _, err := s.cfg.Builds.Finalize(buildID, StatusCancelled, "cancelled by user"); err != nil { + return err + } + cancel() + return nil +} + +func (s *Service) fail(buildID, msg string) { + if _, err := s.cfg.Builds.Finalize(buildID, StatusFailed, msg); err != nil { + log.Printf("[runs] finalize %s: %v", buildID, err) + } +} + +func (s *Service) execute(ctx context.Context, buildID string, wf *workflow.Workflow, token string, secrets map[string]string) { + // Safety net: whatever happens (panic included) the build leaves "running". + defer func() { + if r := recover(); r != nil { + s.fail(buildID, fmt.Sprintf("internal error: %v", r)) + } + // no-op if already terminal + s.fail(buildID, "run ended without a final status") + }() + + logPath, artifactDir, err := s.prepareArtifacts(buildID) + if err != nil { + s.fail(buildID, "cannot prepare artifacts: "+err.Error()) + return + } + logFile, _ := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if logFile != nil { + defer logFile.Close() + } + logf := func(line string) { + if logFile != nil { + fmt.Fprintln(logFile, line) + } + } + + if err := s.cfg.Runtime.EnsureReady(func(msg string) { logf("[setup] " + msg) }); err != nil { + s.fail(buildID, err.Error()) + return + } + if err := s.cfg.Percy.EnsureReady(); err != nil { + s.fail(buildID, err.Error()) + return + } + if err := s.cfg.Percy.Start(token); err != nil { + s.fail(buildID, err.Error()) + return + } + defer func() { _ = s.cfg.Percy.Stop() }() + + if id, url, err := s.cfg.Percy.BuildInfo(); err == nil { + _, _ = s.cfg.Builds.Update(buildID, func(b *Build) { b.PercyBuildID, b.PercyBuildURL = id, url }) + } + + // Request file: workflow + secrets live only here (0600), removed after the run. + reqPath, err := s.cfg.Runtime.RequestPath(buildID) + if err != nil { + s.fail(buildID, err.Error()) + return + } + req := map[string]any{ + "buildId": buildID, + "workflow": wf, + "artifactDir": artifactDir, + "percyUrl": s.cfg.PercyURL, + "secrets": secrets, + } + data, _ := json.Marshal(req) + if err := os.WriteFile(reqPath, data, 0o600); err != nil { + s.fail(buildID, "cannot write runner request: "+err.Error()) + return + } + defer os.Remove(reqPath) + + _, _ = s.cfg.Builds.Update(buildID, func(b *Build) { + now := time.Now().UTC() + b.Status = StatusRunning + b.StartedAt = &now + }) + + pump := newPump(s.cfg.Builds, buildID, logf) + proc, err := s.cfg.Launch(ctx, s.cfg.Runtime.RunnerEntry(), reqPath, s.cfg.Runtime.PlaywrightDir(), pump.onLine, logf) + if err != nil { + s.fail(buildID, "cannot start runner: "+err.Error()) + return + } + waitErr := proc.Wait() + pump.flush() + + switch { + case ctx.Err() == context.DeadlineExceeded: + _ = proc.Kill() + s.fail(buildID, fmt.Sprintf("run timed out after %s", s.cfg.RunTimeout)) + return + case ctx.Err() != nil: // cancelled; Cancel() already finalized + return + } + + // Let Percy finish processing uploaded snapshots before reporting. + idleErr := s.cfg.Percy.WaitIdle(s.cfg.IdleTimeout) + + status, msg := classify(waitErr, pump, idleErr) + if _, err := s.cfg.Builds.Finalize(buildID, status, msg); err != nil { + log.Printf("[runs] finalize %s: %v", buildID, err) + } +} + +func classify(waitErr error, p *pump, idleErr error) (Status, string) { + if waitErr == nil { + if idleErr != nil { + return StatusPartial, "snapshots captured but Percy did not finish processing: " + idleErr.Error() + } + if p.uploadFailures() > 0 { + return StatusPartial, "" + } + return StatusPassed, "" + } + var ee *exec.ExitError + if errors.As(waitErr, &ee) { + switch ee.ExitCode() { + case 2: + return StatusPartial, "" + case 3: + return StatusFailed, "runner rejected the request as invalid" + case 1: + return StatusFailed, "" + } + return StatusFailed, fmt.Sprintf("runner crashed (exit %d)", ee.ExitCode()) + } + return StatusFailed, "runner error: " + waitErr.Error() +} + +func (s *Service) prepareArtifacts(buildID string) (logPath, artifactDir string, err error) { + artifactDir, err = s.cfg.Runtime.ArtifactDir(buildID) + if err != nil { + return "", "", err + } + for _, d := range []string{"dom", "metadata", "logs"} { + if err := os.MkdirAll(filepath.Join(artifactDir, d), 0o755); err != nil { + return "", "", err + } + } + return filepath.Join(artifactDir, "logs", "runner.log"), artifactDir, nil +} + +// ---- real process launcher ---- + +type execProc struct { + cmd *exec.Cmd + wg *sync.WaitGroup +} + +func (p *execProc) Wait() error { + p.wg.Wait() // drain stdout/stderr first + return p.cmd.Wait() +} + +func (p *execProc) Kill() error { + if p.cmd.Process == nil { + return nil + } + // kill the whole group so chromium children die with the runner + if err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL); err != nil { + return p.cmd.Process.Kill() + } + return nil +} + +// ExecLauncher runs `node --request ` (no shell, fixed argv). +func ExecLauncher(ctx context.Context, entry, requestPath, dir string, onLine func(string), onLog func(string)) (Process, error) { + cmd := exec.CommandContext(ctx, "node", entry, "--request", requestPath) + cmd.Dir = dir + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + return cmd.Process.Kill() + } + return nil + } + cmd.WaitDelay = 5 * time.Second + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + wg := &sync.WaitGroup{} + scan := func(r io.Reader, fn func(string)) { + defer wg.Done() + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64*1024), 4*1024*1024) + for sc.Scan() { + fn(sc.Text()) + } + } + wg.Add(2) + go scan(stdout, onLine) + go scan(stderr, func(l string) { onLog("[stderr] " + l) }) + return &execProc{cmd: cmd, wg: wg}, nil +} diff --git a/go-backend/internal/runs/service_test.go b/go-backend/internal/runs/service_test.go new file mode 100644 index 0000000..5885ff7 --- /dev/null +++ b/go-backend/internal/runs/service_test.go @@ -0,0 +1,470 @@ +package runs + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +// ---- fakes ---- + +type fakePercy struct { + mu sync.Mutex + started int + stopped int + idleErr error + startEr error +} + +func (f *fakePercy) EnsureReady() error { return nil } +func (f *fakePercy) Start(string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.started++ + return f.startEr +} +func (f *fakePercy) Stop() error { + f.mu.Lock() + defer f.mu.Unlock() + f.stopped++ + return nil +} +func (f *fakePercy) BuildInfo() (string, string, error) { return "999", "https://percy.io/b/999", nil } +func (f *fakePercy) WaitIdle(time.Duration) error { return f.idleErr } + +type fakeRuntime struct { + root string + setupErr error +} + +func (r *fakeRuntime) EnsureReady(func(string)) error { return r.setupErr } +func (r *fakeRuntime) RunnerEntry() string { return "runner.js" } +func (r *fakeRuntime) PlaywrightDir() string { return r.root } +func (r *fakeRuntime) ArtifactDir(id string) (string, error) { + if !ValidBuildID(id) { + return "", ErrInvalidID + } + return filepath.Join(r.root, "artifacts", id), nil +} +func (r *fakeRuntime) RequestPath(id string) (string, error) { + return filepath.Join(r.root, "artifacts", id, ".request.json"), nil +} + +type fakeProc struct { + lines []string + exit int + block chan struct{} // if set, Wait blocks until closed or ctx done + ctx context.Context + onStart func(requestPath string) +} + +func (p *fakeProc) Wait() error { + if p.block != nil { + select { + case <-p.block: + case <-p.ctx.Done(): + return p.ctx.Err() + } + } + if p.exit == 0 { + return nil + } + // produce a real *exec.ExitError with the wanted code + cmd := exec.Command("sh", "-c", "exit "+string(rune('0'+p.exit))) + return cmd.Run() +} +func (p *fakeProc) Kill() error { return nil } + +type harness struct { + svc *Service + builds *Store + wfs *workflow.Store + percy *fakePercy + rt *fakeRuntime + wf *workflow.Workflow + launch *launchCfg + root string + busy bool + reqSeen string +} + +type launchCfg struct { + mu sync.Mutex + lines []string + exit int + block chan struct{} + err error + req []byte +} + +func newHarness(t *testing.T) *harness { + t.Helper() + root := t.TempDir() + wfs, _ := workflow.NewStore(filepath.Join(root, "workflows")) + builds, _ := NewStore(filepath.Join(root, "builds")) + wf := &workflow.Workflow{ + Name: "Dash", SchemaVersion: 1, StartURL: "https://example.test/", + Settings: workflow.Settings{Viewport: workflow.Viewport{Width: 1280, Height: 800}}, + Steps: []workflow.Step{ + {ID: "step_001", Type: "fill", Target: &workflow.Target{Role: "textbox", Name: "Password"}, ValueRef: "pw"}, + {ID: "step_002", Type: "click", Target: &workflow.Target{Role: "button", Name: "Go"}}, + {ID: "step_003", Type: "snapshot", CheckpointID: "dashboard", Name: "Dashboard"}, + {ID: "step_004", Type: "snapshot", CheckpointID: "accounts", Name: "Accounts"}, + }, + } + if err := wfs.Create(wf); err != nil { + t.Fatal(err) + } + h := &harness{builds: builds, wfs: wfs, percy: &fakePercy{}, rt: &fakeRuntime{root: root}, wf: wf, launch: &launchCfg{}, root: root} + h.svc = NewService(Config{ + Workflows: wfs, Builds: builds, Percy: h.percy, Runtime: h.rt, PercyURL: "http://127.0.0.1:5338", + RunTimeout: 3 * time.Second, IdleTimeout: time.Second, + Busy: func() bool { return h.busy }, + Launch: func(ctx context.Context, entry, req, dir string, onLine, onLog func(string)) (Process, error) { + h.launch.mu.Lock() + defer h.launch.mu.Unlock() + if h.launch.err != nil { + return nil, h.launch.err + } + data, _ := os.ReadFile(req) // request file must exist while running + h.launch.req = data + for _, l := range h.launch.lines { + onLine(l) + } + return &fakeProc{exit: h.launch.exit, block: h.launch.block, ctx: ctx}, nil + }, + }) + return h +} + +func ev(m map[string]any) string { b, _ := json.Marshal(m); return string(b) } + +func happyLines(root string) []string { + return []string{ + ev(map[string]any{"type": "stepStarted", "stepId": "step_001"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_001", "status": "passed", "durationMs": 12}), + ev(map[string]any{"type": "stepStarted", "stepId": "step_002"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_002", "status": "passed"}), + ev(map[string]any{"type": "stepStarted", "stepId": "step_003"}), + ev(map[string]any{"type": "checkpointCaptured", "checkpointId": "dashboard", "domPath": root + "/artifacts/b/dom/dashboard.html", "metadataPath": root + "/artifacts/b/metadata/dashboard.json"}), + ev(map[string]any{"type": "checkpointUploaded", "checkpointId": "dashboard"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_003", "status": "passed"}), + ev(map[string]any{"type": "stepStarted", "stepId": "step_004"}), + ev(map[string]any{"type": "checkpointCaptured", "checkpointId": "accounts", "domPath": root + "/artifacts/b/dom/accounts.html", "metadataPath": root + "/artifacts/b/metadata/accounts.json"}), + ev(map[string]any{"type": "checkpointUploaded", "checkpointId": "accounts"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_004", "status": "passed"}), + } +} + +func (h *harness) startAndWait(t *testing.T, key string) *Build { + t.Helper() + b, _, err := h.svc.Start(h.wf.WorkflowID, "tok", key, map[string]string{"pw": "hunter2-SECRET"}) + if err != nil { + t.Fatal(err) + } + h.svc.Wait() + got, err := h.builds.Get(b.BuildID) + if err != nil { + t.Fatal(err) + } + return got +} + +// ---- tests ---- + +func TestHappyPathPassesAndRecordsVersionAndCheckpoints(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b := h.startAndWait(t, "") + + if b.Status != StatusPassed { + t.Fatalf("status = %s errors=%v", b.Status, b.Errors) + } + if b.WorkflowID != h.wf.WorkflowID || b.WorkflowVersion != h.wf.Version { + t.Fatalf("workflow ref wrong: %s v%d", b.WorkflowID, b.WorkflowVersion) + } + if b.StartedAt == nil || b.CompletedAt == nil { + t.Fatal("timestamps missing") + } + if b.PercyBuildID != "999" || b.PercyBuildURL == "" { + t.Fatalf("percy ref missing: %+v", b) + } + if len(b.Checkpoints) != 2 { + t.Fatalf("checkpoints = %d", len(b.Checkpoints)) + } + for _, c := range b.Checkpoints { + if c.Status != CheckpointUploaded { + t.Fatalf("checkpoint %s = %s", c.CheckpointID, c.Status) + } + // artifact-relative, never absolute + if filepath.IsAbs(c.DomPath) || !strings.HasPrefix(c.DomPath, "dom/") || !strings.HasPrefix(c.MetadataPath, "metadata/") { + t.Fatalf("bad paths: %q %q", c.DomPath, c.MetadataPath) + } + } + if b.Checkpoints[0].CheckpointID != "dashboard" || b.Checkpoints[1].CheckpointID != "accounts" { + t.Fatal("checkpoint order changed") + } + for _, s := range b.Steps { + if s.Status != StepPassed { + t.Fatalf("step %s = %s", s.StepID, s.Status) + } + } + if h.percy.started != 1 || h.percy.stopped != 1 { + t.Fatalf("percy start/stop = %d/%d", h.percy.started, h.percy.stopped) + } +} + +func TestBuildRecordPersistedBeforeRunnerStarts(t *testing.T) { + h := newHarness(t) + h.launch.block = make(chan struct{}) + b, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil) + if err != nil { + t.Fatal(err) + } + // returned immediately (202 semantics) and already on disk + if _, err := os.Stat(filepath.Join(h.root, "builds", b.BuildID+".json")); err != nil { + t.Fatalf("build file missing while run in progress: %v", err) + } + if b.Status != StatusQueued { + t.Fatalf("initial status = %s", b.Status) + } + close(h.launch.block) + h.svc.Wait() +} + +func TestSecretsNeverPersistedOrLogged(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b := h.startAndWait(t, "") + + // the runner did get the secret (via the 0600 request file)... + if !strings.Contains(string(h.launch.req), "hunter2-SECRET") { + t.Fatal("runner request should carry the secret") + } + // ...but it is removed afterwards and never lands in build JSON or logs + if _, err := os.Stat(filepath.Join(h.root, "artifacts", b.BuildID, ".request.json")); !os.IsNotExist(err) { + t.Fatalf("request file should be deleted, err=%v", err) + } + err := filepath.Walk(h.root, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + data, _ := os.ReadFile(p) + if strings.Contains(string(data), "hunter2-SECRET") { + t.Errorf("secret leaked into %s", p) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestIdempotencyKeyReturnsSameBuild(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b1, dup1, err := h.svc.Start(h.wf.WorkflowID, "tok", "key-1", nil) + if err != nil || dup1 { + t.Fatalf("first: %v dup=%v", err, dup1) + } + h.svc.Wait() + b2, dup2, err := h.svc.Start(h.wf.WorkflowID, "tok", "key-1", nil) + if err != nil || !dup2 || b2.BuildID != b1.BuildID { + t.Fatalf("retry should return existing build: %v dup=%v %s vs %s", err, dup2, b2.BuildID, b1.BuildID) + } + list, _ := h.builds.List() + if len(list) != 1 { + t.Fatalf("builds = %d, want 1", len(list)) + } +} + +func TestConcurrentRunsRejected(t *testing.T) { + h := newHarness(t) + h.launch.block = make(chan struct{}) + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); err != nil { + t.Fatal(err) + } + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); !errors.Is(err, ErrBusy) { + t.Fatalf("want ErrBusy, got %v", err) + } + close(h.launch.block) + h.svc.Wait() + list, _ := h.builds.List() + if len(list) != 1 { + t.Fatalf("rejected run must not leave a build: %d", len(list)) + } +} + +func TestBusyWhenSnapshotFinalizeRunning(t *testing.T) { + h := newHarness(t) + h.busy = true + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); !errors.Is(err, ErrBusy) { + t.Fatalf("want ErrBusy, got %v", err) + } +} + +func TestMissingTokenAndMissingWorkflow(t *testing.T) { + h := newHarness(t) + if _, _, err := h.svc.Start(h.wf.WorkflowID, "", "", nil); !errors.Is(err, ErrNoToken) { + t.Fatalf("want ErrNoToken, got %v", err) + } + if _, _, err := h.svc.Start("wf_missing", "tok", "", nil); !errors.Is(err, workflow.ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } + if _, _, err := h.svc.Start("../x", "tok", "", nil); !errors.Is(err, workflow.ErrInvalidID) { + t.Fatalf("want ErrInvalidID, got %v", err) + } +} + +func TestStepFailureFailsBuildAndNamesTheStep(t *testing.T) { + h := newHarness(t) + h.launch.exit = 1 + h.launch.lines = []string{ + ev(map[string]any{"type": "stepStarted", "stepId": "step_001"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_001", "status": "passed"}), + ev(map[string]any{"type": "stepStarted", "stepId": "step_002"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_002", "status": "failed", "message": `locator role=button[name="Go"] matched 2 elements`}), + } + b := h.startAndWait(t, "") + if b.Status != StatusFailed { + t.Fatalf("status = %s", b.Status) + } + if len(b.Errors) == 0 || b.Errors[0].StepID != "step_002" || !strings.Contains(b.Errors[0].Message, "matched 2") { + t.Fatalf("errors = %+v", b.Errors) + } + // later steps never ran: skipped, not left pending/running + if b.Steps[2].Status != StepSkipped || b.Steps[3].Status != StepSkipped { + t.Fatalf("remaining steps = %s %s", b.Steps[2].Status, b.Steps[3].Status) + } + for _, c := range b.Checkpoints { + if c.Status == CheckpointPending { + t.Fatalf("checkpoint %s left pending", c.CheckpointID) + } + } + if h.percy.stopped != 1 { + t.Fatal("percy must be stopped after a failed run") + } +} + +func TestUploadFailureMakesBuildPartialAndKeepsDom(t *testing.T) { + h := newHarness(t) + h.launch.exit = 2 + h.launch.lines = []string{ + ev(map[string]any{"type": "stepStarted", "stepId": "step_003"}), + ev(map[string]any{"type": "checkpointCaptured", "checkpointId": "dashboard", "domPath": h.root + "/x/dom/dashboard.html", "metadataPath": h.root + "/x/metadata/dashboard.json"}), + ev(map[string]any{"type": "checkpointUploadFailed", "checkpointId": "dashboard", "message": "percy returned 500"}), + ev(map[string]any{"type": "stepFinished", "stepId": "step_003", "status": "passed"}), + } + b := h.startAndWait(t, "") + if b.Status != StatusPartial { + t.Fatalf("status = %s", b.Status) + } + c := b.Checkpoints[0] + if c.Status != CheckpointUploadFailed || c.DomPath != "dom/dashboard.html" || !strings.Contains(c.Error, "500") { + t.Fatalf("checkpoint = %+v", c) + } +} + +func TestPercyNotIdleMakesBuildPartial(t *testing.T) { + h := newHarness(t) + h.percy.idleErr = errors.New("timed out waiting for Percy") + h.launch.lines = happyLines(h.root) + b := h.startAndWait(t, "") + if b.Status != StatusPartial { + t.Fatalf("status = %s", b.Status) + } +} + +func TestRunnerCrashDoesNotLeaveBuildRunning(t *testing.T) { + h := newHarness(t) + h.launch.exit = 9 // e.g. killed / segfault + h.launch.lines = []string{ev(map[string]any{"type": "stepStarted", "stepId": "step_001"})} + b := h.startAndWait(t, "") + if b.Status != StatusFailed || len(b.Errors) == 0 || !strings.Contains(b.Errors[len(b.Errors)-1].Message, "crashed") { + t.Fatalf("status=%s errors=%+v", b.Status, b.Errors) + } + if b.Steps[0].Status != StepSkipped { + t.Fatalf("in-flight step should be finalized, got %s", b.Steps[0].Status) + } +} + +func TestLaunchFailureAndSetupFailureFinalize(t *testing.T) { + h := newHarness(t) + h.launch.err = errors.New("node not found") + if b := h.startAndWait(t, ""); b.Status != StatusFailed || !strings.Contains(b.Errors[0].Message, "node not found") { + t.Fatalf("launch failure: %s %+v", b.Status, b.Errors) + } + h2 := newHarness(t) + h2.rt.setupErr = errors.New("npm install failed") + if b := h2.startAndWait(t, ""); b.Status != StatusFailed || !strings.Contains(b.Errors[0].Message, "npm install") { + t.Fatalf("setup failure: %s %+v", b.Status, b.Errors) + } + h3 := newHarness(t) + h3.percy.startEr = errors.New("percy exited before it was ready") + if b := h3.startAndWait(t, ""); b.Status != StatusFailed { + t.Fatalf("percy start failure: %s", b.Status) + } +} + +func TestTimeoutFinalizesAsFailed(t *testing.T) { + h := newHarness(t) + h.svc.cfg.RunTimeout = 150 * time.Millisecond + h.launch.block = make(chan struct{}) // never released + b := h.startAndWait(t, "") + if b.Status != StatusFailed || !strings.Contains(b.Errors[len(b.Errors)-1].Message, "timed out") { + t.Fatalf("status=%s errors=%+v", b.Status, b.Errors) + } +} + +func TestCancelFinalizesAsCancelledAndFreesTheSlot(t *testing.T) { + h := newHarness(t) + h.launch.block = make(chan struct{}) + b, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil) + if err != nil { + t.Fatal(err) + } + // wait until the runner is actually running + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cur, _ := h.builds.Get(b.BuildID); cur != nil && cur.Status == StatusRunning { + break + } + time.Sleep(10 * time.Millisecond) + } + if err := h.svc.Cancel(b.BuildID); err != nil { + t.Fatal(err) + } + h.svc.Wait() + got, _ := h.builds.Get(b.BuildID) + if got.Status != StatusCancelled { + t.Fatalf("status = %s", got.Status) + } + if h.percy.stopped != 1 { + t.Fatal("percy not stopped after cancel") + } + // slot is free again + h.launch.block = nil + h.launch.lines = happyLines(h.root) + if _, _, err := h.svc.Start(h.wf.WorkflowID, "tok", "", nil); err != nil { + t.Fatalf("new run after cancel: %v", err) + } + h.svc.Wait() + if err := h.svc.Cancel(b.BuildID); !errors.Is(err, ErrNotCancelled) { + t.Fatalf("cancelling a finished build: %v", err) + } +} + +func TestLateRunnerEventsCannotOverwriteTerminalState(t *testing.T) { + h := newHarness(t) + h.launch.lines = happyLines(h.root) + b := h.startAndWait(t, "") + _, err := h.builds.Update(b.BuildID, func( \ No newline at end of file diff --git a/go-backend/internal/runs/store.go b/go-backend/internal/runs/store.go new file mode 100644 index 0000000..bb10170 --- /dev/null +++ b/go-backend/internal/runs/store.go @@ -0,0 +1,217 @@ +package runs + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/workflow" +) + +var ( + ErrNotFound = errors.New("build not found") + ErrInvalidID = errors.New("invalid build id") + + buildIDRe = regexp.MustCompile(`^build_[0-9]{8}_[a-z0-9]{1,32}$`) +) + +func ValidBuildID(id string) bool { return buildIDRe.MatchString(id) } + +// NewBuildID returns a unique, filesystem-safe ID like build_20261014_a1b2c3d4. +func NewBuildID(now time.Time) string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return fmt.Sprintf("build_%s_%s", now.UTC().Format("20060102"), hex.EncodeToString(b)) +} + +// Store keeps one JSON file per build. All mutation goes through Update so +// concurrent writers (runner event pump, cancel, crash handler) never lose +// each other's changes. +type Store struct { + dir string + mu sync.Mutex +} + +func NewStore(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + return &Store{dir: dir}, nil +} + +func (s *Store) path(id string) (string, error) { + if !ValidBuildID(id) { + return "", ErrInvalidID + } + return filepath.Join(s.dir, id+".json"), nil +} + +func (s *Store) Create(b *Build) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.writeLocked(b) +} + +func (s *Store) Get(id string) (*Build, error) { + if !ValidBuildID(id) { + return nil, ErrInvalidID + } + s.mu.Lock() + defer s.mu.Unlock() + return s.readLocked(id) +} + +// Update applies fn to the stored build under the lock and persists atomically. +// Once a build is terminal, further updates are ignored so a late runner event +// cannot overwrite a cancelled/failed final state. +func (s *Store) Update(id string, fn func(b *Build)) (*Build, error) { + if !ValidBuildID(id) { + return nil, ErrInvalidID + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := s.readLocked(id) + if err != nil { + return nil, err + } + if b.Status.Terminal() { + return b, nil + } + fn(b) + if err := s.writeLocked(b); err != nil { + return nil, err + } + return b, nil +} + +// Finalize sets a terminal status exactly once (no-op if already terminal). +func (s *Store) Finalize(id string, status Status, errMsg string) (*Build, error) { + return s.Update(id, func(b *Build) { + now := time.Now().UTC() + b.Status = status + b.CompletedAt = &now + b.CurrentStepID = "" + if errMsg != "" { + b.Errors = append(b.Errors, RunError{Message: errMsg}) + } + for i := range b.Steps { + if b.Steps[i].Status == StepRunning || b.Steps[i].Status == StepPending { + b.Steps[i].Status = StepSkipped + } + } + for i := range b.Checkpoints { + if b.Checkpoints[i].Status == CheckpointPending { + b.Checkpoints[i].Status = CheckpointFailed + } + } + }) +} + +func (s *Store) List() ([]Build, error) { + s.mu.Lock() + defer s.mu.Unlock() + entries, err := os.ReadDir(s.dir) + if errors.Is(err, os.ErrNotExist) { + // The builds folder can be removed while the server runs (cleanup or + // reinstall). That means no builds yet, not a failure. + return []Build{}, nil + } + if err != nil { + return nil, err + } + out := []Build{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".json") { + continue + } + id := strings.TrimSuffix(name, ".json") + if !ValidBuildID(id) { + continue + } + b, err := s.readLocked(id) + if err != nil { + continue + } + out = append(out, *b) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out, nil +} + +// FindByIdempotencyKey returns an existing build created with the same key +// for the same workflow, so client retries never create a second build. +func (s *Store) FindByIdempotencyKey(workflowID, key string) (*Build, error) { + if key == "" { + return nil, nil + } + list, err := s.List() + if err != nil { + return nil, err + } + for i := range list { + if list[i].IdempotencyKey == key && list[i].WorkflowID == workflowID { + return &list[i], nil + } + } + return nil, nil +} + +// InterruptedBuilds returns non-terminal builds (left behind by a crash). +func (s *Store) InterruptedBuilds() ([]string, error) { + list, err := s.List() + if err != nil { + return nil, err + } + var ids []string + for _, b := range list { + if !b.Status.Terminal() { + ids = append(ids, b.BuildID) + } + } + return ids, nil +} + +func (s *Store) readLocked(id string) (*Build, error) { + p, err := s.path(id) + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + var b Build + if err := json.Unmarshal(data, &b); err != nil { + return nil, fmt.Errorf("build %s is corrupt: %w", id, err) + } + return &b, nil +} + +func (s *Store) writeLocked(b *Build) error { + p, err := s.path(b.BuildID) + if err != nil { + return err + } + data, err := json.MarshalIndent(b, "", " ") + if err != nil { + return err + } + // Recreate the builds folder if it was removed while the server ran. + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return err + } + return workflow.WriteFileAtomic(p, data) +} diff --git a/go-backend/internal/runtime/dir.go b/go-backend/internal/runtime/dir.go new file mode 100644 index 0000000..2261eb3 --- /dev/null +++ b/go-backend/internal/runtime/dir.go @@ -0,0 +1,68 @@ +// Package runtime resolves the server-local runtime folder shared by the Percy +// CLI, the Playwright runner, and workflow/build storage. +package runtime + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ensureModuleType sets "type": "module" in a package.json so the runner's +// ESM sources load. No-op when already set. +func ensureModuleType(pkgPath string) error { + data, err := os.ReadFile(pkgPath) + if err != nil { + return err + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return err + } + if m["type"] == "module" { + return nil + } + m["type"] = "module" + out, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(pkgPath, out, 0o644) +} + +// Dir describes the /runtime folder. +type Dir struct { + Root string +} + +// NewDir resolves the runtime folder next to the server executable +// (same location percy.NewBinary uses). +func NewDir() *Dir { + exePath, err := os.Executable() + if err != nil { + panic(fmt.Sprintf("failed to determine server location: %v", err)) + } + return &Dir{Root: filepath.Join(filepath.Dir(exePath), "runtime")} +} + +// NewDirAt builds a Dir rooted at root (used by tests). +func NewDirAt(root string) *Dir { return &Dir{Root: root} } + +func (d *Dir) Percy() string { return filepath.Join(d.Root, "percy") } +func (d *Dir) Playwright() string { return filepath.Join(d.Root, "playwright") } +func (d *Dir) Workflows() string { return filepath.Join(d.Root, "workflows") } +func (d *Dir) Builds() string { return filepath.Join(d.Root, "builds") } +func (d *Dir) Artifacts() string { return filepath.Join(d.Root, "artifacts") } + +// Resolve joins parts under the runtime root and guarantees the result stays +// inside it. +func (d *Dir) Resolve(parts ...string) (string, error) { + root := filepath.Clean(d.Root) + p := filepath.Clean(filepath.Join(append([]string{root}, parts...)...)) + if p != root && !strings.HasPrefix(p, root+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes runtime directory") + } + return p, nil +} diff --git a/go-backend/internal/runtime/embed.go b/go-backend/internal/runtime/embed.go new file mode 100644 index 0000000..7ffddb1 --- /dev/null +++ b/go-backend/internal/runtime/embed.go @@ -0,0 +1,38 @@ +package runtime + +import ( + "embed" + "io/fs" + "os" + "path/filepath" +) + +// Only the runtime sources are embedded; fixtures and node:test files are not. +// +//go:embed runnerjs/index.js runnerjs/src/*.js +var runnerFS embed.FS + +// ExtractRunner writes the embedded runner sources to /runner/, +// overwriting previous copies so upgrades take effect. +func (d *Dir) ExtractRunner() error { + return fs.WalkDir(runnerFS, "runnerjs", func(path string, e fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel("runnerjs", path) + dest := filepath.Join(d.Playwright(), "runner", rel) + if e.IsDir() { + return os.MkdirAll(dest, 0o755) + } + data, err := runnerFS.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(dest, data, 0o644) + }) +} + +// RunnerEntry is the path of the extracted runner entry script. +func (d *Dir) RunnerEntry() string { + return filepath.Join(d.Playwright(), "runner", "index.js") +} diff --git a/go-backend/internal/runtime/installer_test.go b/go-backend/internal/runtime/installer_test.go new file mode 100644 index 0000000..31034e1 --- /dev/null +++ b/go-backend/internal/runtime/installer_test.go @@ -0,0 +1,37 @@ +package runtime + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// Heavy: downloads playwright + chromium. Run with PW_INSTALL_TEST=. +// The directory is kept so the runner PoC test can be executed against it. +func TestEnsureReadyInstallsPlaywright(t *testing.T) { + root := os.Getenv("PW_INSTALL_TEST") + if root == "" { + t.Skip("set PW_INSTALL_TEST= to run the real installer") + } + d := NewDirAt(root) + if err := NewPlaywrightInstaller(d).EnsureReady(func(s string) { t.Log(s) }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(d.RunnerEntry()); err != nil { + t.Fatal(err) + } + // copy PoC test + fixture next to the extracted runner and execute it + src := filepath.Join("runnerjs", "test") + dst := filepath.Join(d.Playwright(), "runner", "test") + if out, err := exec.Command("cp", "-R", src, dst).CombinedOutput(); err != nil { + t.Fatalf("copy tests: %v %s", err, out) + } + cmd := exec.Command("node", "--test", filepath.Join(dst, "poc.test.js")) + cmd.Dir = d.Playwright() + out, err := cmd.CombinedOutput() + t.Log(string(out)) + if err != nil { + t.Fatalf("runner PoC failed: %v", err) + } +} diff --git a/go-backend/internal/runtime/playwright_installer.go b/go-backend/internal/runtime/playwright_installer.go new file mode 100644 index 0000000..0f78272 --- /dev/null +++ b/go-backend/internal/runtime/playwright_installer.go @@ -0,0 +1,93 @@ +package runtime + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" +) + +// pinned so replay behaviour is stable; bump deliberately. +const playwrightVersion = "1.49.1" + +type PlaywrightInstaller struct { + dir *Dir +} + +func NewPlaywrightInstaller(dir *Dir) *PlaywrightInstaller { + return &PlaywrightInstaller{dir: dir} +} + +func (i *PlaywrightInstaller) installed() bool { + _, err := os.Stat(filepath.Join(i.dir.Playwright(), "node_modules", "playwright", "package.json")) + return err == nil +} + +func (i *PlaywrightInstaller) browserMarker() string { + return filepath.Join(i.dir.Playwright(), ".chromium-installed") +} + +// EnsureReady installs Playwright + chromium once into runtime/playwright and +// always refreshes the extracted runner sources. progress may be nil. +func (i *PlaywrightInstaller) EnsureReady(progress func(string)) error { + say := func(s string) { + log.Printf("[playwright] %s", s) + if progress != nil { + progress(s) + } + } + + if _, err := exec.LookPath("node"); err != nil { + return fmt.Errorf("Node.js is required to run workflows. Install Node.js and try again") + } + if _, err := exec.LookPath("npm"); err != nil { + return fmt.Errorf("npm is required to install Playwright. Install npm and try again") + } + + if err := os.MkdirAll(i.dir.Playwright(), 0o755); err != nil { + return fmt.Errorf("failed to create Playwright runtime directory: %w", err) + } + + if !i.installed() { + say("initializing npm project") + if err := i.run("npm", "init", "-y"); err != nil { + return err + } + say("installing playwright@" + playwrightVersion) + if err := i.run("npm", "install", "--save-exact", "playwright@"+playwrightVersion); err != nil { + return err + } + } + + if _, err := os.Stat(i.browserMarker()); err != nil { + say("downloading chromium (first run only)") + if err := i.run("npx", "playwright", "install", "chromium"); err != nil { + return err + } + if err := os.WriteFile(i.browserMarker(), []byte(playwrightVersion), 0o644); err != nil { + return err + } + } + + if err := i.dir.ExtractRunner(); err != nil { + return fmt.Errorf("failed to extract workflow runner: %w", err) + } + // runner uses ESM + pj := filepath.Join(i.dir.Playwright(), "package.json") + if err := ensureModuleType(pj); err != nil { + return err + } + say("playwright runtime ready") + return nil +} + +func (i *PlaywrightInstaller) run(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = i.dir.Playwright() + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %v failed: %w\n%s", name, args, err, string(out)) + } + return nil +} diff --git a/go-backend/internal/runtime/runnerjs/index.js b/go-backend/internal/runtime/runnerjs/index.js new file mode 100644 index 0000000..8f8e60e --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/index.js @@ -0,0 +1,30 @@ +// Workflow runner entry. Invoked by the Go backend: +// node index.js --request +// Emits newline-delimited JSON events on stdout. +// Exit codes: 0 passed, 1 failed, 2 partial, 3 invalid input. +import { readFile } from 'node:fs/promises'; +import { emit } from './src/events.js'; +import { runWorkflow } from './src/run.js'; + +async function main() { + const i = process.argv.indexOf('--request'); + if (i === -1 || !process.argv[i + 1]) { + emit({ type: 'error', message: 'missing --request ' }); + return 3; + } + let request; + try { + request = JSON.parse(await readFile(process.argv[i + 1], 'utf8')); + } catch (e) { + emit({ type: 'error', message: `invalid request file: ${e.message}` }); + return 3; + } + return runWorkflow(request); +} + +main() + .then((code) => process.exit(code)) + .catch((e) => { + emit({ type: 'error', message: `runner crashed: ${e.message}` }); + process.exit(1); + }); diff --git a/go-backend/internal/runtime/runnerjs/src/capture.js b/go-backend/internal/runtime/runnerjs/src/capture.js new file mode 100644 index 0000000..1a30375 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/src/capture.js @@ -0,0 +1,77 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const SAFE = /^[A-Za-z0-9_-]+$/; + +const MIN_SECRET_LEN = 4; // shorter values would corrupt ordinary markup if replaced +const REDACTED = '[REDACTED]'; + +// Removes every resolved secret value from serialized HTML, wherever the page +// echoed it (attributes, text, scripts). Longest first so a secret that +// contains another is replaced whole. +export function redactSecrets(html, secrets) { + const values = Object.values(secrets ?? {}) + .filter((v) => typeof v === 'string' && v.length >= MIN_SECRET_LEN) + .sort((a, b) => b.length - a.length); + let out = html; + for (const v of values) out = out.split(v).join(REDACTED); + return out; +} + +// Serializes the live main document (iframes / shadow DOM are not captured). +// Password-like input values are stripped, then every secret the runner typed +// is scrubbed by value, before anything is written to disk or sent to Percy. +export async function serializeDom(page, secrets) { + const html = await serializeRaw(page); + return redactSecrets(html, secrets); +} + +async function serializeRaw(page) { + return page.evaluate(() => { + const clone = document.documentElement.cloneNode(true); + clone + .querySelectorAll('input[type="password"], input[autocomplete*="password"], input[autocomplete*="cc-"]') + .forEach((el) => { + el.removeAttribute('value'); + }); + return '' + clone.outerHTML; + }); +} + +// Best-effort DOM dump for a failed step (kept for debugging). Same redaction +// as checkpoint DOM; the step id is validated before it becomes a file name. +export async function saveFailureDom(page, artifactDir, stepId, secrets) { + if (!SAFE.test(stepId)) return; + const dir = path.join(artifactDir, 'dom'); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, `failure_${stepId}.html`), await serializeDom(page, secrets)); +} + +// Writes dom/.html and metadata/.json inside artifactDir. +export async function saveCheckpoint(page, { artifactDir, checkpointId, buildId, workflowVersion, viewport }, dom) { + if (!SAFE.test(checkpointId)) throw new Error(`unsafe checkpointId: ${checkpointId}`); + const domDir = path.join(artifactDir, 'dom'); + const metaDir = path.join(artifactDir, 'metadata'); + await mkdir(domDir, { recursive: true }); + await mkdir(metaDir, { recursive: true }); + const domPath = path.join(domDir, `${checkpointId}.html`); + const metadataPath = path.join(metaDir, `${checkpointId}.json`); + await writeFile(domPath, dom); + await writeFile( + metadataPath, + JSON.stringify( + { + checkpointId, + buildId, + workflowVersion, + url: page.url(), + title: await page.title(), + viewport, + capturedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + return { domPath, metadataPath }; +} diff --git a/go-backend/internal/runtime/runnerjs/src/events.js b/go-backend/internal/runtime/runnerjs/src/events.js new file mode 100644 index 0000000..177d982 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/src/events.js @@ -0,0 +1,4 @@ +// One JSON object per line on stdout. Never include secret values. +export function emit(event) { + process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n'); +} diff --git a/go-backend/internal/runtime/runnerjs/src/locate.js b/go-backend/internal/runtime/runnerjs/src/locate.js new file mode 100644 index 0000000..38db320 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/src/locate.js @@ -0,0 +1,114 @@ +// Locator resolution. Order of preference matches the recorder: +// test id, role+name, CSS, text. Recorded fallbacks are tried in order. +// All strategies are polled together; an ambiguous match is narrowed to the +// visible elements before it is treated as an error. + +export class LocatorError extends Error { + constructor(message, kind) { + super(message); + this.kind = kind; // 'missing' | 'ambiguous' + } +} + +export function describe(t) { + if (!t) return '(no target)'; + if (t.testId) return `testId=${t.testId}`; + if (t.role && t.name) return `role=${t.role}[name="${t.name}"]`; + if (t.css) return `css=${t.css}`; + if (t.text) return `text="${t.text}"`; + return '(empty target)'; +} + +function build(page, t) { + if (t.testId) return page.getByTestId(t.testId); + if (t.role && t.name) return page.getByRole(t.role, { name: t.name, exact: true }); + if (t.role) return page.getByRole(t.role); + if (t.css) return page.locator(t.css); + if (t.text) return page.getByText(t.text, { exact: true }); + return null; +} + +function usable(t) { + return Boolean(t.testId || t.role || t.css || t.text); +} + +function candidates(target) { + const { fallbacks, ...primary } = target; + return [primary, ...(fallbacks ?? [])].filter(usable); +} + +/** + * Locator for waitFor hidden/detached: the element may legitimately not exist, + * so there is no existence wait and no "exactly one" requirement. Uses the + * primary strategy only. + */ +export function resolveLoose(page, target) { + const { fallbacks, ...primary } = target; + const loc = build(page, primary); + if (!loc) throw new LocatorError('step has no usable locator', 'missing'); + return loc; +} + +const POLL_MS = 150; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * Looks at one candidate right now. Returns {loc, n} where loc is a locator + * for exactly one element (or null) and n is the raw match count. When a + * candidate matches several elements, narrows to the visible ones; if exactly + * one is visible that is unambiguous enough to act on. + */ +async function probe(page, c) { + const loc = build(page, c); + if (!loc) return { loc: null, n: 0 }; + let n; + try { + n = await loc.count(); + } catch { + return { loc: null, n: 0 }; // e.g. invalid selector: treat as not found + } + if (n === 1) return { loc, n }; + if (n > 1) { + const visible = loc.filter({ visible: true }); + const v = await visible.count().catch(() => 0); + if (v === 1) return { loc: visible, n, visibleOnly: true }; + return { loc: null, n, visibleCount: v }; + } + return { loc: null, n: 0 }; +} + +/** + * Resolves `target` to one element. Every recorded strategy (primary and + * fallbacks) is re-checked on each poll until the timeout, so a page that + * renders late, or a primary locator that stops matching, still resolves via + * another strategy. Candidates are ranked: the highest-priority one that is + * unique (or unique among visible elements) wins. If nothing is ever unique + * the step fails safely rather than acting on an arbitrary element. Throws + * LocatorError listing everything that was tried. + */ +export async function resolve(page, target, timeoutMs) { + const list = candidates(target); + if (list.length === 0) throw new LocatorError('step has no usable locator', 'missing'); + + const deadline = Date.now() + timeoutMs; + let last = []; + for (;;) { + const results = []; + for (const c of list) results.push(await probe(page, c)); + last = results; + const hit = results.find((r) => r.loc); + if (hit) return hit.loc; + if (Date.now() >= deadline) break; + await sleep(POLL_MS); + } + + // Nothing was unique across ALL strategies: refuse to act on an arbitrary + // element. The error lists every strategy and its match count. + const tried = list.map((c, i) => { + const r = last[i]; + if (r?.n > 1) return `${describe(c)} (matched ${r.n} elements)`; + return `${describe(c)} (not found)`; + }); + const anyAmbiguous = last.some((r) => r?.n > 1); + throw new LocatorError(`no element found. Tried: ${tried.join('; ')}`, anyAmbiguous ? 'ambiguous' : 'missing'); +} diff --git a/go-backend/internal/runtime/runnerjs/src/percy.js b/go-backend/internal/runtime/runnerjs/src/percy.js new file mode 100644 index 0000000..63a3c70 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/src/percy.js @@ -0,0 +1,18 @@ +// Posts a DOM snapshot to the Percy CLI local server started by the Go backend +// (percy exec:start). Same payload shape as go-backend/internal/percy/client.go. +export async function postSnapshot(percyUrl, { name, url, dom, widths, minHeight, scope, enableJavaScript, percyCSS }) { + const body = { name, url, domSnapshot: dom }; + if (widths) body.widths = widths; + if (minHeight) body.minHeight = minHeight; + if (scope) body.scope = scope; + if (enableJavaScript) body.enableJavaScript = true; + if (percyCSS) body.percyCSS = percyCSS; + const res = await fetch(`${percyUrl}/percy/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`percy returned ${res.status}: ${(await res.text()).slice(0, 300)}`); + } +} diff --git a/go-backend/internal/runtime/runnerjs/src/run.js b/go-backend/internal/runtime/runnerjs/src/run.js new file mode 100644 index 0000000..bdba892 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/src/run.js @@ -0,0 +1,124 @@ +import { chromium } from 'playwright'; +import { emit } from './events.js'; +import { serializeDom, saveCheckpoint, saveFailureDom } from './capture.js'; +import { postSnapshot } from './percy.js'; +import { executors, navigate } from './steps.js'; + +// Exit codes (read by the Go backend): 0 passed, 1 failed, 2 partial, 3 invalid input. +export const EXIT = { PASSED: 0, FAILED: 1, PARTIAL: 2, INVALID: 3 }; + +function validate(req) { + const wf = req?.workflow; + if (!req?.buildId || !req?.artifactDir) return 'request needs buildId and artifactDir'; + if (!wf || !Array.isArray(wf.steps) || wf.steps.length === 0) return 'workflow has no steps'; + try { + const u = new URL(wf.startUrl); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return 'startUrl must be http(s)'; + } catch { + return 'startUrl is invalid'; + } + for (const s of wf.steps) { + if (s.type !== 'snapshot' && !executors[s.type]) return `unsupported step type: ${s.type}`; + } + return null; +} + +async function doSnapshot(page, step, req, state) { + const { buildId, artifactDir, percyUrl, workflow } = req; + // Intentional wait so late content settles before the DOM is captured. + if (step.waitMs > 0) await page.waitForTimeout(step.waitMs); + const dom = await serializeDom(page, req.secrets); + const paths = await saveCheckpoint( + page, + { + artifactDir, + checkpointId: step.checkpointId, + buildId, + workflowVersion: workflow.version, + viewport: workflow.settings?.viewport, + }, + dom, + ); + emit({ type: 'checkpointCaptured', checkpointId: step.checkpointId, ...paths }); + + if (!percyUrl) return; + // DOM capture and Percy upload are separate: a failed upload keeps the DOM + // artifact and downgrades the build to "partial". + try { + await postSnapshot(percyUrl, { + name: step.name, + url: page.url(), + dom, + widths: step.options?.widths?.length + ? step.options.widths + : workflow.settings?.viewport + ? [workflow.settings.viewport.width] + : undefined, + minHeight: step.options?.minHeight || workflow.settings?.viewport?.height, + scope: step.options?.scope, + enableJavaScript: step.options?.enableJavaScript, + percyCSS: step.options?.percyCSS, + }); + emit({ type: 'checkpointUploaded', checkpointId: step.checkpointId }); + } catch (e) { + state.uploadFailed++; + emit({ type: 'checkpointUploadFailed', checkpointId: step.checkpointId, message: e.message }); + } +} + +export async function runWorkflow(req) { + const problem = validate(req); + if (problem) { + emit({ type: 'error', message: problem }); + return EXIT.INVALID; + } + const { workflow, secrets } = req; + const settings = workflow.settings ?? {}; + const viewport = settings.viewport ?? { width: 1280, height: 800 }; + const state = { uploadFailed: 0 }; + + const browser = await chromium.launch(); + let failed = false; + try { + const context = await browser.newContext({ viewport }); + const page = await context.newPage(); + page.setDefaultTimeout(settings.defaultTimeoutMs || 10000); + + try { + await navigate(page, workflow.startUrl, settings.defaultTimeoutMs); + } catch (e) { + emit({ type: 'error', message: `could not open startUrl: ${e.message}` }); + return EXIT.FAILED; + } + + const ctx = { settings, secrets }; + for (const step of workflow.steps) { + emit({ type: 'stepStarted', stepId: step.id }); + const t0 = Date.now(); + try { + if (step.type === 'snapshot') { + await doSnapshot(page, step, req, state); + } else { + await executors[step.type](page, step, ctx); + } + emit({ type: 'stepFinished', stepId: step.id, status: 'passed', durationMs: Date.now() - t0 }); + } catch (e) { + failed = true; + // keep debugging evidence for failed runs + await saveFailureDom(page, req.artifactDir, step.id, secrets).catch(() => {}); + emit({ + type: 'stepFinished', + stepId: step.id, + status: 'failed', + durationMs: Date.now() - t0, + message: String(e.message || e).split('\n')[0].slice(0, 500), + }); + break; // stop on critical failure + } + } + } finally { + await browser.close(); + } + if (failed) return EXIT.FAILED; + return state.uploadFailed ? EXIT.PARTIAL : EXIT.PASSED; +} diff --git a/go-backend/internal/runtime/runnerjs/src/steps.js b/go-backend/internal/runtime/runnerjs/src/steps.js new file mode 100644 index 0000000..717938b --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/src/steps.js @@ -0,0 +1,93 @@ +import { resolve, resolveLoose, describe } from './locate.js'; + +// Each executor throws on failure (the caller records the failed step and stops). +// Waits are condition-based (Playwright auto-wait / waitFor), never fixed sleeps. + +function timeoutOf(step, settings) { + return step.timeoutMs || settings?.defaultTimeoutMs || 10000; +} + +// Literal value, or a secret resolved at run time. Secret values are never +// emitted in events or error messages. +function valueOf(step, secrets) { + if (step.valueRef) { + const v = secrets?.[step.valueRef]; + if (v === undefined) { + throw new Error(`secret "${step.valueRef}" was not provided for this run`); + } + return v; + } + return step.value ?? ''; +} + +const MIN_NAV_TIMEOUT_MS = 30000; + +/** + * Open a URL. Real sites load slowly (ads, analytics, large images), so this + * waits only for DOMContentLoaded and allows at least 30s, independent of the + * per-action timeout. A short best-effort wait for `load` follows so the page + * is mostly settled; if it does not fire in time the run carries on, because + * every snapshot still waits for its own conditions. + */ +export async function navigate(page, url, requestedTimeoutMs) { + const timeout = Math.max(requestedTimeoutMs || 0, MIN_NAV_TIMEOUT_MS); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout }); + await page.waitForLoadState('load', { timeout: 10000 }).catch(() => {}); + // Best effort: let client-side apps finish rendering before the next action. + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); +} + +export const executors = { + async click(page, step, ctx) { + const loc = await resolve(page, step.target, timeoutOf(step, ctx.settings)); + await loc.click({ timeout: timeoutOf(step, ctx.settings) }); + }, + + async fill(page, step, ctx) { + const loc = await resolve(page, step.target, timeoutOf(step, ctx.settings)); + await loc.fill(valueOf(step, ctx.secrets), { timeout: timeoutOf(step, ctx.settings) }); + }, + + async select(page, step, ctx) { + const loc = await resolve(page, step.target, timeoutOf(step, ctx.settings)); + await loc.selectOption(valueOf(step, ctx.secrets), { timeout: timeoutOf(step, ctx.settings) }); + }, + + async check(page, step, ctx) { + const loc = await resolve(page, step.target, timeoutOf(step, ctx.settings)); + await loc.check({ timeout: timeoutOf(step, ctx.settings) }); + }, + + async uncheck(page, step, ctx) { + const loc = await resolve(page, step.target, timeoutOf(step, ctx.settings)); + await loc.uncheck({ timeout: timeoutOf(step, ctx.settings) }); + }, + + async navigate(page, step, ctx) { + await navigate(page, step.url, timeoutOf(step, ctx.settings)); + }, + + async waitFor(page, step, ctx) { + const ms = timeoutOf(step, ctx.settings); + const state = { visible: 'visible', hidden: 'hidden', attached: 'attached', detached: 'detached' }[step.condition || 'visible']; + // For "hidden"/"detached" the element may legitimately never exist. + const loc = await resolveForWait(page, step, ms, state); + await loc.first().waitFor({ state, timeout: ms }); + }, + + async scroll(page, step) { + await page.evaluate(([x, y]) => window.scrollTo(x, y), [step.scrollX ?? 0, step.scrollY ?? 0]); + }, +}; + +async function resolveForWait(page, step, ms, state) { + if (state === 'hidden' || state === 'detached') { + // Do not require the element to exist; wait on the primary strategy only. + return resolveLoose(page, step.target); + } + return resolve(page, step.target, ms); +} + +export function stepLabel(step) { + return step.target ? `${step.type} ${describe(step.target)}` : step.type; +} diff --git a/go-backend/internal/runtime/runnerjs/test/fixture/dynamic.html b/go-backend/internal/runtime/runnerjs/test/fixture/dynamic.html new file mode 100644 index 0000000..6c6eea9 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/test/fixture/dynamic.html @@ -0,0 +1,34 @@ + + + Dynamic fixture + +

Login

+ + + + + + + + + + + +
+ + + diff --git a/go-backend/internal/runtime/runnerjs/test/fixture/index.html b/go-backend/internal/runtime/runnerjs/test/fixture/index.html new file mode 100644 index 0000000..487616e --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/test/fixture/index.html @@ -0,0 +1,10 @@ + + + Fixture + +

Fixture page

+ +

not clicked

+ + + diff --git a/go-backend/internal/runtime/runnerjs/test/poc.test.js b/go-backend/internal/runtime/runnerjs/test/poc.test.js new file mode 100644 index 0000000..b1c34f0 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/test/poc.test.js @@ -0,0 +1,54 @@ +// M1 PoC: needs playwright + chromium installed in the runtime folder. +// Run via: node --test (from runtime/playwright/runner after extraction), or +// through the Go integration helper. Percy is stubbed with a local HTTP server. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { runWorkflow } from '../src/run.js'; +import { serveFixtures } from './serve.js'; + +test('click, capture live DOM, post to percy', async () => { + const received = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + received.push({ url: req.url, body: JSON.parse(body) }); + res.writeHead(200).end('{}'); + }); + }); + await new Promise((r) => server.listen(0, r)); + const percyUrl = `http://127.0.0.1:${server.address().port}`; + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const fixtures = await serveFixtures(); + + const code = await runWorkflow({ + buildId: 'build_test', + artifactDir, + percyUrl, + workflow: { + version: 1, + startUrl: `${fixtures.base}/index.html`, + steps: [ + { id: 's1', type: 'click', target: { css: '#go' } }, + { id: 's2', type: 'snapshot', checkpointId: 'after_click', name: 'After click' }, + ], + }, + }); + server.close(); + await fixtures.close(); + + assert.equal(code, 0); + const dom = await readFile(path.join(artifactDir, 'dom', 'after_click.html'), 'utf8'); + assert.match(dom, /clicked/); // live state, not the original HTML + assert.doesNotMatch(dom, /s3cret-do-not-persist/); // sensitive value redacted + assert.equal(received.length, 1); + assert.equal(received[0].url, '/percy/snapshot'); + assert.equal(received[0].body.name, 'After click'); + const meta = JSON.parse(await readFile(path.join(artifactDir, 'metadata', 'after_click.json'), 'utf8')); + assert.equal(meta.buildId, 'build_test'); + assert.equal(meta.workflowVersion, 1); +}); diff --git a/go-backend/internal/runtime/runnerjs/test/serve.js b/go-backend/internal/runtime/runnerjs/test/serve.js new file mode 100644 index 0000000..5ebb623 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/test/serve.js @@ -0,0 +1,23 @@ +// Serves test/fixture over http://127.0.0.1:/ . The runner (like the Go +// validator) only accepts http(s) start URLs, so fixtures cannot use file://. +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixture'); + +export async function serveFixtures() { + const server = http.createServer(async (req, res) => { + const name = path.basename(new URL(req.url, 'http://x').pathname) || 'index.html'; + try { + const body = await readFile(path.join(root, name)); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(body); + } catch { + res.writeHead(404).end('not found'); + } + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + return { base, close: () => new Promise((r) => server.close(r)) }; +} diff --git a/go-backend/internal/runtime/runnerjs/test/steps.test.js b/go-backend/internal/runtime/runnerjs/test/steps.test.js new file mode 100644 index 0000000..9464750 --- /dev/null +++ b/go-backend/internal/runtime/runnerjs/test/steps.test.js @@ -0,0 +1,299 @@ +// Runner behaviour against a local fixture (file://), Percy stubbed over HTTP. +// Needs playwright + chromium (installed by the Go PlaywrightInstaller). +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { mkdtemp, readFile, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { runWorkflow, EXIT } from '../src/run.js'; +import { serveFixtures } from './serve.js'; + +let fixtures; +let startUrl; +before(async () => { + fixtures = await serveFixtures(); + startUrl = `${fixtures.base}/dynamic.html`; +}); +after(async () => { + await fixtures.close(); +}); + +async function percyStub(status = 200) { + const received = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + received.push(JSON.parse(body)); + res.writeHead(status).end('{}'); + }); + }); + await new Promise((r) => server.listen(0, r)); + return { received, url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() }; +} + +// Collect NDJSON events the runner writes to stdout. +async function capture(fn) { + const events = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk, ...rest) => { + for (const line of String(chunk).split('\n')) { + if (line.trim().startsWith('{')) { + try { events.push(JSON.parse(line)); } catch { /* not ours */ } + } + } + return true; + }; + try { + return { result: await fn(), events }; + } finally { + process.stdout.write = orig; + } +} + +function wf(steps, extra = {}) { + return { + version: 3, + startUrl, + steps, + settings: { defaultTimeoutMs: 4000, viewport: { width: 900, height: 600 } }, + ...extra, + }; +} + +const journey = [ + { id: 'step_001', type: 'fill', target: { role: 'textbox', name: 'Username' }, value: 'alice' }, + { id: 'step_002', type: 'fill', target: { role: 'textbox', name: 'Password' }, valueRef: 'pw' }, + { id: 'step_003', type: 'select', target: { css: '#plan' }, value: 'pro' }, + { id: 'step_004', type: 'check', target: { role: 'checkbox', name: 'I agree' } }, + { id: 'step_005', type: 'snapshot', checkpointId: 'before', name: 'Before' }, + { id: 'step_006', type: 'click', target: { role: 'button', name: 'Sign in' } }, + { id: 'step_007', type: 'waitFor', condition: 'visible', target: { role: 'heading', name: 'Dashboard' }, timeoutMs: 5000 }, + { id: 'step_008', type: 'snapshot', checkpointId: 'dashboard', name: 'Dashboard' }, + { id: 'step_009', type: 'scroll', scrollX: 0, scrollY: 50 }, + { id: 'step_010', type: 'snapshot', checkpointId: 'scrolled', name: 'Scrolled' }, +]; + +test('replays a journey in order, waits for dynamic content, writes one DOM per checkpoint', async () => { + const percy = await percyStub(); + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const { result, events } = await capture(() => + runWorkflow({ buildId: 'build_t', artifactDir, percyUrl: percy.url, secrets: { pw: 'hunter2-SECRET' }, workflow: wf(journey) }), + ); + percy.close(); + + assert.equal(result, EXIT.PASSED); + + // steps ran strictly in recorded order + const started = events.filter((e) => e.type === 'stepStarted').map((e) => e.stepId); + assert.deepEqual(started, journey.map((s) => s.id)); + + // one distinct DOM file per checkpoint + const doms = (await readdir(path.join(artifactDir, 'dom'))).sort(); + assert.deepEqual(doms, ['before.html', 'dashboard.html', 'scrolled.html']); + + // the dashboard DOM is the LIVE state after the delayed render + form values + const dash = await readFile(path.join(artifactDir, 'dom', 'dashboard.html'), 'utf8'); + assert.match(dash, /Dashboard/); + // The page echoes the typed password into visible text. The other fields prove + // the form really was filled; the secret itself must be scrubbed by value. + assert.match(dash, /user=alice;pw=\[REDACTED\];agree=true;plan=pro/); + assert.doesNotMatch(dash, /hunter2-SECRET/); + assert.doesNotMatch(JSON.stringify(percy.received), /hunter2-SECRET/); + const before = await readFile(path.join(artifactDir, 'dom', 'before.html'), 'utf8'); + assert.doesNotMatch(before, /

Dashboard<\/h2>/); + + // Percy received the three checkpoints, by name, in order + assert.deepEqual(percy.received.map((r) => r.name), ['Before', 'Dashboard', 'Scrolled']); + + // metadata names the build + workflow version + const meta = JSON.parse(await readFile(path.join(artifactDir, 'metadata', 'dashboard.json'), 'utf8')); + assert.equal(meta.buildId, 'build_t'); + assert.equal(meta.workflowVersion, 3); + assert.deepEqual(meta.viewport, { width: 900, height: 600 }); + + // the secret never appears in events (they go to Go's logs) or in metadata + assert.doesNotMatch(JSON.stringify(events), /hunter2-SECRET/); + assert.doesNotMatch(JSON.stringify(meta), /hunter2-SECRET/); +}); + +test('redactSecrets scrubs by value, longest first, and skips values too short to be safe', async () => { + const { redactSecrets } = await import('../src/capture.js'); + const html = '

abcd1234

abcdxab'; + assert.equal( + redactSecrets(html, { long: 'abcd1234', short: 'abcd', tiny: 'x', tiny2: 'ab', notString: 42 }), + '

[REDACTED]

[REDACTED]xab', + ); + assert.equal(redactSecrets(html, undefined), html); + assert.equal(redactSecrets(html, {}), html); +}); + +test('password input value is redacted from the saved DOM artifact', async () => { + const percy = await percyStub(); + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + await capture(() => + runWorkflow({ + buildId: 'build_t', artifactDir, percyUrl: percy.url, secrets: { pw: 'hunter2-SECRET' }, + workflow: wf([ + { id: 'step_001', type: 'fill', target: { css: '#pw' }, valueRef: 'pw' }, + { id: 'step_002', type: 'snapshot', checkpointId: 'filled', name: 'Filled' }, + ]), + }), + ); + percy.close(); + // fill() sets the property, not the attribute, so check both the file and what Percy was sent + const saved = await readFile(path.join(artifactDir, 'dom', 'filled.html'), 'utf8'); + assert.doesNotMatch(saved, /hunter2-SECRET/); + assert.doesNotMatch(JSON.stringify(percy.received), /hunter2-SECRET/); +}); + +test('ambiguous locator fails safely at that step and keeps a debug DOM', async () => { + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const { result, events } = await capture(() => + runWorkflow({ + buildId: 'build_t', artifactDir, + workflow: wf([ + { id: 'step_001', type: 'click', target: { role: 'button', name: 'Duplicate' } }, + { id: 'step_002', type: 'snapshot', checkpointId: 'never', name: 'Never' }, + ]), + }), + ); + assert.equal(result, EXIT.FAILED); + const fin = events.find((e) => e.type === 'stepFinished' && e.stepId === 'step_001'); + assert.equal(fin.status, 'failed'); + assert.match(fin.message, /matched 2 elements/); + assert.ok(!events.some((e) => e.type === 'stepStarted' && e.stepId === 'step_002'), 'must stop after the failure'); + assert.ok((await readdir(path.join(artifactDir, 'dom'))).includes('failure_step_001.html')); +}); + +test('missing element fails with the locator and tried strategies named', async () => { + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const { result, events } = await capture(() => + runWorkflow({ + buildId: 'build_t', artifactDir, + workflow: wf([{ id: 'step_001', type: 'click', target: { role: 'button', name: 'Nope', fallbacks: [{ css: '#nope' }] }, timeoutMs: 600 }]), + }), + ); + assert.equal(result, EXIT.FAILED); + const fin = events.find((e) => e.type === 'stepFinished'); + assert.match(fin.message, /role=button\[name="Nope"\]/); + assert.match(fin.message, /css=#nope/); +}); + +test('a recorded fallback locator rescues a stale primary', async () => { + const percy = await percyStub(); + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const { result } = await capture(() => + runWorkflow({ + buildId: 'build_t', artifactDir, percyUrl: percy.url, + workflow: wf([ + { id: 'step_001', type: 'click', target: { role: 'button', name: 'Old label', fallbacks: [{ css: '#signin' }] }, timeoutMs: 600 }, + { id: 'step_002', type: 'waitFor', condition: 'visible', target: { role: 'heading', name: 'Dashboard' }, timeoutMs: 4000 }, + ]), + }), + ); + percy.close(); + assert.equal(result, EXIT.PASSED); +}); + +test('missing secret fails the step without echoing any value', async () => { + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const { result, events } = await capture(() => + runWorkflow({ + buildId: 'build_t', artifactDir, secrets: {}, + workflow: wf([{ id: 'step_001', type: 'fill', target: { css: '#pw' }, valueRef: 'pw' }]), + }), + ); + assert.equal(result, EXIT.FAILED); + assert.match(events.find((e) => e.type === 'stepFinished').message, /secret "pw" was not provided/); +}); + +test('percy upload failure keeps the DOM and yields PARTIAL', async () => { + const percy = await percyStub(500); + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const { result, events } = await capture(() => + runWorkflow({ + buildId: 'build_t', artifactDir, percyUrl: percy.url, + workflow: wf([{ id: 'step_001', type: 'snapshot', checkpointId: 'only', name: 'Only' }]), + }), + ); + percy.close(); + assert.equal(result, EXIT.PARTIAL); + assert.ok(events.some((e) => e.type === 'checkpointUploadFailed' && e.checkpointId === 'only')); + assert.ok((await readdir(path.join(artifactDir, 'dom'))).includes('only.html')); +}); + +test('invalid requests are rejected before a browser is launched', async () => { + for (const bad of [ + { buildId: 'b', artifactDir: '/tmp/x', workflow: { startUrl: 'file:///etc/passwd', steps: [{ id: 's', type: 'snapshot', checkpointId: 'c', name: 'n' }] } }, + { buildId: 'b', artifactDir: '/tmp/x', workflow: { startUrl: 'https://a.test', steps: [{ id: 's', type: 'exec' }] } }, + { buildId: 'b', artifactDir: '/tmp/x', workflow: { startUrl: 'https://a.test', steps: [] } }, + { artifactDir: '/tmp/x', workflow: {} }, + ]) { + const { result } = await capture(() => runWorkflow(bad)); + assert.equal(result, EXIT.INVALID); + } +}); + +test('path-unsafe checkpoint ids cannot write outside the artifact dir', async () => { + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const { result } = await capture(() => + runWorkflow({ + buildId: 'build_t', artifactDir, + workflow: wf([{ id: 'step_001', type: 'snapshot', checkpointId: '../../escape', name: 'Evil' }]), + }), + ); + assert.equal(result, EXIT.FAILED); + assert.deepEqual((await readdir(path.dirname(artifactDir))).filter((n) => n === 'escape.html'), []); +}); + +test('snapshot waits waitMs before capture and forwards Percy options', async () => { + const percy = await percyStub(); + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const steps = [ + { + id: 'step_001', + type: 'snapshot', + checkpointId: 'opts', + name: 'Opts', + waitMs: 1200, + options: { widths: [375, 1280], minHeight: 800, scope: '#app', enableJavaScript: true, percyCSS: '.x{display:none}' }, + }, + ]; + const t0 = Date.now(); + const { result } = await capture(() => + runWorkflow({ buildId: 'build_o', artifactDir, percyUrl: percy.url, secrets: {}, workflow: wf(steps) }), + ); + const elapsed = Date.now() - t0; + percy.close(); + + assert.equal(result, EXIT.PASSED); + assert.ok(elapsed >= 1200, `expected the intentional wait, took ${elapsed}ms`); + const body = percy.received[0]; + assert.deepEqual(body.widths, [375, 1280]); + assert.equal(body.minHeight, 800); + assert.equal(body.scope, '#app'); + assert.equal(body.enableJavaScript, true); + assert.equal(body.percyCSS, '.x{display:none}'); +}); + +test('legacy snapshot without waitMs/options keeps viewport widths and does not wait', async () => { + const percy = await percyStub(); + const artifactDir = await mkdtemp(path.join(tmpdir(), 'wf-')); + const steps = [{ id: 'step_001', type: 'snapshot', checkpointId: 'legacy', name: 'Legacy' }]; + const t0 = Date.now(); + const { result } = await capture(() => + runWorkflow({ buildId: 'build_l', artifactDir, percyUrl: percy.url, secrets: {}, workflow: wf(steps) }), + ); + const elapsed = Date.now() - t0; + percy.close(); + + assert.equal(result, EXIT.PASSED); + assert.ok(elapsed < 1200, `legacy snapshot should not add a wait, took ${elapsed}ms`); + const body = percy.received[0]; + assert.deepEqual(body.widths, [900]); + assert.equal(body.minHeight, 600); + assert.equal(body.scope, undefined); + assert.equal(body.percyCSS, undefined); +}); diff --git a/go-backend/internal/runtime/runtime_test.go b/go-backend/internal/runtime/runtime_test.go new file mode 100644 index 0000000..14faf52 --- /dev/null +++ b/go-backend/internal/runtime/runtime_test.go @@ -0,0 +1,54 @@ +package runtime + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveStaysInsideRoot(t *testing.T) { + d := NewDirAt(t.TempDir()) + if _, err := d.Resolve("workflows", "wf_1.json"); err != nil { + t.Fatalf("expected ok, got %v", err) + } + for _, bad := range []string{"../x", "workflows/../../x", "/etc/passwd"} { + p, err := d.Resolve(bad) + if err == nil && !isInside(d.Root, p) { + t.Fatalf("%q escaped root: %s", bad, p) + } + } + if _, err := d.Resolve("..", "other"); err == nil { + t.Fatal("expected traversal to be rejected") + } +} + +func isInside(root, p string) bool { + rel, err := filepath.Rel(root, p) + return err == nil && rel != ".." && !filepath.IsAbs(rel) && len(rel) > 0 && rel[0] != '.' +} + +func TestExtractRunnerWritesEntry(t *testing.T) { + d := NewDirAt(t.TempDir()) + if err := d.ExtractRunner(); err != nil { + t.Fatal(err) + } + for _, f := range []string{"index.js", "src/run.js", "src/capture.js", "src/percy.js", "src/events.js"} { + if _, err := os.Stat(filepath.Join(d.Playwright(), "runner", f)); err != nil { + t.Fatalf("missing %s: %v", f, err) + } + } +} + +func TestEnsureModuleType(t *testing.T) { + p := filepath.Join(t.TempDir(), "package.json") + if err := os.WriteFile(p, []byte(`{"name":"x"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := ensureModuleType(p); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + if string(b) == `{"name":"x"}` { + t.Fatal("package.json not updated") + } +} diff --git a/go-backend/internal/service/build.go b/go-backend/internal/service/build.go index c42de28..f13c38a 100644 --- a/go-backend/internal/service/build.go +++ b/go-backend/internal/service/build.go @@ -1,87 +1,132 @@ package service import ( + "errors" "fmt" "log" + "time" + "github.com/jostan30/Percy_chrome_ext/go-backend/internal/percy" ) +// ErrBuildRunning is returned when a build is already in progress. +var ErrBuildRunning = errors.New("a build is already running") + type BuildService struct { percy *percy.Controller client *percy.Client snapshot *SnapshotService -} -type BuildResult struct { - BuildID string - BuildURL string + job *BuildJob + + // externalBusy, when set, reports another user of the single Percy process + // (e.g. a workflow run). Finalize refuses to start while it returns true. + externalBusy func() bool } -func NewBuildService(controller *percy.Controller,client *percy.Client,snapshot*SnapshotService, ) *BuildService { - return &BuildService{ - percy: controller, - client: client, +// SetExternalBusy registers a check for other users of the Percy process. +func (s *BuildService) SetExternalBusy(fn func() bool) { s.externalBusy = fn } + +// Running reports whether a finalize build is in progress. +func (s *BuildService) Running() bool { return s.job.Snapshot().Status == StatusRunning } + +func NewBuildService(controller *percy.Controller, client *percy.Client, snapshot *SnapshotService) *BuildService { + s := &BuildService{ + percy: controller, + client: client, snapshot: snapshot, + job: NewBuildJob(), } + controller.SetLogSink(s.job.AddLog) + return s +} + +// Status returns the current build job state. +func (s *BuildService) Status() BuildStatus { + return s.job.Snapshot() } -func (s *BuildService) Finalize(token string) (*BuildResult, error) { +// Subscribe returns a channel signalled on every state change. +func (s *BuildService) Subscribe() subscriber { return s.job.Subscribe() } - snapshots := s.snapshot.List() +// Unsubscribe removes the subscriber. +func (s *BuildService) Unsubscribe(ch subscriber) { s.job.Unsubscribe(ch) } +// StartFinalize validates, then runs the build in the background. +func (s *BuildService) StartFinalize(token string) error { + if s.externalBusy != nil && s.externalBusy() { + return ErrBuildRunning + } + snapshots := s.snapshot.List() if len(snapshots) == 0 { - return nil, fmt.Errorf("no snapshots available") + return fmt.Errorf("no snapshots available") } - if err := s.percy.EnsureReady(); err != nil { - return nil, err + progress := make([]SnapshotProgress, len(snapshots)) + for i, snap := range snapshots { + progress[i] = SnapshotProgress{ID: snap.ID, Name: snap.Name, State: SnapPending} + } + if !s.job.Start(progress) { + return ErrBuildRunning } + go func() { + if err := s.run(token); err != nil { + log.Printf("[build] failed: %v", err) + s.job.AddLog("Build failed: " + err.Error()) + s.job.Fail(err) + } + }() + return nil +} + +func (s *BuildService) run(token string) error { + snapshots := s.snapshot.List() + + s.job.SetPhase(PhaseInstalling) + if err := s.percy.EnsureReady(); err != nil { + return err + } log.Println("[build] Percy runtime ready") - log.Println("[build] Starting Percy exec:start...") + s.job.SetPhase(PhaseStarting) if err := s.percy.Start(token); err != nil { - return nil, err + return err } - defer s.percy.Stop() + // Always stop Percy, on success or failure. + defer func() { + if err := s.percy.Stop(); err != nil { + log.Printf("[build] failed to stop Percy: %v", err) + } + }() health, err := s.client.Health() if err != nil { - return nil, err + return err } - fmt.Println(health) - + s.job.SetPhase(PhaseUploading) for i, snap := range snapshots { - - fmt.Printf( - "Uploading %d/%d: %s\n", - i+1, - len(snapshots), - snap.Name, - ) + s.job.SetSnapshot(i, SnapSending, "") + s.job.AddLog(fmt.Sprintf("Uploading %d/%d: %s", i+1, len(snapshots), snap.Name)) if err := s.client.Snapshot(snap); err != nil { - return nil, err + s.job.SetSnapshot(i, SnapFailed, err.Error()) + return fmt.Errorf("snapshot %q failed: %w", snap.Name, err) } - if err := s.percy.WaitFor("Snapshot taken"); err != nil { - return nil, err - } + s.job.SetSnapshot(i, SnapSent, "") + s.job.AddLog(fmt.Sprintf("Sent %d/%d", i+1, len(snapshots))) + } - fmt.Printf( - "✓ Completed %d/%d\n", - i+1, - len(snapshots), - ) + // Wait until Percy has processed every queued snapshot (or exits/times out). + s.job.SetPhase(PhaseProcessing) + if err := s.client.WaitIdle(5*time.Minute, s.percy.Exited()); err != nil { + return err } s.snapshot.Clear() - - fmt.Println("Percy finished processing snapshot") - - return &BuildResult{ - BuildID: health.Build.ID, - BuildURL: health.Build.URL, - }, nil + s.job.AddLog("Percy finished processing snapshots") + s.job.Succeed(health.Build.ID, health.Build.URL) + return nil } \ No newline at end of file diff --git a/go-backend/internal/service/buildjob.go b/go-backend/internal/service/buildjob.go new file mode 100644 index 0000000..24cb878 --- /dev/null +++ b/go-backend/internal/service/buildjob.go @@ -0,0 +1,201 @@ +package service + +import ( + "strings" + "sync" + "time" +) + +const maxLogLines = 500 + +// subscriber is a channel that receives a signal whenever the job state changes. +type subscriber chan struct{} + +type BuildStatusValue string +type BuildPhase string +type SnapshotState string + +const ( + StatusIdle BuildStatusValue = "idle" + StatusRunning BuildStatusValue = "running" + StatusSuccess BuildStatusValue = "success" + StatusFailed BuildStatusValue = "failed" + + PhaseInstalling BuildPhase = "installing" + PhaseStarting BuildPhase = "starting" + PhaseUploading BuildPhase = "uploading" + PhaseProcessing BuildPhase = "processing" + PhaseDone BuildPhase = "done" + + SnapPending SnapshotState = "pending" + SnapSending SnapshotState = "sending" + SnapSent SnapshotState = "sent" + SnapFailed SnapshotState = "failed" +) + +type BuildLogEntry struct { + Timestamp time.Time `json:"ts"` + Level string `json:"level"` + Line string `json:"line"` +} + +type SnapshotProgress struct { + ID string `json:"id"` + Name string `json:"name"` + State SnapshotState `json:"state"` + Error string `json:"error,omitempty"` +} + +// BuildStatus is the JSON snapshot returned by GET /build/status. +type BuildStatus struct { + Status BuildStatusValue `json:"status"` + Phase BuildPhase `json:"phase"` + Total int `json:"total"` + Sent int `json:"sent"` + Snapshots []SnapshotProgress `json:"snapshots"` + Logs []BuildLogEntry `json:"logs"` + Error string `json:"error,omitempty"` + BuildID string `json:"buildId,omitempty"` + BuildURL string `json:"buildUrl,omitempty"` +} + +// BuildJob tracks the progress of one finalize run. Safe for concurrent use. +type BuildJob struct { + mu sync.Mutex + s BuildStatus + subs map[subscriber]struct{} +} + +func NewBuildJob() *BuildJob { + return &BuildJob{ + s: BuildStatus{ + Status: StatusIdle, + Snapshots: []SnapshotProgress{}, + Logs: []BuildLogEntry{}, + }, + subs: make(map[subscriber]struct{}), + } +} + +// Subscribe returns a channel that receives a signal (non-blocking send) on +// every state change. Call Unsubscribe when done. +func (j *BuildJob) Subscribe() subscriber { + ch := make(subscriber, 1) + j.mu.Lock() + j.subs[ch] = struct{}{} + j.mu.Unlock() + return ch +} + +// Unsubscribe removes and closes the subscriber channel. +func (j *BuildJob) Unsubscribe(ch subscriber) { + j.mu.Lock() + delete(j.subs, ch) + j.mu.Unlock() + close(ch) +} + +// notify sends a non-blocking signal to all subscribers. Must be called with mu held. +func (j *BuildJob) notify() { + for ch := range j.subs { + select { + case ch <- struct{}{}: + default: + } + } +} + +// Start resets the job for a new run. Returns false if one is already running. +func (j *BuildJob) Start(snaps []SnapshotProgress) bool { + j.mu.Lock() + defer j.mu.Unlock() + if j.s.Status == StatusRunning { + return false + } + j.s = BuildStatus{ + Status: StatusRunning, + Phase: PhaseInstalling, + Total: len(snaps), + Snapshots: snaps, + Logs: []BuildLogEntry{}, + } + j.notify() + return true +} + +func (j *BuildJob) SetPhase(p BuildPhase) { + j.mu.Lock() + j.s.Phase = p + j.notify() + j.mu.Unlock() +} + +func (j *BuildJob) SetSnapshot(i int, state SnapshotState, errMsg string) { + j.mu.Lock() + defer j.mu.Unlock() + if i < 0 || i >= len(j.s.Snapshots) { + return + } + j.s.Snapshots[i].State = state + j.s.Snapshots[i].Error = errMsg + if state == SnapSent { + j.s.Sent++ + } + j.notify() +} + +// AddLog appends a line, detecting its level, keeping only the newest lines. +func (j *BuildJob) AddLog(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + entry := BuildLogEntry{Timestamp: time.Now(), Level: detectLevel(line), Line: line} + j.mu.Lock() + defer j.mu.Unlock() + j.s.Logs = append(j.s.Logs, entry) + if len(j.s.Logs) > maxLogLines { + j.s.Logs = append([]BuildLogEntry(nil), j.s.Logs[len(j.s.Logs)-maxLogLines:]...) + } + j.notify() +} + +func (j *BuildJob) Succeed(buildID, buildURL string) { + j.mu.Lock() + j.s.Status = StatusSuccess + j.s.Phase = PhaseDone + j.s.BuildID = buildID + j.s.BuildURL = buildURL + j.notify() + j.mu.Unlock() +} + +func (j *BuildJob) Fail(err error) { + j.mu.Lock() + j.s.Status = StatusFailed + j.s.Error = err.Error() + j.notify() + j.mu.Unlock() +} + +// Snapshot returns a deep copy safe to serialize. +func (j *BuildJob) Snapshot() BuildStatus { + j.mu.Lock() + defer j.mu.Unlock() + out := j.s + out.Snapshots = append([]SnapshotProgress{}, j.s.Snapshots...) + out.Logs = append([]BuildLogEntry{}, j.s.Logs...) + return out +} + +func detectLevel(line string) string { + l := strings.ToLower(line) + switch { + case strings.Contains(l, "error") || strings.Contains(l, "failed") || strings.Contains(l, "fatal"): + return "error" + case strings.Contains(l, "warn"): + return "warn" + default: + return "info" + } +} diff --git a/go-backend/internal/workflow/model.go b/go-backend/internal/workflow/model.go new file mode 100644 index 0000000..5ed576f --- /dev/null +++ b/go-backend/internal/workflow/model.go @@ -0,0 +1,67 @@ +// Package workflow defines the recorded-workflow schema, its validation and +// its JSON persistence under runtime/workflows. +package workflow + +import "time" + +const SchemaVersion = 1 + +type Target struct { + TestID string `json:"testId,omitempty"` + Role string `json:"role,omitempty"` + Name string `json:"name,omitempty"` + CSS string `json:"css,omitempty"` + Text string `json:"text,omitempty"` + Fallbacks []Target `json:"fallbacks,omitempty"` +} + +// SnapshotOptions are the per-snapshot Percy options (snapshot steps only). +type SnapshotOptions struct { + Widths []int `json:"widths,omitempty"` + MinHeight int `json:"minHeight,omitempty"` + Scope string `json:"scope,omitempty"` + EnableJavaScript bool `json:"enableJavaScript,omitempty"` + PercyCSS string `json:"percyCSS,omitempty"` +} + +type Step struct { + ID string `json:"id"` + Type string `json:"type"` + Label string `json:"label,omitempty"` + Target *Target `json:"target,omitempty"` + Value string `json:"value,omitempty"` // literal, non-sensitive only + ValueRef string `json:"valueRef,omitempty"` // secret / run-time variable name + URL string `json:"url,omitempty"` // navigate + Condition string `json:"condition,omitempty"` + TimeoutMs int `json:"timeoutMs,omitempty"` + CheckpointID string `json:"checkpointId,omitempty"` // snapshot + Name string `json:"name,omitempty"` // snapshot display name + ScrollX int `json:"scrollX,omitempty"` + ScrollY int `json:"scrollY,omitempty"` + + // Snapshot-only: intentional wait before capture and Percy options. + WaitMs int `json:"waitMs,omitempty"` + Options *SnapshotOptions `json:"options,omitempty"` +} + +type Viewport struct { + Width int `json:"width"` + Height int `json:"height"` +} + +type Settings struct { + DefaultTimeoutMs int `json:"defaultTimeoutMs,omitempty"` + Viewport Viewport `json:"viewport"` +} + +type Workflow struct { + WorkflowID string `json:"workflowId"` + Name string `json:"name"` + SchemaVersion int `json:"schemaVersion"` + Version int `json:"version"` + StartURL string `json:"startUrl"` + Steps []Step `json:"steps"` + Settings Settings `json:"settings"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/go-backend/internal/workflow/store.go b/go-backend/internal/workflow/store.go new file mode 100644 index 0000000..6e799db --- /dev/null +++ b/go-backend/internal/workflow/store.go @@ -0,0 +1,198 @@ +package workflow + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +var ( + ErrNotFound = errors.New("workflow not found") + ErrInvalidID = errors.New("invalid workflow id") +) + +// Store persists one JSON file per workflow in a single directory. +type Store struct { + dir string + mu sync.Mutex // serialises writes; workflows are small and writes rare +} + +func NewStore(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + return &Store{dir: dir}, nil +} + +// NewID returns a unique, filesystem-safe workflow ID. +func NewID() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return fmt.Sprintf("wf_%d_%s", time.Now().Unix(), hex.EncodeToString(b)) +} + +func (s *Store) path(id string) (string, error) { + if !ValidID(id) { + return "", ErrInvalidID + } + p := filepath.Join(s.dir, id+".json") + // belt and braces: stay inside dir + if filepath.Dir(p) != filepath.Clean(s.dir) { + return "", ErrInvalidID + } + return p, nil +} + +// WriteFileAtomic writes data to path via a temp file + rename. +func WriteFileAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { + return err + } + name := tmp.Name() + cleanup := func() { _ = os.Remove(name) } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + cleanup() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + cleanup() + return err + } + if err := tmp.Close(); err != nil { + cleanup() + return err + } + if err := os.Rename(name, path); err != nil { + cleanup() + return err + } + return nil +} + +// Create validates and stores a new workflow, assigning its ID and version 1. +func (s *Store) Create(w *Workflow) error { + w.WorkflowID = NewID() + w.Version = 1 + w.SchemaVersion = orDefault(w.SchemaVersion) + if err := w.Validate(); err != nil { + return err + } + now := time.Now().UTC() + w.CreatedAt, w.UpdatedAt = now, now + return s.write(w) +} + +// Update overwrites an existing workflow in place (same version), keeping +// its ID and creation time. +func (s *Store) Update(id string, w *Workflow) error { + if !ValidID(id) { + return ErrInvalidID + } + s.mu.Lock() + defer s.mu.Unlock() + old, err := s.read(id) + if err != nil { + return err + } + w.WorkflowID = id + w.Version = old.Version + w.CreatedAt = old.CreatedAt + w.SchemaVersion = orDefault(w.SchemaVersion) + if err := w.Validate(); err != nil { + return err + } + w.UpdatedAt = time.Now().UTC() + return s.writeLocked(w) +} + +func orDefault(v int) int { + if v == 0 { + return SchemaVersion + } + return v +} + +func (s *Store) Get(id string) (*Workflow, error) { + if !ValidID(id) { + return nil, ErrInvalidID + } + return s.read(id) +} + +func (s *Store) List() ([]Workflow, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, err + } + out := []Workflow{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".json") { + continue + } + id := strings.TrimSuffix(name, ".json") + if !ValidID(id) { + continue + } + w, err := s.read(id) + if err != nil { + continue // skip unreadable/corrupt files rather than failing the list + } + out = append(out, *w) + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) }) + return out, nil +} + +func (s *Store) read(id string) (*Workflow, error) { + p, err := s.path(id) + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + var w Workflow + if err := json.Unmarshal(data, &w); err != nil { + return nil, fmt.Errorf("workflow %s is corrupt: %w", id, err) + } + return &w, nil +} + +func (s *Store) write(w *Workflow) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.writeLocked(w) +} + +func (s *Store) writeLocked(w *Workflow) error { + p, err := s.path(w.WorkflowID) + if err != nil { + return err + } + // The runtime folder can be removed while the server is running (cleanup, + // reinstall). Recreate it instead of failing every save until a restart. + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return fmt.Errorf("cannot create workflows folder %s: %w", s.dir, err) + } + data, err := json.MarshalIndent(w, "", " ") + if err != nil { + return err + } + return WriteFileAtomic(p, data) +} diff --git a/go-backend/internal/workflow/validate.go b/go-backend/internal/workflow/validate.go new file mode 100644 index 0000000..945433f --- /dev/null +++ b/go-backend/internal/workflow/validate.go @@ -0,0 +1,197 @@ +package workflow + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +var ( + idRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_]{0,63}$`) + checkpointRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + stepTypes = map[string]bool{ + "click": true, "fill": true, "select": true, "check": true, "uncheck": true, + "navigate": true, "waitFor": true, "scroll": true, "snapshot": true, + } + needsTarget = map[string]bool{ + "click": true, "fill": true, "select": true, "check": true, "uncheck": true, "waitFor": true, + } + conditions = map[string]bool{"": true, "visible": true, "hidden": true, "attached": true, "detached": true} +) + +const ( + maxSteps = 500 + maxTimeoutMs = 300000 + maxLiteralSize = 2000 + maxWaitMs = 60000 + maxWidths = 10 + maxWidth = 4000 + maxMinHeight = 20000 + maxScopeLen = 500 + maxCSSLen = 10000 +) + +// ValidationError marks an error caused by invalid user input (HTTP 400). +type ValidationError struct{ Msg string } + +func (e *ValidationError) Error() string { return e.Msg } + +// ValidID reports whether id is a filesystem-safe workflow/build identifier. +func ValidID(id string) bool { return idRe.MatchString(id) } + +func validURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return fmt.Errorf("invalid url %q", raw) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("url scheme must be http or https") + } + return nil +} + +func hasLocator(t *Target) bool { + return t != nil && (t.TestID != "" || t.Role != "" || t.CSS != "" || t.Text != "") +} + +// Validate checks the workflow definition. It never accepts executable +// content: only the enumerated step types with structured fields. +func (w *Workflow) Validate() error { + if err := w.validate(); err != nil { + return &ValidationError{Msg: err.Error()} + } + return nil +} + +func (w *Workflow) validate() error { + if strings.TrimSpace(w.Name) == "" { + return fmt.Errorf("name is required") + } + if len(w.Name) > 200 { + return fmt.Errorf("name too long") + } + if w.SchemaVersion != SchemaVersion { + return fmt.Errorf("unsupported schemaVersion %d", w.SchemaVersion) + } + if err := validURL(w.StartURL); err != nil { + return fmt.Errorf("startUrl: %w", err) + } + if len(w.Steps) == 0 || len(w.Steps) > maxSteps { + return fmt.Errorf("steps must contain 1..%d entries", maxSteps) + } + if v := w.Settings.Viewport; v.Width < 200 || v.Width > 4000 || v.Height < 200 || v.Height > 4000 { + return fmt.Errorf("settings.viewport must be 200..4000 in each dimension") + } + if w.Settings.DefaultTimeoutMs < 0 || w.Settings.DefaultTimeoutMs > maxTimeoutMs { + return fmt.Errorf("settings.defaultTimeoutMs out of range") + } + + stepIDs := map[string]bool{} + checkpoints := map[string]bool{} + for i, s := range w.Steps { + at := fmt.Sprintf("steps[%d]", i) + if s.ID == "" || !idRe.MatchString(s.ID) { + return fmt.Errorf("%s: invalid id", at) + } + if stepIDs[s.ID] { + return fmt.Errorf("%s: duplicate id %q", at, s.ID) + } + stepIDs[s.ID] = true + if !stepTypes[s.Type] { + return fmt.Errorf("%s: unknown step type %q", at, s.Type) + } + if s.TimeoutMs < 0 || s.TimeoutMs > maxTimeoutMs { + return fmt.Errorf("%s: timeoutMs out of range", at) + } + if needsTarget[s.Type] && !hasLocator(s.Target) { + return fmt.Errorf("%s: %s requires a target locator", at, s.Type) + } + if len(s.Value) > maxLiteralSize { + return fmt.Errorf("%s: value too long", at) + } + if s.ValueRef != "" && !regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]{0,63}$`).MatchString(s.ValueRef) { + return fmt.Errorf("%s: invalid valueRef", at) + } + if s.Type != "snapshot" && (s.WaitMs != 0 || s.Options != nil) { + return fmt.Errorf("%s: waitMs and options are only allowed on snapshot steps", at) + } + if s.WaitMs < 0 || s.WaitMs > maxWaitMs { + return fmt.Errorf("%s: waitMs must be 0..%d", at, maxWaitMs) + } + if err := validateOptions(s.Options); err != nil { + return fmt.Errorf("%s: %w", at, err) + } + switch s.Type { + case "fill": + if s.Value == "" && s.ValueRef == "" { + return fmt.Errorf("%s: fill requires value or valueRef", at) + } + if s.Value != "" && s.ValueRef != "" { + return fmt.Errorf("%s: provide either value or valueRef, not both", at) + } + // Sensitive fields must never carry a literal. + if s.Value != "" && s.Target != nil && looksSensitive(s.Target) { + return fmt.Errorf("%s: sensitive field must use valueRef, not a literal value", at) + } + case "select": + if s.Value == "" && s.ValueRef == "" { + return fmt.Errorf("%s: select requires value or valueRef", at) + } + case "navigate": + if err := validURL(s.URL); err != nil { + return fmt.Errorf("%s: %w", at, err) + } + case "waitFor": + if !conditions[s.Condition] { + return fmt.Errorf("%s: unknown condition %q", at, s.Condition) + } + case "snapshot": + if !checkpointRe.MatchString(s.CheckpointID) { + return fmt.Errorf("%s: invalid checkpointId", at) + } + if checkpoints[s.CheckpointID] { + return fmt.Errorf("%s: duplicate checkpointId %q", at, s.CheckpointID) + } + checkpoints[s.CheckpointID] = true + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("%s: snapshot requires a name", at) + } + } + } + return nil +} + +func validateOptions(o *SnapshotOptions) error { + if o == nil { + return nil + } + if len(o.Widths) > maxWidths { + return fmt.Errorf("options.widths allows at most %d entries", maxWidths) + } + for _, w := range o.Widths { + if w < 1 || w > maxWidth { + return fmt.Errorf("options.widths entries must be 1..%d", maxWidth) + } + } + if o.MinHeight < 0 || o.MinHeight > maxMinHeight { + return fmt.Errorf("options.minHeight must be 0..%d", maxMinHeight) + } + if len(o.Scope) > maxScopeLen { + return fmt.Errorf("options.scope too long") + } + if len(o.PercyCSS) > maxCSSLen { + return fmt.Errorf("options.percyCSS too long") + } + return nil +} + +func looksSensitive(t *Target) bool { + hay := strings.ToLower(t.Name + " " + t.TestID + " " + t.CSS + " " + t.Text) + for _, k := range []string{"password", "passwd", "secret", "token", "otp", "cvv", "card"} { + if strings.Contains(hay, k) { + return true + } + } + return false +} diff --git a/go-backend/internal/workflow/workflow_test.go b/go-backend/internal/workflow/workflow_test.go new file mode 100644 index 0000000..07ec7de --- /dev/null +++ b/go-backend/internal/workflow/workflow_test.go @@ -0,0 +1,196 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func valid() *Workflow { + return &Workflow{ + Name: "Dashboard", + SchemaVersion: 1, + StartURL: "https://example.test/login", + Settings: Settings{Viewport: Viewport{Width: 1440, Height: 900}}, + Steps: []Step{ + {ID: "step_001", Type: "fill", Target: &Target{Role: "textbox", Name: "Username"}, ValueRef: "username"}, + {ID: "step_002", Type: "click", Target: &Target{Role: "button", Name: "Sign in"}}, + {ID: "step_003", Type: "waitFor", Condition: "visible", Target: &Target{Role: "heading", Name: "Dashboard"}, TimeoutMs: 10000}, + {ID: "step_004", Type: "snapshot", CheckpointID: "dashboard", Name: "Dashboard"}, + }, + } +} + +func TestValidateOK(t *testing.T) { + if err := valid().Validate(); err != nil { + t.Fatal(err) + } +} + +func TestValidateRejects(t *testing.T) { + cases := map[string]func(w *Workflow){ + "unknown step type": func(w *Workflow) { w.Steps[1].Type = "exec" }, + "bad scheme": func(w *Workflow) { w.StartURL = "javascript:alert(1)" }, + "file scheme": func(w *Workflow) { w.StartURL = "file:///etc/passwd" }, + "missing target": func(w *Workflow) { w.Steps[1].Target = nil }, + "literal in password": func(w *Workflow) { + w.Steps[0].Target.Name = "Password" + w.Steps[0].ValueRef = "" + w.Steps[0].Value = "hunter2" + }, + "both value and ref": func(w *Workflow) { w.Steps[0].Value = "x" }, + "duplicate step id": func(w *Workflow) { w.Steps[1].ID = "step_001" }, + "duplicate checkpoint": func(w *Workflow) { + w.Steps = append(w.Steps, Step{ID: "step_005", Type: "snapshot", CheckpointID: "dashboard", Name: "again"}) + }, + "unsafe checkpoint": func(w *Workflow) { w.Steps[3].CheckpointID = "../evil" }, + "no steps": func(w *Workflow) { w.Steps = nil }, + "bad schema": func(w *Workflow) { w.SchemaVersion = 99 }, + "bad condition": func(w *Workflow) { w.Steps[2].Condition = "sleep" }, + "timeout too large": func(w *Workflow) { w.Steps[2].TimeoutMs = 99999999 }, + "bad valueRef": func(w *Workflow) { w.Steps[0].ValueRef = "a b;rm -rf" }, + "navigate bad url": func(w *Workflow) { w.Steps = append(w.Steps, Step{ID: "step_009", Type: "navigate", URL: "ftp://x"}) }, + "snapshot without name": func(w *Workflow) { w.Steps[3].Name = " " }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + w := valid() + mutate(w) + if err := w.Validate(); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +func TestStoreRoundTripAndUpdate(t *testing.T) { + s, _ := NewStore(t.TempDir()) + w := valid() + if err := s.Create(w); err != nil { + t.Fatal(err) + } + if !ValidID(w.WorkflowID) || w.Version != 1 { + t.Fatalf("bad id/version: %s %d", w.WorkflowID, w.Version) + } + got, err := s.Get(w.WorkflowID) + if err != nil || got.Name != "Dashboard" || len(got.Steps) != 4 { + t.Fatalf("round trip failed: %v %+v", err, got) + } + + upd := valid() + upd.Name = "Renamed" + if err := s.Update(w.WorkflowID, upd); err != nil { + t.Fatal(err) + } + got, _ = s.Get(w.WorkflowID) + if got.Name != "Renamed" || got.Version != 1 || !got.CreatedAt.Equal(w.CreatedAt) { + t.Fatalf("overwrite semantics wrong: %+v", got) + } + + list, _ := s.List() + if len(list) != 1 { + t.Fatalf("list = %d", len(list)) + } +} + +func TestStoreErrors(t *testing.T) { + dir := t.TempDir() + s, _ := NewStore(dir) + if _, err := s.Get("wf_missing"); err != ErrNotFound { + t.Fatalf("want ErrNotFound, got %v", err) + } + for _, id := range []string{"../x", "a/b", "", "WF_UP", "wf 1", "..", strings.Repeat("a", 100)} { + if _, err := s.Get(id); err != ErrInvalidID { + t.Fatalf("id %q: want ErrInvalidID, got %v", id, err) + } + if err := s.Update(id, valid()); err != ErrInvalidID { + t.Fatalf("update id %q: want ErrInvalidID, got %v", id, err) + } + } + // corrupt file is reported on Get, skipped on List + if err := os.WriteFile(filepath.Join(dir, "wf_bad.json"), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.Get("wf_bad"); err == nil || err == ErrNotFound { + t.Fatalf("expected corrupt error, got %v", err) + } + if list, err := s.List(); err != nil || len(list) != 0 { + t.Fatalf("list should skip corrupt: %v %d", err, len(list)) + } + if err := s.Update("wf_nope", valid()); err != ErrNotFound { + t.Fatalf("want ErrNotFound, got %v", err) + } +} + +func TestConcurrentUpdatesLeaveValidFile(t *testing.T) { + s, _ := NewStore(t.TempDir()) + w := valid() + if err := s.Create(w); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + u := valid() + _ = s.Update(w.WorkflowID, u) + }() + } + wg.Wait() + if _, err := s.Get(w.WorkflowID); err != nil { + t.Fatalf("file corrupt after concurrent writes: %v", err) + } + entries, _ := os.ReadDir(s.dir) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".tmp-") { + t.Fatalf("leftover temp file %s", e.Name()) + } + } +} + +func TestSnapshotWaitAndOptionsValid(t *testing.T) { + w := valid() + w.Steps[3].WaitMs = 4000 + w.Steps[3].Options = &SnapshotOptions{Widths: []int{375, 1280}, MinHeight: 1024, Scope: ".main", EnableJavaScript: true, PercyCSS: ".a{}"} + if err := w.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestSnapshotWaitAndOptionsRejects(t *testing.T) { + cases := map[string]func(w *Workflow){ + "negative wait": func(w *Workflow) { w.Steps[3].WaitMs = -1 }, + "wait too large": func(w *Workflow) { w.Steps[3].WaitMs = 60001 }, + "wait on click": func(w *Workflow) { w.Steps[1].WaitMs = 1000 }, + "options on click": func(w *Workflow) { w.Steps[1].Options = &SnapshotOptions{Scope: ".a"} }, + "zero width": func(w *Workflow) { w.Steps[3].Options = &SnapshotOptions{Widths: []int{0}} }, + "huge width": func(w *Workflow) { w.Steps[3].Options = &SnapshotOptions{Widths: []int{99999}} }, + "too many widths": func(w *Workflow) { w.Steps[3].Options = &SnapshotOptions{Widths: make([]int, 11)} }, + "negative minHeight": func(w *Workflow) { w.Steps[3].Options = &SnapshotOptions{MinHeight: -5} }, + "css too long": func(w *Workflow) { w.Steps[3].Options = &SnapshotOptions{PercyCSS: strings.Repeat("a", 10001)} }, + "scope too long": func(w *Workflow) { w.Steps[3].Options = &SnapshotOptions{Scope: strings.Repeat("a", 501)} }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + w := valid() + mutate(w) + if err := w.Validate(); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + +// Workflows saved before waitMs/options existed must still load and validate. +func TestLegacySnapshotStepStillValid(t *testing.T) { + w := valid() // snapshot step has no WaitMs/Options + if w.Steps[3].WaitMs != 0 || w.Steps[3].Options != nil { + t.Fatal("fixture should be legacy-shaped") + } + if err := w.Validate(); err != nil { + t.Fatal(err) + } +} diff --git a/percy-local-manager-extension/library-snapshot.html b/percy-local-manager-extension/library-snapshot.html deleted file mode 100644 index a859d06..0000000 --- a/percy-local-manager-extension/library-snapshot.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Percy Snapshots - - - -
- - - - \ No newline at end of file diff --git a/percy-local-manager-extension/package.json b/percy-local-manager-extension/package.json index 8b0e89d..a8e4d0d 100644 --- a/percy-local-manager-extension/package.json +++ b/percy-local-manager-extension/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" + "build": "tsc -b && vite build && vite build --config vite.content.config.ts", + "preview": "vite preview", + "test:unit": "node --test tests/*.test.mjs" }, "dependencies": { "react": "^18.3.1", diff --git a/percy-local-manager-extension/public/manifest.json b/percy-local-manager-extension/public/manifest.json index fafec63..7fffe4b 100644 --- a/percy-local-manager-extension/public/manifest.json +++ b/percy-local-manager-extension/public/manifest.json @@ -8,11 +8,24 @@ "default_title": "Percy Local Manager", "default_icon": "icons/percy-icon.png" }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": false + } + ], "permissions": [ "activeTab", "scripting", "storage", - "tabs" + "tabs", + "webNavigation" ], "host_permissions": [ "http://localhost:4321/*" diff --git a/percy-local-manager-extension/src/background/recorder.ts b/percy-local-manager-extension/src/background/recorder.ts new file mode 100644 index 0000000..7e7af69 --- /dev/null +++ b/percy-local-manager-extension/src/background/recorder.ts @@ -0,0 +1,256 @@ +/** + * Service worker: owns recording state so it survives the popup closing. + * State is mirrored to chrome.storage.session because MV3 workers are + * suspended when idle. + */ +import { addSnapshot, appendStep, renumber } from '../utils/recorderCore'; +import { createWorkflow } from '../services/workflowApi'; +import type { RecordingState, WorkflowStep } from '../types/workflow'; + +const KEY = 'recordingState'; +const EMPTY: RecordingState = { active: false, steps: [] }; + +async function load(): Promise { + const stored = await chrome.storage.session.get(KEY); + return (stored[KEY] as RecordingState | undefined) ?? EMPTY; +} + +async function save(state: RecordingState): Promise { + await chrome.storage.session.set({ [KEY]: state }); + // Keep the on-page recorder widget live (it also listens to storage, but a + // direct message keeps it in sync across worker restarts). + if (state.tabId !== undefined) { + try { + await chrome.tabs.sendMessage(state.tabId, { kind: 'rec:state', state }); + } catch { + /* page has no content script */ + } + } +} + +async function notifyTab(tabId: number | undefined, active: boolean) { + if (tabId === undefined) return; + try { + await chrome.tabs.sendMessage(tabId, { kind: 'rec:active', active }); + } catch { + /* content script not present on this page */ + } +} + +/** Asks the page for its viewport; resolves undefined if no content script answers. */ +async function askViewport(tabId: number): Promise<{ width: number; height: number } | undefined> { + try { + const res = (await chrome.tabs.sendMessage(tabId, { kind: 'rec:viewport' })) as + | { width: number; height: number } + | undefined; + return res?.width && res?.height ? res : undefined; + } catch { + return undefined; + } +} + +async function start(): Promise { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id || !tab.url || !/^https?:/.test(tab.url)) { + throw new Error('Open an http(s) page in the active tab to record.'); + } + // Pages opened before the extension was installed/reloaded have no content + // script yet. activeTab (granted by the popup click) lets us inject it now. + let measured = await askViewport(tab.id); + if (!measured) { + try { + await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] }); + measured = await askViewport(tab.id); + } catch { + throw new Error('Could not attach the recorder to this page. Reload the page and try again.'); + } + } + const viewport = measured ?? { width: 1280, height: 800 }; + const state: RecordingState = { + active: true, + tabId: tab.id, + startUrl: tab.url, + viewport, + steps: [], + }; + await save(state); + await notifyTab(tab.id, true); + return state; +} + +async function stop(): Promise { + const state = await load(); + const next = { ...state, active: false, paused: false }; + await save(next); + // Send the full state (with steps) so the content-script widget can render + // the save form immediately without waiting for a page reload. + if (state.tabId !== undefined) { + try { + await chrome.tabs.sendMessage(state.tabId, { kind: 'rec:state', state: next }); + } catch { + /* tab closed or no content script */ + } + } + return next; +} + +async function clear(): Promise { + const state = await load(); + await notifyTab(state.tabId, false); + await chrome.storage.session.set({ [KEY]: EMPTY }); + // EMPTY has no tabId, so tell the previously recorded tab directly: this is + // what removes the floating panel after Save / Discard. + if (state.tabId !== undefined) { + try { + await chrome.tabs.sendMessage(state.tabId, { kind: 'rec:state', state: EMPTY }); + } catch { + /* tab closed or no content script */ + } + } + return EMPTY; +} + +const NEEDS_TARGET = new Set(['click', 'fill', 'select', 'check', 'uncheck']); + +function hasLocator(step: Omit): boolean { + const t = step.target; + return !!(t && (t.testId || t.role || t.css || t.text)); +} + +/** Saves the finished recording through the Go API. Runs here, not in the page, + * because an https page may not call http://localhost (mixed content). */ +async function saveWorkflow(name: string): Promise<{ workflowId?: string; name: string }> { + const state = await load(); + if (state.active) throw new Error('Stop recording before saving.'); + if (!state.startUrl || !state.viewport) throw new Error('Nothing recorded.'); + if (!state.steps.some((s) => s.type === 'snapshot')) { + throw new Error('Add at least one Capture Snapshot checkpoint before saving.'); + } + const trimmed = name.trim(); + if (!trimmed) throw new Error('Enter a workflow name.'); + const bad = state.steps.findIndex((s) => NEEDS_TARGET.has(s.type) && !hasLocator(s)); + if (bad >= 0) { + throw new Error(`Step ${bad + 1} (${state.steps[bad].type}) has no locator. Delete it in the panel and save again.`); + } + const saved = await createWorkflow({ + name: trimmed, + schemaVersion: 1, + startUrl: state.startUrl, + steps: renumber(state.steps), + settings: { defaultTimeoutMs: 10000, viewport: state.viewport }, + }); + await clear(); + return { workflowId: saved.workflowId, name: saved.name }; +} + +/** Pause: keep the recording open but stop capturing user actions. */ +async function pause(): Promise { + const state = await load(); + if (!state.active) throw new Error('Not recording.'); + const next = { ...state, paused: true }; + await save(next); + await notifyTab(state.tabId, false); + return next; +} + +/** + * Resume capturing. If the user navigated while paused, add a navigate step so + * replay reaches the same page the following actions were recorded on. + */ +async function resume(): Promise { + const state = await load(); + if (!state.active) throw new Error('Not recording.'); + let steps = state.steps; + if (state.tabId !== undefined) { + try { + const tab = await chrome.tabs.get(state.tabId); + const lastNav = [...steps].reverse().find((s) => s.type === 'navigate')?.url ?? state.startUrl; + if (tab.url && /^https?:/.test(tab.url) && tab.url !== lastNav) { + steps = appendStep(steps, { type: 'navigate', url: tab.url }); + } + } catch { + /* tab gone: nothing to align */ + } + } + const next = { ...state, paused: false, steps }; + await save(next); + await notifyTab(state.tabId, true); + return next; +} + +async function onEvent(step: Omit, senderTab?: number): Promise { + const state = await load(); + if (!state.active || state.paused || state.tabId !== senderTab) return; + // A step the backend would reject (no locator) is noise, not a user action. + if (NEEDS_TARGET.has(step.type) && !hasLocator(step)) return; + await save({ ...state, steps: appendStep(state.steps, step) }); +} + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + const run = async () => { + switch (msg?.kind) { + case 'rec:start': + return start(); + case 'rec:stop': + return stop(); + case 'rec:pause': + return pause(); + case 'rec:resume': + return resume(); + case 'rec:get': + return load(); + case 'rec:clear': + return clear(); + case 'rec:snapshot': { + const state = await load(); + if (!state.active) throw new Error('Not recording.'); + const waitMs = typeof msg.waitMs === 'number' && msg.waitMs >= 0 && msg.waitMs <= 60000 ? Math.round(msg.waitMs) : undefined; + const next = { + ...state, + steps: addSnapshot(state.steps, String(msg.name ?? ''), { waitMs, options: msg.options }), + }; + await save(next); + return next; + } + case 'rec:save': + return saveWorkflow(String(msg.name ?? '')); + case 'rec:setSteps': { + const state = await load(); + await save({ ...state, steps: renumber(msg.steps as WorkflowStep[]) }); + return { ok: true }; + } + case 'rec:event': + await onEvent(msg.step, sender.tab?.id); + return { ok: true }; + case 'rec:hello': { + const state = await load(); + // Also return the state for the recorded tab so the on-page widget + // reappears after a full-page navigation. + const mine = state.tabId === sender.tab?.id; + return { active: state.active && !state.paused && mine, state: mine ? state : undefined }; + } + default: + return undefined; + } + }; + run() + .then((r) => sendResponse(r)) + .catch((e) => sendResponse({ error: String(e?.message ?? e) })); + return true; // async response +}); + +// Record full-page navigations in the recorded tab as navigate steps. +chrome.webNavigation?.onCommitted.addListener(async (d) => { + if (d.frameId !== 0) return; + const state = await load(); + if (!state.active || state.tabId !== d.tabId) return; + // Paused: do not capture navigations or switch the page's recording back on. + // Resume adds one navigate step if the URL changed in the meantime. + if (state.paused) return; + // Only user-driven navigations (typed/link/reload-like); skip the initial load and form/link + // navigations already implied by the preceding click. + if (d.transitionType === 'typed' || d.transitionType === 'auto_bookmark') { + await save({ ...state, steps: appendStep(state.steps, { type: 'navigate', url: d.url }) }); + } + await notifyTab(d.tabId, true); +}); diff --git a/percy-local-manager-extension/src/components/BuildProgress.tsx b/percy-local-manager-extension/src/components/BuildProgress.tsx new file mode 100644 index 0000000..64deeb4 --- /dev/null +++ b/percy-local-manager-extension/src/components/BuildProgress.tsx @@ -0,0 +1,181 @@ +import { useEffect, useRef, useState } from 'react'; +import type { BuildPhase, BuildStatus } from '../types'; + +const PHASES: { key: BuildPhase; label: string }[] = [ + { key: 'installing', label: 'Install' }, + { key: 'starting', label: 'Start' }, + { key: 'uploading', label: 'Upload' }, + { key: 'processing', label: 'Process' }, + { key: 'done', label: 'Done' }, +]; + +const STATE_LABEL = { + pending: 'Waiting', + sending: 'Uploading', + sent: 'Sent', + failed: 'Failed', +} as const; + +const STATE_ICON = { + pending: '', + sending: '', + sent: '✓', + failed: '✕', +} as const; + +interface BuildProgressProps { + build: BuildStatus; +} + +export function BuildProgress({ build }: BuildProgressProps) { + const [logsOpen, setLogsOpen] = useState(false); + const [copied, setCopied] = useState(false); + const logEndRef = useRef(null); + + const phaseIndex = PHASES.findIndex((p) => p.key === build.phase); + const failed = build.status === 'failed'; + const succeeded = build.status === 'success'; + const percent = + build.total > 0 ? Math.round((build.sent / build.total) * 100) : 0; + const errorCount = build.logs.filter((l) => l.level === 'error').length; + + useEffect(() => { + if (logsOpen) { + logEndRef.current?.scrollIntoView({ block: 'end' }); + } + }, [build.logs.length, logsOpen]); + + async function handleCopy() { + const text = build.logs + .map((l) => `${l.ts} [${l.level}] ${l.line}`) + .join('\n'); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + } + + return ( +
+
    + {PHASES.map((p, i) => { + let state = 'todo'; + if (succeeded || i < phaseIndex) state = 'done'; + else if (i === phaseIndex) state = failed ? 'failed' : 'active'; + return ( +
  1. + + {state === 'done' ? '✓' : state === 'failed' ? '✕' : i + 1} + + {p.label} +
  2. + ); + })} +
+ +
+
+ + {build.sent} + /{build.total} + + snapshots sent + {percent}% +
+
+
+
+
+ + {failed && build.error && ( +
+ Build failed + {build.error} +
+ )} + +
    + {build.snapshots.map((s) => ( +
  • + +
    + + {s.name} + + {s.error && ( + {s.error} + )} +
    + {STATE_LABEL[s.state]} +
  • + ))} +
+ +
+
+ + +
+ {logsOpen && ( +
+ {build.logs.length === 0 && ( +
No output yet…
+ )} + {build.logs.map((l, i) => ( +
+ + {l.level === 'error' ? 'ERR' : l.level === 'warn' ? 'WRN' : 'INF'} + + {l.line} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/percy-local-manager-extension/src/components/EditStepPanel.tsx b/percy-local-manager-extension/src/components/EditStepPanel.tsx new file mode 100644 index 0000000..953e7fd --- /dev/null +++ b/percy-local-manager-extension/src/components/EditStepPanel.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react'; +import type { Target, WorkflowStep } from '../types/workflow'; +import { SnapshotOptionsFields, draftFromStep, parseDraft } from './SnapshotOptionsFields'; + +interface EditStepPanelProps { + step: WorkflowStep; + onSave: (step: WorkflowStep) => void; + onCancel: () => void; +} + +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; + +/** + * Edits the parts of a step users actually need to tweak: label, locator, + * value / secret reference and timeout. A literal value can never replace a + * valueRef, so a recorded secret can't be turned into stored plaintext here. + */ +export function EditStepPanel({ step, onSave, onCancel }: EditStepPanelProps) { + const [label, setLabel] = useState(step.label ?? ''); + const [name, setName] = useState(step.name ?? ''); + const [target, setTarget] = useState(step.target ?? {}); + const [value, setValue] = useState(step.value ?? ''); + const [valueRef, setValueRef] = useState(step.valueRef ?? ''); + const [timeout, setTimeoutMs] = useState(step.timeoutMs ? String(step.timeoutMs) : ''); + const [snapDraft, setSnapDraft] = useState(() => draftFromStep(step.waitMs, step.options)); + const [error, setError] = useState(null); + + const usesRef = Boolean(step.valueRef); + const hasValue = step.type === 'fill' || step.type === 'select'; + const hasTarget = Boolean(step.target); + + function setTargetField(key: keyof Target, v: string) { + setTarget((t) => ({ ...t, [key]: v || undefined })); + } + + function save() { + if (usesRef && !REF_PATTERN.test(valueRef)) { + setError('Reference must be letters, digits and underscores (not starting with a digit).'); + return; + } + const ms = timeout === '' ? undefined : Number(timeout); + if (ms !== undefined && (!Number.isFinite(ms) || ms < 0 || ms > 300000)) { + setError('Timeout must be between 0 and 300000 ms.'); + return; + } + if (step.type === 'snapshot' && !name.trim()) { + setError('Snapshot name is required.'); + return; + } + const updated: WorkflowStep = { + ...step, + label: label.trim() || undefined, + timeoutMs: ms || undefined, + }; + if (step.type === 'snapshot') { + const parsed = parseDraft(snapDraft); + if ('error' in parsed) { + setError(parsed.error); + return; + } + updated.waitMs = parsed.waitMs; + updated.options = parsed.options; + } + if (hasTarget) updated.target = target; + if (hasValue) { + if (usesRef) updated.valueRef = valueRef; + else updated.value = value; + } + if (step.type === 'snapshot') updated.name = name.trim(); + onSave(updated); + } + + const id = step.id; + return ( +
+ + setLabel(e.target.value)} + /> + + {step.type === 'snapshot' && ( + <> + + setName(e.target.value)} + /> + + + )} + + {hasTarget && ( + <> + + setTargetField('testId', e.target.value)} + /> + + setTargetField('role', e.target.value)} + /> + + setTargetField('name', e.target.value)} + /> + + setTargetField('css', e.target.value)} + /> + + )} + + {hasValue && + (usesRef ? ( + <> + + setValueRef(e.target.value)} + /> +

Resolved when the workflow is replayed. The value is never saved.

+ + ) : ( + <> + + setValue(e.target.value)} + /> + + ))} + + + setTimeoutMs(e.target.value)} + /> + + {error &&

{error}

} + +
+ + +
+
+ ); +} diff --git a/percy-local-manager-extension/src/components/FinalizeBuildPanel.tsx b/percy-local-manager-extension/src/components/FinalizeBuildPanel.tsx index bbf4562..9ad8644 100644 --- a/percy-local-manager-extension/src/components/FinalizeBuildPanel.tsx +++ b/percy-local-manager-extension/src/components/FinalizeBuildPanel.tsx @@ -1,19 +1,30 @@ +import { createPortal } from 'react-dom'; import { useFinalizeBuild } from '../hooks/useFinalizeBuild'; +import { BuildProgress } from './BuildProgress'; interface FinalizeBuildPanelProps { token: string; disabled: boolean; onFinalized: () => void; + /** + * Optional element to render the progress card into, so a page can place it + * outside its action bar. Falls back to rendering inline. + */ + progressHost?: HTMLElement | null; } export function FinalizeBuildPanel({ token, disabled, onFinalized, + progressHost, }: FinalizeBuildPanelProps) { - const { status, error, result, finalize } = useFinalizeBuild(onFinalized); + const { status, error, result, build, finalize } = + useFinalizeBuild(onFinalized); - const isFinalizing = status === 'loading'; + const isRunning = status === 'loading' || build?.status === 'running'; + const progress = + build && build.status !== 'idle' ? : null; function handleOpenBuild() { if (result?.buildUrl) { @@ -23,27 +34,33 @@ export function FinalizeBuildPanel({ return (
- - - {status === 'success' && result && ( -
-

✓ Build finished

- - -
- )} - - {status === 'error' && ( -

{error}

- )} +
+ + + {status === 'success' && result && ( +
+

✓ Build finished

+ + +
+ )} + + {/* When the progress card is showing, it carries the error itself. */} + {status === 'error' && !progress && ( +

{error}

+ )} +
+ + {progress && + (progressHost ? createPortal(progress, progressHost) : progress)}
); -} \ No newline at end of file +} diff --git a/percy-local-manager-extension/src/components/LibraryTokenPanel.tsx b/percy-local-manager-extension/src/components/LibraryTokenPanel.tsx deleted file mode 100644 index 7ac2ce6..0000000 --- a/percy-local-manager-extension/src/components/LibraryTokenPanel.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useState } from 'react'; -import { useLibraryToken } from '../hooks/useLibraryToken'; - -export function LibraryTokenPanel() { - const { isConnected, count, isChecking, saveStatus, error, saveToken } = useLibraryToken(); - const [token, setToken] = useState(''); - const [isEditing, setIsEditing] = useState(false); - const isSaving = saveStatus === 'loading'; - - if (isChecking) return null; // avoid a flash of the input before we know - - if (isConnected && !isEditing) { - return ( -
-
-
- -
- ); - } - - return ( -
- Library Token - - setToken(e.target.value)} - disabled={isSaving} - /> - - {saveStatus === 'error' &&

{error}

} -
- ); -} \ No newline at end of file diff --git a/percy-local-manager-extension/src/components/RecordLauncher.tsx b/percy-local-manager-extension/src/components/RecordLauncher.tsx new file mode 100644 index 0000000..52b5ba9 --- /dev/null +++ b/percy-local-manager-extension/src/components/RecordLauncher.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { useRecorder } from '../hooks/useRecorder'; +import { RecordedSteps } from './RecordedSteps'; +import type { WorkflowStep } from '../types/workflow'; + +interface RecordLauncherProps { + disabled: boolean; +} + +/** + * Popup entry point for workflow recording. Starting a recording closes the + * popup; the on-page floating panel takes over from there. + */ +export function RecordLauncher({ disabled }: RecordLauncherProps) { + const recorder = useRecorder(); + const { state } = recorder; + const hasUnsaved = !state.active && state.steps.length > 0; + const hasSnapshot = state.steps.some((s) => s.type === 'snapshot'); + + const [workflowName, setWorkflowName] = useState(''); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [savedName, setSavedName] = useState(null); + + async function handleRecord() { + await recorder.start(); + // On failure useRecorder sets `error` and the popup stays open to show it. + const next = (await chrome.runtime.sendMessage({ kind: 'rec:get' })) as { active?: boolean }; + if (next?.active) window.close(); + } + + async function handleSave() { + setSaving(true); + setSaveError(null); + try { + const res = (await chrome.runtime.sendMessage({ kind: 'rec:save', name: workflowName })) as { name?: string; error?: string }; + if (res?.error) throw new Error(res.error); + setSavedName(res?.name ?? workflowName); + setWorkflowName(''); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + } + + function handleStepsChange(steps: WorkflowStep[]) { + void recorder.setSteps(steps); + } + + return ( +
+ Workflow + + {state.active ? ( +

+ Recording in progress. Use the floating panel on the page. +

+ ) : hasUnsaved ? ( + <> + + {!hasSnapshot && ( +

Add at least one Capture Snapshot checkpoint to save.

+ )} + + setWorkflowName(e.target.value)} + /> +
+ + +
+ {saveError &&

{saveError}

} + + ) : null} + + {savedName && ( +

+ {`✓ Saved "${savedName}"`} +

+ )} + + {!hasUnsaved && !state.active && ( + <> + +

Replay saved workflows from the Snapshots page.

+ + )} + + {recorder.error &&

{recorder.error}

} +
+ ); +} diff --git a/percy-local-manager-extension/src/components/RecordedSteps.tsx b/percy-local-manager-extension/src/components/RecordedSteps.tsx new file mode 100644 index 0000000..4a3d0ae --- /dev/null +++ b/percy-local-manager-extension/src/components/RecordedSteps.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import type { Target, WorkflowStep } from '../types/workflow'; +import { EditStepPanel } from './EditStepPanel'; + +interface RecordedStepsProps { + steps: WorkflowStep[]; + editable: boolean; + onChange: (steps: WorkflowStep[]) => void; +} + +export function describeTarget(target?: Target): string { + if (!target) return ''; + if (target.testId) return `[data-testid=${target.testId}]`; + if (target.role && target.name) return `${target.role} "${target.name}"`; + if (target.css) return target.css; + if (target.text) return `text "${target.text}"`; + return ''; +} + +export function describeStep(step: WorkflowStep): string { + const where = describeTarget(step.target); + switch (step.type) { + case 'click': + return `Click ${where}`; + case 'fill': + return step.valueRef + ? `Fill ${where} with {${step.valueRef}}` + : `Fill ${where}`; + case 'select': + return `Select "${step.value ?? ''}" in ${where}`; + case 'check': + return `Check ${where}`; + case 'uncheck': + return `Uncheck ${where}`; + case 'navigate': + return `Go to ${step.url ?? ''}`; + case 'waitFor': + return `Wait for ${where} to be ${step.condition || 'visible'}`; + case 'scroll': + return `Scroll to (${step.scrollX ?? 0}, ${step.scrollY ?? 0})`; + case 'snapshot': + return `Snapshot: ${step.name ?? step.checkpointId ?? ''}${ + step.waitMs !== undefined ? ` (wait ${step.waitMs / 1000}s)` : '' + }`; + default: + return step.type; + } +} + +export function RecordedSteps({ steps, editable, onChange }: RecordedStepsProps) { + const [editingId, setEditingId] = useState(null); + const [confirmId, setConfirmId] = useState(null); + + if (steps.length === 0) { + return

No steps recorded yet.

; + } + + function replace(updated: WorkflowStep) { + onChange(steps.map((s) => (s.id === updated.id ? updated : s))); + setEditingId(null); + } + + function remove(id: string) { + onChange(steps.filter((s) => s.id !== id)); + setConfirmId(null); + } + + function move(index: number, delta: number) { + const next = [...steps]; + const to = index + delta; + if (to < 0 || to >= next.length) return; + [next[index], next[to]] = [next[to], next[index]]; + onChange(next); + } + + return ( +
    + {steps.map((step, index) => ( +
  1. + {editingId === step.id ? ( + setEditingId(null)} + /> + ) : ( + <> + + {step.label || describeStep(step)} + + {editable && confirmId === step.id && ( + + Delete this step? + + + + )} + {editable && confirmId !== step.id && ( + + + + + + + )} + + )} +
  2. + ))} +
+ ); +} diff --git a/percy-local-manager-extension/src/components/SnapshotOptionsFields.tsx b/percy-local-manager-extension/src/components/SnapshotOptionsFields.tsx new file mode 100644 index 0000000..6c394a9 --- /dev/null +++ b/percy-local-manager-extension/src/components/SnapshotOptionsFields.tsx @@ -0,0 +1,154 @@ +import type { SnapshotStepOptions } from '../types/workflow'; + +export interface SnapshotOptionsDraft { + waitSeconds: string; + widths: string[]; + minHeight: string; + scope: string; + enableJavaScript: boolean; + percyCSS: string; +} + +export function draftFromStep(waitMs: number | undefined, o?: SnapshotStepOptions): SnapshotOptionsDraft { + return { + waitSeconds: String((waitMs ?? 4000) / 1000), + widths: (o?.widths?.length ? o.widths : []).map(String), + minHeight: o?.minHeight ? String(o.minHeight) : '', + scope: o?.scope ?? '', + enableJavaScript: o?.enableJavaScript ?? false, + percyCSS: o?.percyCSS ?? '', + }; +} + +/** Validates the draft; returns an error string or the values to store. */ +export function parseDraft( + d: SnapshotOptionsDraft, +): { error: string } | { waitMs: number; options: SnapshotStepOptions | undefined } { + const sec = Number(d.waitSeconds); + if (d.waitSeconds.trim() === '' || !Number.isFinite(sec) || sec < 0 || sec > 60) { + return { error: 'Wait must be between 0 and 60 seconds.' }; + } + const widths: number[] = []; + for (const w of d.widths) { + if (w.trim() === '') continue; + const n = Number(w); + if (!Number.isInteger(n) || n < 1 || n > 4000) return { error: 'Widths must be whole numbers from 1 to 4000.' }; + widths.push(n); + } + if (widths.length > 10) return { error: 'At most 10 widths.' }; + let minHeight: number | undefined; + if (d.minHeight.trim() !== '') { + minHeight = Number(d.minHeight); + if (!Number.isInteger(minHeight) || minHeight < 0 || minHeight > 20000) { + return { error: 'Minimum height must be 0 to 20000.' }; + } + } + if (d.scope.length > 500) return { error: 'Scope is too long.' }; + if (d.percyCSS.length > 10000) return { error: 'Percy CSS is too long.' }; + const options: SnapshotStepOptions = {}; + if (widths.length) options.widths = widths; + if (minHeight) options.minHeight = minHeight; + if (d.scope.trim()) options.scope = d.scope.trim(); + if (d.enableJavaScript) options.enableJavaScript = true; + if (d.percyCSS.trim()) options.percyCSS = d.percyCSS.trim(); + return { waitMs: Math.round(sec * 1000), options: Object.keys(options).length ? options : undefined }; +} + +interface Props { + idPrefix: string; + value: SnapshotOptionsDraft; + onChange: (next: SnapshotOptionsDraft) => void; +} + +/** Wait-before-capture + advanced Percy options for one snapshot step. */ +export function SnapshotOptionsFields({ idPrefix, value, onChange }: Props) { + const set = (k: K, v: SnapshotOptionsDraft[K]) => + onChange({ ...value, [k]: v }); + + return ( + <> + + set('waitSeconds', e.target.value)} + /> + + + {value.widths.map((w, i) => ( +
+ set('widths', value.widths.map((x, j) => (j === i ? e.target.value : x)))} + /> + +
+ ))} + + + + set('minHeight', e.target.value)} + /> + + + set('scope', e.target.value)} + /> + + + + +