ci: Product CI merge gate and Autopilot auto-merge (#84)
* ci: add Product CI merge gate and Autopilot auto-merge path
Aggregate lint, typecheck, builds, unit, Postgres journeys, and Web E2E
into a single Product CI check for branch protection. Soft-fail artifact
uploads and skip headless macOS screenshots so infra noise does not block
merge. Add autopilot/auto-merge workflow and MERGE_GATES.md.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(ci): keep autopilot auto-merge inert without label
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(ci): run autopilot auto-merge on self-hosted runner
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(ci): free stale E2E ports before Playwright bind on Linux
Self-hosted evoo-ci runners can retain listeners on harness ports
(3110 API, 5180 web, 4321 marketing, 3111 device control) after a
failed run, causing EADDRINUSE before the next job. Add a guarded
free-e2e-ports.sh that uses fuser on Linux only and never touches
live dev ports 3100/5173. Wire it into the Playwright workflow and
the E2E harness with clear logging.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(web): stabilize message More menu Escape in hover E2E
The desktop hover spec assumed Escape always closed the first page-level
menu within one keypress. On self-hosted Chromium the Copy menu could stay
open while the rail remained visible, failing getByRole('menu') count.
Wait for the Copy item to prove the menu opened, then retry Escape until
that item hides while asserting the More control stays on the hover rail.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(web): retry sign-up navigation in Playwright helpers
Late-suite specs can miss the sign-up heading when Vite is still
warming on self-hosted runners. Retry goto until the form renders
instead of failing once at the default timeout.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
15379e8fec
commit
5cd1bc906f
66
.github/MERGE_GATES.md
vendored
Normal file
66
.github/MERGE_GATES.md
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
# CI merge gates
|
||||
|
||||
This repository splits continuous integration into **product** checks that should block merge and **infra** checks that provide signal without blocking Autopilot merges.
|
||||
|
||||
## Required for merge (product)
|
||||
|
||||
Configure branch protection on `main` to require exactly one status check from the `ci` workflow:
|
||||
|
||||
| Check name | Workflow | What it covers |
|
||||
| --- | --- | --- |
|
||||
| **Product CI** | `ci.yml` | Lint, Typecheck, Production builds, Unit tests, Postgres journeys, Web E2E |
|
||||
|
||||
The aggregate `Product CI` job fails when any of those product jobs fail. Individual job names remain visible on the pull request for debugging.
|
||||
|
||||
Optional but recommended repository settings:
|
||||
|
||||
- Enable **Allow auto-merge**
|
||||
- Enable **Automatically delete head branches** after merge
|
||||
|
||||
## Infra-only (never required)
|
||||
|
||||
These workflows may fail without blocking product delivery. Do **not** add them to required status checks.
|
||||
|
||||
| Check / workflow | Why infra |
|
||||
| --- | --- |
|
||||
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
|
||||
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
|
||||
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
|
||||
| `mobile Android screenshots` | Manual `workflow_dispatch` only |
|
||||
| `nightly verification` | Scheduled visual regression |
|
||||
| `manual topology verification` | Manual smoke |
|
||||
| `release-desktop.yml` | Tag-driven release pipeline |
|
||||
| `publish-playwright-report.yml` | Report publishing helper |
|
||||
|
||||
## Autopilot auto-merge
|
||||
|
||||
Workflow: `.github/workflows/autopilot-auto-merge.yml` (informational — **not** a required check)
|
||||
|
||||
1. Open a pull request from an Autopilot/cloud agent.
|
||||
2. Add label **`autopilot`** or **`automerge`**.
|
||||
3. Wait until **Product CI** is green on the head commit.
|
||||
4. The workflow runs `gh pr merge --auto --squash`. GitHub merges when branch protection is satisfied (required checks green, reviews if configured).
|
||||
|
||||
### Dry-run mode
|
||||
|
||||
Set repository variable `AUTOPILOT_MERGE_DRY_RUN` to `true` to log the merge command without enabling auto-merge.
|
||||
|
||||
### Optional secret: `AUTOPILOT_MERGE_TOKEN`
|
||||
|
||||
Most repositories can rely on the built-in `GITHUB_TOKEN` (`pull-requests: write`).
|
||||
|
||||
Add a fine-grained PAT or GitHub App installation token as **`AUTOPILOT_MERGE_TOKEN`** when auto-merge is rejected because branch protection requires a human actor or bypass permission the default token lacks.
|
||||
|
||||
Minimum scopes:
|
||||
|
||||
- Pull requests: read and write
|
||||
- Contents: read (if merge commits need it under your rules)
|
||||
|
||||
After adding the secret, remove `AUTOPILOT_MERGE_DRY_RUN` or set it to `false`.
|
||||
|
||||
## One-time branch protection checklist (maintainer)
|
||||
|
||||
1. Settings → Branches → `main` → Require status checks before merging.
|
||||
2. Search for **Product CI** and select only that check.
|
||||
3. Remove **macOS setup screenshot**, artifact-only jobs, and other infra workflows from required checks if they were added earlier.
|
||||
4. Enable **Allow auto-merge** if Autopilot should merge without manual clicking.
|
||||
119
.github/workflows/autopilot-auto-merge.yml
vendored
Normal file
119
.github/workflows/autopilot-auto-merge.yml
vendored
Normal file
@ -0,0 +1,119 @@
|
||||
# Enables GitHub auto-merge for Autopilot PRs once the Product CI gate is green.
|
||||
# Label a pull request with `autopilot` or `automerge` to opt in.
|
||||
# This workflow is informational only — never add it to required status checks.
|
||||
name: autopilot auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, synchronize, reopened, ready_for_review]
|
||||
check_suite:
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
concurrency:
|
||||
group: autopilot-auto-merge-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Autopilot auto-merge
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
steps:
|
||||
- name: Resolve pull request
|
||||
id: pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
event_name="${{ github.event_name }}"
|
||||
number=""
|
||||
if [[ "$event_name" == "pull_request" ]]; then
|
||||
number="${{ github.event.pull_request.number }}"
|
||||
elif [[ "$event_name" == "check_suite" ]]; then
|
||||
number="$(jq -r '.check_suite.pull_requests[0].number // empty' "$GITHUB_EVENT_PATH")"
|
||||
fi
|
||||
if [[ -z "$number" ]]; then
|
||||
echo "No pull request in event; skipping."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${number}")"
|
||||
labels="$(jq -r '[.labels[].name] | join(",")' <<<"$pr_json")"
|
||||
enabled=false
|
||||
if [[ ",${labels}," == *",autopilot,"* || ",${labels}," == *",automerge,"* ]]; then
|
||||
enabled=true
|
||||
fi
|
||||
{
|
||||
echo "number=$number"
|
||||
echo "head_sha=$(jq -r .head.sha <<<"$pr_json")"
|
||||
echo "draft=$(jq -r .draft <<<"$pr_json")"
|
||||
echo "labels=$labels"
|
||||
echo "enabled=$enabled"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Skip unless Autopilot label is present
|
||||
if: steps.pr.outputs.enabled != 'true'
|
||||
run: echo "No autopilot/automerge label; skipping."
|
||||
|
||||
- name: Skip draft pull requests
|
||||
if: steps.pr.outputs.enabled == 'true' && steps.pr.outputs.draft == 'true'
|
||||
run: echo "Draft pull request; auto-merge waits for ready_for_review."
|
||||
|
||||
- name: Verify Product CI on head commit
|
||||
id: gate
|
||||
if: steps.pr.outputs.enabled == 'true' && steps.pr.outputs.draft != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t conclusions < <(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
|
||||
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
|
||||
)
|
||||
if [[ "${#conclusions[@]}" -eq 0 ]]; then
|
||||
echo "Product CI has not finished yet."
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
for conclusion in "${conclusions[@]}"; do
|
||||
case "$conclusion" in
|
||||
success)
|
||||
echo "Product CI succeeded."
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
failure|cancelled|timed_out|action_required)
|
||||
echo "Product CI failed ($conclusion); not enabling auto-merge."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo "Product CI still pending."
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.pr.outputs.enabled == 'true' && steps.pr.outputs.draft != 'true' && steps.gate.outputs.ready == 'true'
|
||||
env:
|
||||
AUTOPILOT_MERGE_TOKEN: ${{ secrets.AUTOPILOT_MERGE_TOKEN }}
|
||||
DRY_RUN: ${{ vars.AUTOPILOT_MERGE_DRY_RUN }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$DRY_RUN" == 'true' ]]; then
|
||||
echo "AUTOPILOT_MERGE_DRY_RUN=true — would run: gh pr merge $PR_NUMBER --auto --squash"
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$AUTOPILOT_MERGE_TOKEN" ]]; then
|
||||
export GH_TOKEN="$AUTOPILOT_MERGE_TOKEN"
|
||||
echo "Using AUTOPILOT_MERGE_TOKEN."
|
||||
else
|
||||
export GH_TOKEN="${{ github.token }}"
|
||||
echo "Using GITHUB_TOKEN. Add AUTOPILOT_MERGE_TOKEN if branch protection requires a PAT."
|
||||
fi
|
||||
gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash
|
||||
echo "Auto-merge enabled (squash). GitHub merges when branch protection allows."
|
||||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
@ -1,3 +1,6 @@
|
||||
# Product merge gate: the `Product CI` job aggregates lint, typecheck, builds, unit,
|
||||
# Postgres journeys, and Web E2E. Branch protection should require only that check.
|
||||
# Deploy, OTA, and artifact uploads here are post-merge or soft-fail infra helpers.
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
@ -210,6 +213,36 @@ jobs:
|
||||
with:
|
||||
upload_artifacts: true
|
||||
|
||||
product-gate:
|
||||
name: Product CI
|
||||
if: always()
|
||||
needs: [lint, check, build, test, test-integration, test-e2e]
|
||||
runs-on: [self-hosted, evoo-ci, Linux]
|
||||
steps:
|
||||
- name: Verify product checks
|
||||
env:
|
||||
LINT: ${{ needs.lint.result }}
|
||||
CHECK: ${{ needs.check.result }}
|
||||
BUILD: ${{ needs.build.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
TEST_INTEGRATION: ${{ needs.test-integration.result }}
|
||||
TEST_E2E: ${{ needs.test-e2e.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do
|
||||
result="${!name}"
|
||||
printf '%s=%s\n' "$name" "$result"
|
||||
if [[ "$result" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
if [[ "$failed" -ne 0 ]]; then
|
||||
echo "One or more product checks failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All product checks passed."
|
||||
|
||||
publish-mobile-update:
|
||||
name: Publish compatible mobile update
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
27
.github/workflows/desktop-macos-screenshot.yml
vendored
27
.github/workflows/desktop-macos-screenshot.yml
vendored
@ -1,3 +1,5 @@
|
||||
# Infra-only: native macOS screenshots for PR review. Never add to branch protection.
|
||||
# Skips cleanly on headless runners (no display). Artifact quota failures are soft.
|
||||
name: desktop-macos-screenshot
|
||||
on:
|
||||
push:
|
||||
@ -24,21 +26,37 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
screenshot:
|
||||
name: macOS setup screenshot
|
||||
name: macOS setup screenshot (infra)
|
||||
runs-on: [self-hosted, evoo-ci, macOS]
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Detect interactive display
|
||||
id: display
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if system_profiler SPDisplaysDataType 2>/dev/null | grep -q "Resolution:"; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "available=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No display attached on this macOS runner. Skipping infra screenshot; merge is not blocked."
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.display.outputs.available == 'true'
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v4
|
||||
if: steps.display.outputs.available == 'true'
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.display.outputs.available == 'true'
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
if: steps.display.outputs.available == 'true'
|
||||
- run: pnpm --filter @rakazo/desktop build
|
||||
if: steps.display.outputs.available == 'true'
|
||||
- name: Verify packaged Tahoe icon and legacy fallback
|
||||
if: steps.display.outputs.available == 'true'
|
||||
run: |
|
||||
pnpm --filter @rakazo/web build
|
||||
CSC_IDENTITY_AUTO_DISCOVERY=false pnpm --filter @rakazo/desktop exec electron-builder --mac --dir --publish never -c.mac.identity=null -c.mac.notarize=false
|
||||
@ -51,6 +69,8 @@ jobs:
|
||||
env:
|
||||
RAKAZO_ALLOW_DEV_SECRETS: "1"
|
||||
- name: Capture packaged macOS setup window and Dock
|
||||
if: steps.display.outputs.available == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
RAKAZO_E2E_EXECUTABLE: out/mac-arm64/Rakazo.app/Contents/MacOS/Rakazo
|
||||
run: >-
|
||||
@ -58,12 +78,13 @@ jobs:
|
||||
--config e2e/playwright.config.ts setup.spec.ts
|
||||
--grep "first run asks whether to use a local or existing instance"
|
||||
- name: Upload native macOS setup screenshot
|
||||
if: always()
|
||||
if: always() && steps.display.outputs.available == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-e2e-macos-screenshot
|
||||
path: |
|
||||
apps/desktop/e2e/screenshots/08-setup-macos-native-controls.png
|
||||
apps/desktop/e2e/screenshots/09-macos-dock.png
|
||||
if-no-files-found: error
|
||||
if-no-files-found: warn
|
||||
retention-days: 1
|
||||
|
||||
5
.github/workflows/playwright.yml
vendored
5
.github/workflows/playwright.yml
vendored
@ -111,6 +111,8 @@ jobs:
|
||||
env:
|
||||
BOX_API_KEY: ${{ secrets.BOX_API_KEY }}
|
||||
run: test -n "$BOX_API_KEY"
|
||||
- name: Free stale E2E ports
|
||||
run: bash scripts/free-e2e-ports.sh
|
||||
- name: Run Playwright tests
|
||||
id: playwright_tests
|
||||
env:
|
||||
@ -121,6 +123,9 @@ jobs:
|
||||
run: pnpm test:e2e -- --sandbox="$SANDBOX_TEST_PROVIDER"
|
||||
# Marketing homepage only: separate config so product chromium does not start Astro.
|
||||
# Runs after product e2e so shared apps/web/test-results is not wiped by the product run.
|
||||
- name: Free stale marketing E2E ports
|
||||
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
|
||||
run: bash scripts/free-e2e-ports.sh
|
||||
- name: Run marketing homepage E2E
|
||||
id: www_playwright_tests
|
||||
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
|
||||
|
||||
@ -87,8 +87,12 @@ export async function signup(
|
||||
name: string,
|
||||
testInfo?: TestInfo,
|
||||
) {
|
||||
await page.goto("/sign-up");
|
||||
await expect(page.getByRole("heading", { name: "Create your EvOo" })).toBeVisible();
|
||||
await expect(async () => {
|
||||
await page.goto("/sign-up", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "Create your EvOo" })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}).toPass({ timeout: 45_000 });
|
||||
if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up");
|
||||
await page.getByPlaceholder("Your name").fill(name);
|
||||
await page.getByPlaceholder("Your email address").fill(email);
|
||||
|
||||
@ -10,6 +10,25 @@ async function sendComposerMessage(page: Page, composer: Locator, text: string)
|
||||
await expect(composer).toHaveValue("");
|
||||
}
|
||||
|
||||
/** Copy is unique to the message More menu while it is open. */
|
||||
function messageMoreCopyItem(page: Page) {
|
||||
return page.getByRole("menuitem", { name: "Copy" });
|
||||
}
|
||||
|
||||
async function expectMessageMoreMenu(page: Page, open: boolean) {
|
||||
const copyItem = messageMoreCopyItem(page);
|
||||
if (open) {
|
||||
await expect(copyItem).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await expect(async () => {
|
||||
if (await copyItem.isVisible()) {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await expect(copyItem).toBeHidden({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function revealHoverRail(row: Locator): Promise<Locator> {
|
||||
const rail = row.getByTestId("message-hover-rail");
|
||||
await expect
|
||||
@ -180,12 +199,13 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
|
||||
expect(timeBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(Math.abs(timeBox!.x - rowBox!.x)).toBeLessThan(2);
|
||||
await toolbar.getByRole("button", { name: "More" }).click();
|
||||
const moreButton = toolbar.getByRole("button", { name: "More" });
|
||||
await moreButton.click();
|
||||
await expectMessageMoreMenu(page, true);
|
||||
await expect(page.getByRole("menu").locator("time")).toHaveCount(0);
|
||||
// Escape closes More; the hover rail stays visible for the pinned row.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menu")).toHaveCount(0);
|
||||
await expect(toolbar.getByRole("button", { name: "More" })).toBeVisible();
|
||||
await expectMessageMoreMenu(page, false);
|
||||
await expect(moreButton).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "message-user-actions-hover-desktop");
|
||||
await page.getByRole("combobox", { name: "Message" }).focus();
|
||||
// Default transcript shot: rail at rest (no hover pin, mouse clear).
|
||||
|
||||
@ -85,6 +85,10 @@ async function main() {
|
||||
cwd: path.resolve("packages/db"),
|
||||
});
|
||||
|
||||
if (e2e && process.platform === "linux") {
|
||||
execSync("bash scripts/free-e2e-ports.sh", { stdio: "inherit", env: process.env });
|
||||
}
|
||||
|
||||
if (integration) {
|
||||
const suites = [
|
||||
"packages/testkit/src/pi-offline.postgres.test.ts",
|
||||
|
||||
47
packages/testkit/src/e2e-ports.test.ts
Normal file
47
packages/testkit/src/e2e-ports.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
||||
const scriptPath = path.join(repoRoot, "scripts/free-e2e-ports.sh");
|
||||
const playwrightWorkflow = readFileSync(
|
||||
path.join(repoRoot, ".github/workflows/playwright.yml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
function portsFromShellArray(source: string, name: string): number[] {
|
||||
const match = source.match(new RegExp(`readonly ${name}=\\(([^)]*)\\)`));
|
||||
expect(match, `${name} array`).toBeTruthy();
|
||||
return [...match![1].matchAll(/\b(\d+)\b/g)].map(([, port]) => Number(port));
|
||||
}
|
||||
|
||||
describe("free-e2e-ports.sh", () => {
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
|
||||
it("declares dedicated E2E ports separate from live dev ports", () => {
|
||||
const livePorts = portsFromShellArray(script, "LIVE_PORTS");
|
||||
const e2ePorts = portsFromShellArray(script, "E2E_PORTS");
|
||||
|
||||
expect(livePorts).toEqual([3100, 5173]);
|
||||
expect(e2ePorts).toEqual([3110, 3111, 5180, 4321]);
|
||||
for (const livePort of livePorts) {
|
||||
expect(e2ePorts).not.toContain(livePort);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses fuser and skips non-Linux hosts", () => {
|
||||
expect(script).toContain('fuser -k "$spec"');
|
||||
expect(script).toContain('uname -s)" != "Linux"');
|
||||
});
|
||||
|
||||
it("is wired into the Playwright workflow before product and marketing E2E", () => {
|
||||
const occurrences = playwrightWorkflow.match(/free-e2e-ports\.sh/g) ?? [];
|
||||
expect(occurrences.length).toBeGreaterThanOrEqual(2);
|
||||
expect(playwrightWorkflow.indexOf("free-e2e-ports.sh")).toBeLessThan(
|
||||
playwrightWorkflow.indexOf("Run Playwright tests"),
|
||||
);
|
||||
expect(playwrightWorkflow.lastIndexOf("free-e2e-ports.sh")).toBeLessThan(
|
||||
playwrightWorkflow.indexOf("Run marketing homepage E2E"),
|
||||
);
|
||||
});
|
||||
});
|
||||
47
scripts/free-e2e-ports.sh
Executable file
47
scripts/free-e2e-ports.sh
Executable file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Free stale listeners on dedicated Playwright/E2E ports before harness bind.
|
||||
# Never touches live dev stack ports 3100 (API) or 5173 (web).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly LIVE_PORTS=(3100 5173)
|
||||
readonly E2E_PORTS=(3110 3111 5180 4321)
|
||||
|
||||
if [[ "$(uname -s)" != "Linux" ]]; then
|
||||
echo "free-e2e-ports: skip (not Linux)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v fuser >/dev/null 2>&1; then
|
||||
echo "free-e2e-ports: warning: fuser not found; cannot pre-free E2E ports" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for port in "${LIVE_PORTS[@]}"; do
|
||||
for e2e_port in "${E2E_PORTS[@]}"; do
|
||||
if [[ "$port" -eq "$e2e_port" ]]; then
|
||||
echo "free-e2e-ports: internal error: live port $port must not be in E2E_PORTS" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "free-e2e-ports: clearing stale listeners on E2E ports (${E2E_PORTS[*]}); live ports ${LIVE_PORTS[*]} untouched"
|
||||
|
||||
for port in "${E2E_PORTS[@]}"; do
|
||||
spec="${port}/tcp"
|
||||
if fuser "$spec" >/dev/null 2>&1; then
|
||||
echo "free-e2e-ports: port $port in use:"
|
||||
fuser -v "$spec" 2>&1 || true
|
||||
echo "free-e2e-ports: killing processes on port $port (fuser -k)"
|
||||
fuser -k "$spec" 2>&1 || true
|
||||
sleep 0.25
|
||||
if fuser "$spec" >/dev/null 2>&1; then
|
||||
echo "free-e2e-ports: warning: port $port still in use after fuser -k" >&2
|
||||
else
|
||||
echo "free-e2e-ports: port $port is now free"
|
||||
fi
|
||||
else
|
||||
echo "free-e2e-ports: port $port already free"
|
||||
fi
|
||||
done
|
||||
Loading…
x
Reference in New Issue
Block a user