Files
molecule-core/.gitea/workflows/e2e-chat.yml
T
devops-engineer 947b5f1ad4
Block internal-flavored paths / Block forbidden paths (push) Skipped
lint-env-coupling-dismissal / lint-env-coupling-dismissal (push) Skipped
verify-providers-gen / Regenerate providers artifact and fail on drift (push) Skipped
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (push) Skipped
boot-to-registration-e2e / Minimal cell (SSOT runtime + molecules-server) (push) Skipped
ci-required-drift / drift (push) Skipped
CI / Detect changes (push) Skipped
CI / Platform (Go) (push) Skipped
CI / Canvas (Next.js) (push) Skipped
CI / Shellcheck (E2E scripts) (push) Skipped
CI / Canvas Deploy Status (push) Skipped
CI / Python Lint & Test (push) Skipped
CI / all-required (push) Skipped
Concierge Creates Workspace Hermetic / Concierge Creates Workspace Hermetic (push) Skipped
contract-ssot-sync / Verify vendored plugin-manifest schema matches the molecule-ai-sdk SSOT (push) Skipped
cron-fixtures-ssot-drift / Verify vendored cron fixtures are byte-identical to the molecule-ai-sdk SSOT (push) Skipped
design-token-drift / Canvas ↔ app design-token SSOT drift (push) Skipped
E2E API Smoke Test / detect-changes (push) Skipped
E2E API Smoke Test / E2E API Smoke Test (push) Skipped
E2E Chat / E2E Chat (push) Skipped
E2E Ephemeral CP Happy Path / detect (push) Skipped
E2E Ephemeral CP Happy Path / E2E Ephemeral CP Happy Path (push) Skipped
E2E External Connect-Snippet + Leak-Lint / E2E External Connect-Snippet + Leak-Lint (push) Skipped
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (compile+skip) (push) Skipped
Handlers Postgres Integration / detect-changes (push) Skipped
Handlers Postgres Integration / Handlers Postgres Integration (push) Skipped
Harness Replays / detect-changes (push) Skipped
Harness Replays / Harness Replays (push) Skipped
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Skipped
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Skipped
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Skipped
Lint workflow YAML (repository compatibility policy) / Lint workflow YAML for repository compatibility policy (push) Skipped
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (push) Skipped
template-delivery-e2e / detect-changes (push) Skipped
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (push) Skipped
Ops Scripts Tests / Ops scripts (unittest) (push) Skipped
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Skipped
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (push) Skipped
Lint forbidden hand-written mcp__ tool-id literals / Scan for hand-written mcp__ tool-id literals (push) Skipped
lint-no-coe-on-required / lint-no-coe-on-required (push) Skipped
lint-phantom-gate / lint-phantom-gate (push) Skipped
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (push) Skipped
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Skipped
Lint schedule budget / Zero-cron ratchet (push) Skipped
lint-setup-go-cache / lint-setup-go-cache (push) Skipped
lint-staging-tenant-cd-gate-chain / lint-staging-tenant-cd-gate-chain (push) Skipped
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (push) Skipped
main-canary / canary (push) Skipped
mcp-plugin-delivery-contract-drift / Compare MCP plugin delivery contract against template and runtime canonicals (push) Skipped
mcp-verb-published-manifest / Published mcp-server manifest ⊇ contract required verbs (push) Skipped
merge-commit-guard / Verify merged PR commits are on main (push) Skipped
nodup-lint / no-duplication (push) Skipped
pin-provenance-guard / Pin provenance (push) Skipped
provision-request-ssot-sync / Verify core's provision-request mirror matches the molecule-ai-sdk SSOT (push) Skipped
publish-canvas-image / Build & push canvas image (push) Skipped
publish-desktop-image / Build & push desktop image (push) Skipped
publish-desktop-image / Promote desktop :latest to CI-green build (push) Skipped
publish-workspace-server-image / Build & push tenant image (push) Skipped
publish-workspace-server-image / Promote tenant :latest to CI-green build (push) Skipped
runtime-default-flip-gate / flip-gate (push) Skipped
sdk-pin-drift / SDK consumer pin vs molecule-ai-sdk main (push) Skipped
sdk-route-milestone-contract-drift / SDK route + milestone derive-gates (push) Skipped
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Skipped
Secret scan / Scan diff for credential-shaped strings (push) Skipped
Self-Host Concierge Schedules E2E / Self-Host Concierge Schedules E2E (stub) (push) Skipped
staging-tenant-cd / await-image (staging-<sha> published) (push) Skipped
staging-tenant-cd / advance-pin (staging tenant image DB pin) (push) Skipped
staging-tenant-cd / redeploy-fleet (roll staging tenants, session-preserving) (push) Skipped
staging-tenant-cd / runtime-image-readiness (exact promoted digests on provisioner daemon) (push) Skipped
staging-tenant-cd / e2e-smoke (staging real provision + A2A HARD GATE) (push) Skipped
staging-tenant-cd / rollback-pin (revert pin if the fleet roll failed) (push) Skipped
staging-tenant-cd / rollback-audit (prove rollback-pin itself actually executed) (push) Skipped
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Skipped
sync-providers-yaml / Verify SDK registry dependency (push) Skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
sync-providers-yaml / Verify SDK registry dependency (pull_request) Skipped
CI / Detect changes (pull_request) Successful in 5s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 5s
E2E Ephemeral CP Happy Path / detect (pull_request) Successful in 7s
boot-to-registration-e2e / Minimal cell (SSOT runtime + molecules-server) (pull_request) Skipped
Check migration collisions / Migration version collision check (pull_request) Skipped
contract-ssot-sync / Verify vendored plugin-manifest schema matches the molecule-ai-sdk SSOT (pull_request) Skipped
cron-fixtures-ssot-drift / Verify vendored cron fixtures are byte-identical to the molecule-ai-sdk SSOT (pull_request) Skipped
design-token-drift / Canvas ↔ app design-token SSOT drift (pull_request) Skipped
E2E External Connect-Snippet + Leak-Lint / E2E External Connect-Snippet + Leak-Lint (pull_request) Skipped
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (compile+skip) (pull_request) Skipped
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Skipped
manifest-entry-existence-check / Verify manifest entries exist on Gitea (pull_request) Skipped
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 5s
mcp-plugin-delivery-contract-drift / Compare MCP plugin delivery contract against template and runtime canonicals (pull_request) Skipped
mcp-verb-published-manifest / Published mcp-server manifest ⊇ contract required verbs (pull_request) Skipped
nodup-lint / no-duplication (pull_request) Skipped
provision-request-ssot-sync / Verify core's provision-request mirror matches the molecule-ai-sdk SSOT (pull_request) Skipped
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (pull_request) Skipped
Self-Host Concierge Schedules E2E / Self-Host Concierge Schedules E2E (stub) (pull_request) Skipped
verify-providers-gen / Regenerate providers artifact and fail on drift (pull_request) Skipped
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint forbidden hand-written mcp__ tool-id literals / Scan for hand-written mcp__ tool-id literals (pull_request) Successful in 4s
ci-required-drift / drift (pull_request) Successful in 21s
Concierge Creates Workspace Hermetic / Concierge Creates Workspace Hermetic (pull_request) Successful in 27s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 25s
lint-phantom-gate / lint-phantom-gate (pull_request) Successful in 17s
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (pull_request) Successful in 17s
lint-bp-context-emit-match / lint-bp-context-emit-match (pull_request) Successful in 27s
lint-no-coe-on-required / lint-no-coe-on-required (pull_request) Successful in 24s
lint-env-coupling-dismissal / lint-env-coupling-dismissal (pull_request) Successful in 28s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 19s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 26s
Lint schedule budget / Zero-cron ratchet (pull_request) Successful in 16s
lint-staging-tenant-cd-gate-chain / lint-staging-tenant-cd-gate-chain (pull_request) Successful in 13s
lint-setup-go-cache / lint-setup-go-cache (pull_request) Successful in 16s
Lint workflow YAML (repository compatibility policy) / Lint workflow YAML for repository compatibility policy (pull_request) Successful in 20s
sdk-pin-drift / SDK consumer pin vs molecule-ai-sdk main (pull_request) Successful in 16s
Secret scan / Scan diff for credential-shaped strings (pull_request) No credential-shaped strings (deterministic re-assert)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
pin-provenance-guard / Pin provenance (pull_request) Successful in 29s
template-delivery-e2e / detect-changes (pull_request) Successful in 8s
sdk-route-milestone-contract-drift / SDK route + milestone derive-gates (pull_request) Successful in 17s
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (pull_request) Successful in 3s
lint-context-set-shrink / lint-context-set-shrink (pull_request) Successful in 1m24s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 54s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Successful in 47s
gitea-merge-queue / queue (pull_request_review) Successful in 15s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 2m1s
Harness Replays / Harness Replays (pull_request) Successful in 2m40s
E2E Chat / E2E Chat (pull_request) Successful in 3m32s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 5m40s
CI / Canvas Deploy Status (pull_request) Successful in 1s
CI / Platform (Go) (pull_request) Successful in 5m53s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6m44s
CI / all-required (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2s
E2E Ephemeral CP Happy Path / E2E Ephemeral CP Happy Path (pull_request) Successful in 23m32s
audit-force-merge / audit (pull_request_target) Successful in 6s
fix(ci): isolate chat browsers from host network churn
2026-10-11 19:40:49 -07:00

668 lines
36 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: E2E Chat
# Comprehensive Playwright E2E for the unified chat stack (desktop
# ChatTab + mobile MobileChat). Every pull request, main/staging push, and
# manual dispatch runs the browser suite; there is no label gate, paths filter,
# or success-without-tests arm.
#
# Architecture:
# 1. Ephemeral Postgres + Redis (docker, unique container names)
# 2. workspace-server built from source, started with
# MOLECULE_ENV=development with fail-closed ADMIN_TOKEN auth
# 3. canvas served from a PRODUCTION build (`next build` + the
# `output: "standalone"` node server — the same artifact
# canvas/Dockerfile ships), NOT `next dev --turbopack`. See
# "Why a production build" below.
# 4. Playwright tests create workspaces via API, point them at an
# in-process echo runtime, and exercise the full send/receive
# round-trip through the browser.
#
# Why a production build (2026-08-05, core#5079 blocker)
# ------------------------------------------------------
# This lane used `npx next dev --turbopack`. It failed non-deterministically
# in ONE mode: React Flow rendered UNSTYLED, so `.react-flow__background`
# kept its browser-default `pointer-events: auto` and swallowed every click
# on the canvas; specs died with `<rect …> from <svg data-testid=
# "rf__background"> subtree intercepts pointer events`. Diagnostics captured
# on a stuck page (run 623425, job 921290):
#
# react-flow background pointer-events = auto
# stylesheets = 1 (1 readable), .react-flow__background rule present = false
# viewport transform = translate(314px, -204px) scale(2)
#
# i.e. a Turbopack CSS chunk never arrived and the page NEVER recovered.
# Document requests were returning in 40–77 ms, so it was not slow
# compilation. Observed 2 failures in 4 runs on core#5079
# (623210 ✗, 623299 ✓, 623425 ✗, 622625 ✓).
#
# `next build` emits immutable, content-hashed CSS files that are NAMED by
# the initial document. There is no on-demand chunk compilation left, which
# removes the COMPILATION mechanism behind the flake.
#
# This paragraph used to add that the React Flow sheet arrives "as `<link
# rel="preload" as="style">` plus an RSC-payload stylesheet entry React
# inserts at hydration". That was true when written and is FALSE NOW: #5105
# moved the import into the layout stylesheet precisely so the sheet is a
# plain, render-blocking `<link rel="stylesheet">` in the initial document,
# and the preflight below ASSERTS that shape and fails the job if it ever
# regresses. Left corrected rather than deleted because a stale claim about
# how an asset is delivered is not inert — it is what a future investigator
# reasons from, and core#5106 has now been misdirected twice by exactly that
# (see the note on the diagnostic in canvas/e2e/helpers/canvas.ts).
#
# It does NOT remove every mechanism, and this file will not pretend
# otherwise. A stylesheet request that is merely SLOW makes React suspend the
# subtree (`pointer-events: none`, no unstyled window); a request that FAILS
# makes React render anyway, reproducing the same unstyled window. Measured
# over this lane’s own runs: ~1 occurrence in 29 production-build runs
# against 3 in 30 dev runs. Substantially more reliable, not immune.
#
# What this costs / what it changes:
# - `next build` is added to the job. MEASURED on this runner: 23-29s
# across 9 runs; a whole green job is ~2.0 min wall (it was ~2.0 min
# on the dev lane, which paid a comparable cold Turbopack compile).
# timeout-minutes stays 15 and was NOT raised: the worst case seen
# here is a totally broken canvas (every click times out at 30s),
# which finished RED in 11.7 min.
# - NEXT_PUBLIC_* are INLINED AT BUILD TIME by Next.js, so the build MUST
# run AFTER the platform port and admin token are chosen. The build step
# asserts the inline actually happened — a bundle built with the wrong
# port starts fine and fails subtly.
# - NO build cache is added. The bundle embeds per-run values (platform
# port, per-run admin token), so a restored bundle would be a WRONG
# bundle that still boots. `actions/setup-node`'s npm cache (lockfile
# keyed) is the only cache here and is unchanged.
# - The suite now runs against the PRODUCTION CSP from src/middleware.ts
# (`NODE_ENV=production` → nonce + strict-dynamic + a connect-src
# allowlist) instead of the permissive dev CSP (`connect-src *`). That
# is the policy real users get; a CSP regression is now caught here.
# - `next build` type-checks and lints (next.config.ts leaves
# eslint.ignoreDuringBuilds / typescript.ignoreBuildErrors at false, as
# production wants), so a canvas type/lint error now reds this lane too.
# That mirrors what canvas/Dockerfile and publish-canvas-image.yml
# already do; nothing was loosened to accommodate it.
#
# Nothing in the suite depended on dev-only behaviour: no spec references
# fast refresh, the Next dev error overlay, source maps, or NODE_ENV. The
# `MOLECULE_ENV=development` the specs DO assert on belongs to the Go
# platform-server and is untouched by this change.
#
# Parallel-safety: per-run containers and ephemeral loopback ports. Chromium
# has its own bridge network namespace: host Docker churn cannot invalidate
# browser sockets (core#5106). Playwright forwards client loopback destinations.
on:
# ALWAYS RUNS — 2026-07-14. No paths filter, no detect-changes job, no no-op arm.
#
# This lane carried the SAME bug as e2e-staging-canvas (core#4323). Its
# detect-changes hand-rolled a paths filter in shell and then, on pull_request
# only, demanded the PR carry a `merge-queue` LABEL:
#
# if printf '%s\n' "$labels" | grep -qx "$QUEUE_LABEL"; then chat=true
# else "PR is not in merge-queue; skipping heavy E2E Chat" chat=false
#
# But the merge queue ABANDONED that label. gitea-merge-queue.yml sets
# AUTO_DISCOVER=1 and calls it "OPTIONAL metadata" — agent Gitea tokens lack
# write:issue and could never self-label. Nothing applies it. So on any PR the
# only reachable arm was the "No-op pass" step, which echoed and exit 0 —
# posting SUCCESS for the `E2E Chat` context without running a single test.
# Under main's `['*']` branch protection every posted context is required, so
# this was a merge gate whose pass arm was "I did not run".
#
# The lane WORKS — it is green whenever it actually executes (139s / 141s / 129s
# on recent main pushes, all success). It simply never executed on the one event
# where it could have blocked a bad change. Now it does.
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
workflow_dispatch:
concurrency:
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request)
# bp-required: pending #1142 — new E2E check; add to branch protection after 3 green runs.
e2e-chat:
name: E2E Chat
# mc#1529 follow-on: docker run/exec for postgres + redis containers.
# Must land on the self-hosted Linux docker-host lane.
runs-on: docker-host
# mc#2654 mask REMOVED — this lane is now FAIL-CLOSED (gating). It runs the
# LOCAL platform-server (MOLECULE_ORG_ID unset → the local Docker /
# molecules-server provisioner path, main.go:230) + canvas + the in-process
# echo runtime entirely on-box: NO EC2, NO remote CP, NO external cloud
# dependency, so there is no infra-flake surface to justify an advisory mask.
#
# Empirical basis for the flip (run-log-grep-before-flip, per
# lint-pre-flip-continue-on-error): the last real main runs pass green with
# NO masked failures — run 430897 "26 passed", run 430812 "26 passed",
# run 430653 no-op pass. The prior continue-on-error:true was hiding nothing;
# keeping it only let a future regression roll up to a false "success"
# (Gitea Quirk #10). Removing it makes a real chat regression go RED.
#
# Assertions stay honest and fail-closed (unchanged by this flip):
# - Postgres/Redis/platform/canvas readiness are already bounded
# readiness-polls that hard-fail (and dump logs) at their deadline,
# not fixed sleeps — preserved.
# - passWithNoTests:false + forbidOnly (playwright.config.ts) → a
# renamed/moved spec or stray test.only can no longer green the lane.
# - REQUIRE-LIVE guard in "Run Playwright E2E tests" requires every
# invocation to execute >=1 test, else exit 1.
# - chat-desktop.spec.ts asserts echo-runtime.lastRequest after each
# round-trip, so an optimistic client-side render cannot pass without
# a real A2A request reaching the server (core#2796 follow-up).
# - There is no paths-filter/no-op arm; every workflow invocation runs
# the browser suite.
#
# Wiring this context into branch_protections/main (bp-required #1142) stays
# an operator step; removing the mask here only stops the masking.
continue-on-error: false
timeout-minutes: 15
env:
PG_CONTAINER: pg-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
REDIS_CONTAINER: redis-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
PLAYWRIGHT_CONTAINER: browser-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
steps:
-
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
# cache:false — the self-hosted runner bind-mounts a persistent
# GOCACHE/GOMODCACHE (/var/cache/ci-go-{build,mod}); actions/cache is
# redundant and corrupts it by untarring over the bind mount ("File
# exists" -> "Failed to restore" -> partial cache -> linker/typecheck
# errors on heavy jobs, e.g. test -race link "too many errors" and
# go-arch-lint "without types"). Fleet sweep after the cp ci.yml find.
cache: false
cache-dependency-path: workspace-server/go.sum
-
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: canvas/package-lock.json
- name: Sweep stale e2e-chat testcontainers (self-heal prior leaks)
run: |
# Prior e2e-chat runs that were cancelled/killed — or whose always()
# cleanup hit a wedged docker daemon — leak their pg-/redis-e2e-chat-*
# containers, which then pile up on the shared runner host (observed: 13
# such containers, up to 2 weeks old, on the operator daemon). Reap any
# e2e-chat container older than the job window so leaks self-heal every
# run instead of relying on each run's own cleanup succeeding. Age-based
# (>2h, well beyond the 15m job) so a CONCURRENT e2e-chat job's fresh
# containers are never touched. See controlplane#646.
now=$(date -u +%s)
docker ps -a --filter name=e2e-chat --format '{{.Names}}' | while read -r c; do
[ -n "$c" ] || continue
created=$(docker inspect -f '{{.Created}}' "$c" 2>/dev/null) || continue
cts=$(date -u -d "$created" +%s 2>/dev/null) || continue
if [ $(( now - cts )) -gt 7200 ]; then
echo "sweeping stale e2e-chat container $c (created $created)"
timeout 30 docker rm -fv "$c" >/dev/null 2>&1 || true
fi
done
- name: Start Postgres (docker)
run: |
# tmpfs PGDATA + run label + `rm -fv` pre-clean: see the script header.
bash .gitea/scripts/ci-service-container.sh postgres "$PG_CONTAINER" postgres:16 \
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
-p 127.0.0.1::5432 >/dev/null
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^(0\.0\.0\.0|127\.0\.0\.1):/ {print $2; exit}')
if [ -z "$PG_PORT" ]; then
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$PG_PORT" ]; then
echo "::error::Could not resolve host port for $PG_CONTAINER"
exit 1
fi
echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
echo "E2E_DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
echo "Postgres ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Postgres did not become ready in 30s"
exit 1
- name: Start Redis (docker)
run: |
bash .gitea/scripts/ci-service-container.sh redis "$REDIS_CONTAINER" redis:7 -p 127.0.0.1::6379 >/dev/null
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^(0\.0\.0\.0|127\.0\.0\.1):/ {print $2; exit}')
if [ -z "$REDIS_PORT" ]; then
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$REDIS_PORT" ]; then
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
exit 1
fi
echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV"
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
echo "Redis ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Redis did not become ready in 15s"
exit 1
- name: Build platform
working-directory: workspace-server
run: go build -o platform-server ./cmd/server
- name: Pick platform port
run: |
PLATFORM_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "PLATFORM_PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "E2E_PLATFORM_URL=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "Platform host port: ${PLATFORM_PORT}"
- name: Pick canvas port
run: |
CANVAS_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "CANVAS_PORT=${CANVAS_PORT}" >> "$GITHUB_ENV"
echo "Canvas host port: ${CANVAS_PORT}"
- name: Set deterministic admin token
run: |
# PR #2291 made auth fail-closed everywhere (no dev-mode escape).
# This ephemeral test platform requires ADMIN_TOKEN; its test Canvas
# uses the matching NEXT_PUBLIC_ADMIN_TOKEN or every API call 401s.
# Production SaaS leaves the public value empty and uses sessions.
# We set a deterministic per-run value so the ephemeral platform
# and canvas are paired correctly.
E2E_ADMIN_TOKEN="e2e-chat-admin-${{ github.run_id }}-${{ github.run_attempt }}"
echo "ADMIN_TOKEN=${E2E_ADMIN_TOKEN}" >> "$GITHUB_ENV"
echo "MOLECULE_ADMIN_TOKEN=${E2E_ADMIN_TOKEN}" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_ADMIN_TOKEN=${E2E_ADMIN_TOKEN}" >> "$GITHUB_ENV"
echo "Admin token configured for e2e-chat platform + canvas."
- name: Start platform (background)
working-directory: workspace-server
run: |
export MOLECULE_ENV=development
export DATABASE_URL="${DATABASE_URL}"
export REDIS_URL="${REDIS_URL}"
export PORT="${PLATFORM_PORT}"
export CORS_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:${CANVAS_PORT},http://127.0.0.1:${CANVAS_PORT}"
./platform-server > platform.log 2>&1 &
echo $! > platform.pid
- name: Wait for /health
run: |
# Readiness signal: the platform binds /health only AFTER the full
# migration chain has been applied on cold start (it prints
# "Platform starting on :PORT" at that point). So a 200 from /health
# is the real "migrations done + server listening" signal.
#
# The migration chain grows every release, so a fixed ~30s budget is
# brittle by construction. Use a generous wall-clock budget that
# comfortably exceeds cold-start + full-migration time, polling fast.
# Robust to a growing chain WITHOUT masking a dead platform: if the
# background platform-server process has exited, fail loudly at once.
DEADLINE_SECS=180 # cold-start + full migration chain headroom
PLATFORM_PID="$(cat workspace-server/platform.pid 2>/dev/null || true)"
start=$(date +%s)
while :; do
if curl -sf "http://127.0.0.1:${PLATFORM_PORT}/health" > /dev/null; then
echo "Platform healthy after $(( $(date +%s) - start ))s"
exit 0
fi
if [ -n "$PLATFORM_PID" ] && ! kill -0 "$PLATFORM_PID" 2>/dev/null; then
echo "::error::platform-server (pid ${PLATFORM_PID}) exited before /health became reachable — see log below"
cat workspace-server/platform.log || true
exit 1
fi
if [ "$(( $(date +%s) - start ))" -ge "$DEADLINE_SECS" ]; then
echo "::error::Platform did not become healthy within ${DEADLINE_SECS}s — see log below"
cat workspace-server/platform.log || true
exit 1
fi
sleep 1
done
- name: Install canvas dependencies
working-directory: canvas
run: npm ci
- name: Start isolated Playwright browser
run: |
set -euo pipefail
endpoint="$(bash .gitea/scripts/start-playwright-server.sh "$PLAYWRIGHT_CONTAINER")"
echo "PLAYWRIGHT_WS_ENDPOINT=$endpoint" >> "$GITHUB_ENV"
- name: Build canvas (production bundle)
working-directory: canvas
env:
NEXT_TELEMETRY_DISABLED: "1"
run: |
set -euo pipefail
# NEXT_PUBLIC_* are INLINED INTO THE CLIENT BUNDLE BY `next build`.
# They are NOT read at runtime, so this step must run AFTER "Pick
# platform port" and "Set deterministic admin token" — building
# first and exporting later yields a bundle that boots happily and
# talks to the wrong port / presents the wrong bearer token.
export NEXT_PUBLIC_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export NEXT_PUBLIC_WS_URL="ws://127.0.0.1:${PLATFORM_PORT}/ws"
start=$(date +%s)
npx next build
echo "next build completed in $(( $(date +%s) - start ))s"
# --- Prove the build is the build we asked for -------------------
# A wrong-but-bootable bundle is the failure mode a production
# build introduces, so assert the inlining instead of trusting it.
#
# Each assertion first proves its own INPUT is non-empty. `set -u`
# only catches an UNSET variable; an EMPTY one sails straight
# through and turns `grep -qF "$X"` into a match-everything no-op
# that reports success. A guard that passes on empty input is
# exactly the vacuous-pass class this lane exists to keep out of
# the tree, so it must not live in the lane’s own guards. Both
# cases have an empty-input proof recorded on the PR.
if [ -z "${PLATFORM_PORT:-}" ]; then
echo "::error::PLATFORM_PORT is empty — 'Pick platform port' did not run, and the inlining check below would collapse to a search for the bare '127.0.0.1:' prefix. Refusing to assert vacuously."
exit 1
fi
if [ -z "${NEXT_PUBLIC_ADMIN_TOKEN:-}" ]; then
echo "::error::NEXT_PUBLIC_ADMIN_TOKEN is empty — 'Set deterministic admin token' did not run, and grepping for the empty string matches EVERY file, so the check below would pass having asserted nothing."
exit 1
fi
if ! grep -rqF "127.0.0.1:${PLATFORM_PORT}" .next/static/chunks; then
echo "::error::NEXT_PUBLIC_PLATFORM_URL (127.0.0.1:${PLATFORM_PORT}) was not inlined into the client bundle — the canvas would call the wrong platform. Check step ordering: the build must run after 'Pick platform port'."
exit 1
fi
# -q so the token value is never echoed into the log.
if ! grep -rqF "${NEXT_PUBLIC_ADMIN_TOKEN}" .next/static/chunks; then
echo "::error::NEXT_PUBLIC_ADMIN_TOKEN was not inlined into the client bundle — every workspace API call would 401. Check step ordering: the build must run after 'Set deterministic admin token'."
exit 1
fi
# The regression this lane switched build modes to kill: React Flow
# rendering unstyled because its stylesheet never reached the page.
# In a production build the rule lives in a content-hashed CSS file
# on disk, so its ABSENCE is a build defect and must be loud here
# rather than showing up as a 15s "node never became hittable".
RF_CSS=$(grep -rlF 'react-flow__background' .next/static/css | head -1 || true)
if [ -z "$RF_CSS" ]; then
echo "::error::No built stylesheet contains .react-flow__background — @xyflow/react/dist/style.css did not make it into the production CSS output; every canvas click would be swallowed by the unstyled background."
exit 1
fi
echo "RF_CSS_PATH=/_next/static/css/$(basename "$RF_CSS")" >> "$GITHUB_ENV"
echo "React Flow stylesheet: $(basename "$RF_CSS")"
# Assemble the standalone runtime exactly as canvas/Dockerfile does
# (`COPY .next/standalone`, `.next/static`, `public`). `next start`
# is NOT usable here — Next 15 prints "next start does not work with
# output: standalone" — so we run the same `node server.js` entry
# point production runs.
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
- name: Start canvas (production server, background)
working-directory: canvas
run: |
set -euo pipefail
# Single node process (not an `npx` wrapper), so the pid we record
# is the pid the cleanup step actually kills.
#
# HOSTNAME MUST be set explicitly. Next's standalone server.js does
# `process.env.HOSTNAME || '0.0.0.0'`, and on this runner HOSTNAME
# is the container's own name — the server then bound
# `http://robot-2:35783` and every `localhost` probe got HTTP 000
# (observed: run 623567). Pin the loopback address the rest of this
# lane already uses (Postgres/Redis/platform all bind 127.0.0.1);
# `localhost` resolves to it on this runner, which is exactly how
# the dev server was being reached before.
#
# --require the core#5106 server probe. On the one captured
# recurrence (run 632484) this log contained four lines — the banner
# and "Ready in 85ms" — for the whole run, so it could establish that
# the server had not crashed and NOTHING else. It could not say
# whether the request for the stylesheet that died ever reached this
# process, nor whether this process was able to run at the time. The
# probe answers both (see canvas/e2e/support/server-probe.cjs) and is
# silent on the happy path. Absolute path: the server runs with
# cwd=.next/standalone, and a relative --require resolves against cwd.
PROBE="${PWD}/e2e/support/server-probe.cjs"
if [ ! -f "$PROBE" ]; then
echo "::error::server probe not found at ${PROBE} — refusing to start the canvas server without the instrument this lane's failures are diagnosed from."
exit 1
fi
cd .next/standalone
PORT="${CANVAS_PORT}" HOSTNAME=127.0.0.1 NODE_OPTIONS="--require ${PROBE}" \
node server.js > ../../canvas.log 2>&1 &
echo $! > ../../canvas.pid
cd ../..
# Readiness is the real signal, not a sleep: poll the exact route
# the specs navigate to (`/?m=chat`) until it answers 2xx. A
# production server has nothing to compile, so this settles in
# ~1s; the budget stays 120s only as an upper bound (unchanged
# from the dev lane — it is a deadline, not a wait).
READY=""
for i in $(seq 1 60); do
# Tempfile-routed -w + set +e/-e prevents curl-exit-code
# pollution of the captured status (lint-curl-status-capture.yml).
set +e
curl -s -o /dev/null -w '%{http_code}' "http://localhost:${CANVAS_PORT}/?m=chat" > /tmp/canvas-ready.code
set -e
CODE=$(cat /tmp/canvas-ready.code 2>/dev/null || echo "000")
if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 400 ]; then
echo "Canvas (production server) up after ~$((i*2))s (HTTP ${CODE})"
READY=1
break
fi
sleep 2
done
if [ -z "$READY" ]; then
echo "::error::Canvas production server did not answer /?m=chat in 120s (last HTTP ${CODE})"
cat canvas.log || true
exit 1
fi
# The probe is only useful if it actually loaded, and a --require that
# silently no-ops (wrong path, NODE_OPTIONS stripped, a syntax error
# in the file) leaves a log that looks exactly like a healthy quiet
# run. That is the same shape as the failure being investigated, so
# assert the instrument is live rather than assuming it.
if ! grep -q '\[probe .*\] attached' canvas.log; then
echo "::error::the core#5106 server probe did not attach — canvas.log has no '[probe] attached' line, so a silent canvas.log would prove nothing. NODE_OPTIONS/--require did not take effect."
cat canvas.log || true
exit 1
fi
echo "Server probe attached (event-loop + static-asset instrumentation live)."
# --- React Flow stylesheet: RENDER-BLOCKING, and SERVED ----------
#
# This used to assert only that the document NAMED the asset. That
# was satisfied by a `<link rel="preload" as="style">`, and a
# preload is not a stylesheet: Next puts PAGE css behind a preload
# plus an RSC-payload entry that REACT inserts during hydration, so
# the rule was reliably absent for the first few hundred ms — and
# for ~5s under CPU contention, measured at 20x throttling on this
# exact build. That window is what produced "workspace node never
# became hittable" (runs 628513, 630140) and
# "<rect> from <svg data-testid=rf__background> subtree intercepts
# pointer events" (run 629224): a guard that passes while the page
# can still render unstyled is not a guard.
#
# So assert the property that actually removes the window: the
# initial document must carry a PLAIN `<link rel="stylesheet">`
# for the sheet that holds the rule. That only happens when the
# import lives in a LAYOUT stylesheet (canvas/src/app/globals.css),
# and it makes the browser block rendering on it — there is then no
# state in which the canvas exists and the rule does not.
#
# Non-empty first: grepping for the empty string matches every
# file, so an empty RF_CSS_PATH would make the document check a
# no-op that reports success.
if [ -z "${RF_CSS_PATH:-}" ]; then
echo "::error::RF_CSS_PATH is empty — the build step did not export it. The document check below would search for the empty string, which matches every file. Refusing to assert vacuously."
exit 1
fi
set +e
curl -s -o /tmp/canvas-doc.html -w '%{http_code}' "http://localhost:${CANVAS_PORT}/?m=chat" > /tmp/canvas-doc.code
curl -s -o /tmp/canvas-rf.css -w '%{http_code}' "http://localhost:${CANVAS_PORT}${RF_CSS_PATH}" > /tmp/canvas-rf.code
set -e
RF_CODE=$(cat /tmp/canvas-rf.code 2>/dev/null || echo "000")
# Split the document into one <link ...> tag per line and require a
# SINGLE tag that is both rel="stylesheet" and points at this exact
# href. Attribute-order independent, and — unlike a bare
# `grep -F href="..."` — a preload of the same href cannot satisfy
# it. Anchoring on the full href (not the basename) keeps an
# incidental occurrence of the hash from matching.
grep -oE '<link[^>]*>' /tmp/canvas-doc.html > /tmp/canvas-links.txt || true
if ! grep -F 'rel="stylesheet"' /tmp/canvas-links.txt | grep -qF 'href="'"${RF_CSS_PATH}"'"'; then
echo "::error::The served /?m=chat document has no render-blocking <link rel=\"stylesheet\" href=\"${RF_CSS_PATH}\">. Found these link tags:"
cat /tmp/canvas-links.txt
echo "::error::@xyflow/react/dist/style.css must be imported from a LAYOUT stylesheet (canvas/src/app/globals.css), not from a client component such as Canvas.tsx. Imported from a component it lands in the PAGE css chunk, which Next ships as a preload that React only turns into a stylesheet during hydration — leaving a window in which the canvas renders unstyled, the background svg swallows every click, and node hit-testing is meaningless."
exit 1
fi
if [ "$RF_CODE" != "200" ]; then
echo "::error::${RF_CSS_PATH} is not served (HTTP ${RF_CODE}) — the standalone static assets were not assembled."
exit 1
fi
if ! grep -qF 'react-flow__background' /tmp/canvas-rf.css; then
echo "::error::${RF_CSS_PATH} served but does not contain the .react-flow__background rule."
exit 1
fi
echo "React Flow stylesheet named by the document and served OK (${RF_CSS_PATH})"
- name: Run Playwright E2E tests
working-directory: canvas
env:
# CI=1 activates forbidOnly in playwright.config.ts (a stray
# `test.only` would otherwise green the suite while skipping the
# rest). passWithNoTests:false (also in the config) already makes
# a zero-match selection exit non-zero.
CI: "1"
run: |
set -euo pipefail
export E2E_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export E2E_DATABASE_URL="${DATABASE_URL}"
export PLAYWRIGHT_BASE_URL="http://localhost:${CANVAS_PORT}"
# REQUIRE-LIVE guard (mirrors CP serving-e2e SERVING_E2E_REQUIRE_LIVE):
# this lane always runs now, so it MUST actually execute the round-trip
# specs. `pipefail` makes a real test failure (playwright non-zero) abort
# here under `set -e`; passWithNoTests:false makes a zero-match selection
# non-zero too. The assertions below are belt-and-braces.
npx playwright test e2e/chat-desktop.spec.ts e2e/chat-mobile.spec.ts e2e/chat-separation.spec.ts \
e2e/local-entry.spec.ts e2e/concierge-chat-rendering.spec.ts \
e2e/schedule-tab.spec.ts \
e2e/boot-regression.spec.ts \
e2e/css-diagnostic.spec.ts \
e2e/net-evidence-guard.spec.ts \
--reporter=list 2>&1 | tee /tmp/pw-chat.out
# net-evidence-guard.spec.ts is listed because a guard that is not
# SELECTED does not run, and this one is the thing that stops
# core#5106 recurring silently. It needs no platform and no canvas —
# it serves its own `/_next/static/**` on an ephemeral port — so it
# cannot add a dependency to this step.
#
# It contains ONE `test.fail()` (an expected-failure over a body that
# asserts nothing, so the only thing that can produce the failure is
# the guard firing in teardown — that is the mutation-proof). An
# expected-failure counts under `passed` and prints no `skipped`, so
# the two REQUIRE-LIVE assertions below are unaffected. Measured on
# @playwright/test 1.59.1: `11 passed`, zero skipped.
# The guard used to be `grep -qE '[0-9]+ (passed|failed|skipped)'` — which
# ACCEPTED `skipped` as proof of execution, while its own comment claimed
# "a silent all-skip can never report green". It could: an all-skipped run
# prints "27 skipped", satisfies the alternation, and posts SUCCESS having
# asserted nothing. Same green-by-no-op class this lane was un-vacuumed to
# kill, hiding inside the anti-vacuity check itself.
#
# Now: require a POSITIVE pass count, and refuse any skip. The chat +
# schedule-tab specs declare ZERO test.skip/test.fixme, so a `skipped` in
# the summary means someone added a conditional skip — that must be loud,
# not silently subtracted from this lane's coverage.
if ! grep -qE '[0-9]+ passed' /tmp/pw-chat.out; then
echo "::error::E2E Chat REQUIRE-LIVE: Playwright reported no PASSED tests — specs missing, all-skipped, or a zero-match selection. A gate that executes nothing must not report success."
exit 1
fi
if grep -qE '[0-9]+ skipped' /tmp/pw-chat.out; then
echo "::error::E2E Chat REQUIRE-LIVE: $(grep -oE '[0-9]+ skipped' /tmp/pw-chat.out) — the chat specs declare no test.skip/test.fixme, so a skip here is silently removed coverage on the only per-PR chat gate. Remove the skip, or delete the test outright so its loss is visible in the diff."
exit 1
fi
- name: Dump platform log on failure
if: failure()
run: cat workspace-server/platform.log || true
- name: Dump canvas log on failure
if: failure()
run: cat canvas/canvas.log || true
- name: Dump browser log on failure
if: failure()
run: docker logs "$PLAYWRIGHT_CONTAINER" || true
# `--reporter=list` writes NO playwright-report/ directory — only the HTML
# reporter does — so this step has been finding nothing and warning
# "No files were found with the provided path" on every failure. It cost
# us the failure screenshot and error-context.md on run 632484, the one
# captured recurrence of core#5106, leaving the job log as the sole
# evidence. `test-results/` is where `screenshot: "only-on-failure"`
# actually puts them; keep the report path too so adding an HTML reporter
# later still uploads.
- name: Upload Playwright failure artifacts
if: failure()
uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2
with:
name: playwright-report-chat
path: |
canvas/test-results/
canvas/playwright-report/
canvas/canvas.log
- name: Stop canvas
if: always()
run: |
if [ -f canvas/canvas.pid ]; then
kill "$(cat canvas/canvas.pid)" 2>/dev/null || true
fi
- name: Stop platform
if: always()
run: |
if [ -f workspace-server/platform.pid ]; then
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
fi
- name: Stop service containers
if: always()
run: |
# timeout-wrap so a wedged docker daemon can't hang this always() step
# (a hung rm here is one way containers leak in the first place).
timeout 30 docker rm -fv "$PG_CONTAINER" 2>/dev/null || true
timeout 30 docker rm -fv "$REDIS_CONTAINER" 2>/dev/null || true
timeout 30 docker rm -fv "$PLAYWRIGHT_CONTAINER" 2>/dev/null || true