From be36e7b0881c3c1003ccf67892fe9cd27e031839 Mon Sep 17 00:00:00 2001 From: Krasimir Kostadinov <12051991+krazymir@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:46:22 +0300 Subject: [PATCH] chore(e2e): run the suite with Playwright workers instead of shards Jenkins split the e2e into E2E_SHARDS shards: N Playwright containers, one worker each, with a hand-written balancer in playwright.config.ts that kept the platform-state specs in shard 1. That is Playwright's own worker pool, rebuilt by hand. Now one run uses E2E_WORKERS workers (1-16, default 4) and two projects: "platform-state" (the 13 specs that change platform-wide settings, held to one worker so they still run one after another) and "isolated" (the rest). The platform-state specs are 29 of the 112 tests and must run back to back, so past about 4 workers the suite gets no faster; the default is 4. - playwright.config.ts: E2E_SHARD/E2E_SHARD_TOTAL and the shard balancer are gone; files go to whichever worker is free; one HTML report; maxFailures now counts across the whole run. - Jenkinsfile: E2E_SHARDS -> E2E_WORKERS (quick runs use it too); one Playwright container, one exit file. The dev-server watchdog and post-mortem are unchanged. - e2e/helpers/stamp.ts: uniqueStamp() replaces Date.now().toString(36) in every spec (107 call sites) and adds a letter for the worker, so two workers can't create identically named fixtures in the same millisecond. --- Jenkinsfile | 131 ++++++++---------- packages/web/e2e/access-control.spec.ts | 3 +- packages/web/e2e/admin-ai.spec.ts | 3 +- packages/web/e2e/admin-audit-verify.spec.ts | 3 +- packages/web/e2e/admin-audit.spec.ts | 3 +- packages/web/e2e/admin-banner.spec.ts | 3 +- packages/web/e2e/admin-delete.spec.ts | 7 +- packages/web/e2e/admin-erasure.spec.ts | 3 +- packages/web/e2e/admin-identity-sync.spec.ts | 5 +- packages/web/e2e/admin-presence.spec.ts | 7 +- packages/web/e2e/admin-settings.spec.ts | 9 +- packages/web/e2e/admin-system-log.spec.ts | 3 +- packages/web/e2e/admin-trash.spec.ts | 5 +- packages/web/e2e/admin-triage.spec.ts | 5 +- packages/web/e2e/admin-webhooks.spec.ts | 3 +- packages/web/e2e/agent-access.spec.ts | 3 +- packages/web/e2e/ai-draft.spec.ts | 3 +- packages/web/e2e/ai-similar.spec.ts | 3 +- packages/web/e2e/ai-summary.spec.ts | 3 +- packages/web/e2e/ai-triage.spec.ts | 3 +- packages/web/e2e/anonymity.spec.ts | 3 +- packages/web/e2e/attachment-drop.spec.ts | 9 +- packages/web/e2e/avatar-photo.spec.ts | 3 +- packages/web/e2e/challenges-list-view.spec.ts | 3 +- packages/web/e2e/comment-replies.spec.ts | 3 +- packages/web/e2e/core-journey.spec.ts | 3 +- packages/web/e2e/detail-controls.spec.ts | 7 +- packages/web/e2e/directory-card.spec.ts | 11 +- packages/web/e2e/discovery.spec.ts | 15 +- packages/web/e2e/duplicate-warning.spec.ts | 3 +- packages/web/e2e/engagement.spec.ts | 3 +- packages/web/e2e/featured.spec.ts | 3 +- packages/web/e2e/form-lock.spec.ts | 9 +- packages/web/e2e/gamification.spec.ts | 3 +- packages/web/e2e/helpers/stamp.ts | 13 ++ packages/web/e2e/hype.spec.ts | 3 +- packages/web/e2e/lifecycle.spec.ts | 7 +- packages/web/e2e/new-markers.spec.ts | 3 +- packages/web/e2e/notifications.spec.ts | 5 +- packages/web/e2e/playwright.config.ts | 91 ++++-------- packages/web/e2e/public-submissions.spec.ts | 10 +- packages/web/e2e/quick-start.spec.ts | 7 +- packages/web/e2e/reveal.spec.ts | 7 +- packages/web/e2e/search-shortcut.spec.ts | 13 +- packages/web/e2e/security-baseline.spec.ts | 5 +- packages/web/e2e/sign-out.spec.ts | 5 +- packages/web/e2e/survey.spec.ts | 25 ++-- packages/web/e2e/visibility.spec.ts | 3 +- packages/web/e2e/whats-new-notice.spec.ts | 15 +- packages/web/scripts/e2e-specs.mjs | 6 +- 50 files changed, 254 insertions(+), 250 deletions(-) create mode 100644 packages/web/e2e/helpers/stamp.ts diff --git a/Jenkinsfile b/Jenkinsfile index f1105b4..3b240c6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -36,7 +36,7 @@ pipeline { options { timestamps() // 120, not 60: the browser e2e (~93 specs against a dev server) ran well past an hour on the - // agent and was cut off mid-suite. Two shards + route warm-up should bring it far under that; + // agent and was cut off mid-suite. Parallel workers + route warm-up should bring it far under that; // the headroom stays so a slow agent fails on a real error, not the clock. timeout(time: 120, unit: 'MINUTES') disableConcurrentBuilds() @@ -47,8 +47,8 @@ pipeline { string(name: 'AGENT_LABEL', defaultValue: 'linux', description: 'Jenkins agent label to run on') booleanParam(name: 'RUN_DB_TESTS', defaultValue: true, description: 'Run gated live-DB integration tests (spins an ephemeral Postgres)') // The first choice is the default. - choice(name: 'E2E_MODE', choices: ['FULL_E2E_TESTS', 'QUICK_E2E_TESTS', 'NO_E2E_TESTS'], description: 'Playwright browser e2e (spins Postgres + dev server; browsers run in the official Playwright Docker image — no agent provisioning needed). NO_E2E_TESTS = skip it. QUICK_E2E_TESTS = a fresh random 10% of the e2e tests on every build, in a single shard (E2E_SHARDS is ignored); the picked tests are listed in the log and archived as e2e-quick.txt. FULL_E2E_TESTS = the whole suite, split into E2E_SHARDS shards.') - string(name: 'E2E_SHARDS', defaultValue: '2', description: 'FULL_E2E_TESTS only: split the e2e suite into this many shards (1-9), run in parallel on this agent as separate Playwright containers against ONE shared Postgres + web dev server; 1 = the whole suite in one container. Specs that change platform-wide settings always stay together in shard 1. More shards = more concurrent browsers sharing the agent\'s CPU and RAM and the one dev server.') + choice(name: 'E2E_MODE', choices: ['FULL_E2E_TESTS', 'QUICK_E2E_TESTS', 'NO_E2E_TESTS'], description: 'Playwright browser e2e (spins Postgres + dev server; browsers run in the official Playwright Docker image — no agent provisioning needed). NO_E2E_TESTS = skip it. QUICK_E2E_TESTS = a fresh random 10% of the e2e tests on every build; the picked tests are listed in the log and archived as e2e-quick.txt. FULL_E2E_TESTS = the whole suite.') + string(name: 'E2E_WORKERS', defaultValue: '4', description: 'Playwright workers (1-16): how many e2e spec files run at once, in ONE Playwright container against ONE shared Postgres + web dev server; 1 = one file at a time. Specs that change platform-wide settings always run one at a time, and they are about a quarter of the suite, so beyond about 4 more workers barely shorten the run. More workers = more concurrent browsers sharing the agent\'s CPU and RAM and the one dev server.') booleanParam(name: 'DEPLOY', defaultValue: false, description: 'Force a deploy from a non-main branch (main deploys automatically; never on pull-request builds)') // Deploy target (host + checkout path) comes from Jenkins credentials, not build parameters — // see innobox-deploy-host / innobox-deploy-path in the credentials list above. @@ -198,33 +198,34 @@ pipeline { set -eu . "${HOME}/.asdf/asdf.sh" - # The E2E_MODE build parameter (validated up front, before anything is started). - # FULL_E2E_TESTS : the whole suite in E2E_SHARDS shards (the fallback covers the - # first build after that parameter is added, when it isn't defined). + # The E2E_MODE and E2E_WORKERS build parameters (validated up front, before anything is + # started). + # FULL_E2E_TESTS : the whole suite. # QUICK_E2E_TESTS : a random 10% of the tests, picked now by scripts/e2e-specs.mjs as - # Playwright test locations (e2e/.spec.ts:), run as - # shard 1 of 1 narrowed to those locations; E2E_SHARDS is ignored. - # The pick is logged and archived, so a failing quick run can be - # rerun locally with the same tests. + # Playwright test locations (e2e/.spec.ts:) and passed + # to the run, which then executes only those. The pick is logged + # and archived, so a failing quick run can be rerun locally with + # the same tests. + # E2E_WORKERS : Playwright's worker count, for either mode (the fallback covers + # the first build after that parameter is added, when it isn't + # defined). QUICK_ARGS="" rm -f e2e-quick.txt # never archive a previous build's pick case "${E2E_MODE}" in QUICK_E2E_TESTS) - SHARDS=1 node packages/web/scripts/e2e-specs.mjs quick > e2e-quick.txt [ -s e2e-quick.txt ] || { echo "the quick e2e picked no tests"; exit 1; } cat e2e-quick.txt QUICK_ARGS="$(cat e2e-quick.txt)" ;; - FULL_E2E_TESTS) - SHARDS="${E2E_SHARDS:-2}" - case "${SHARDS}" in - [1-9]) ;; - *) echo "E2E_SHARDS must be a whole number from 1 to 9, got '${SHARDS}'"; exit 1 ;; - esac - ;; + FULL_E2E_TESTS) ;; *) echo "E2E_MODE must be QUICK_E2E_TESTS or FULL_E2E_TESTS here, got '${E2E_MODE}'"; exit 1 ;; esac + WORKERS="${E2E_WORKERS:-4}" + case "${WORKERS}" in + [1-9]|1[0-6]) ;; + *) echo "E2E_WORKERS must be a whole number from 1 to 16, got '${WORKERS}'"; exit 1 ;; + esac # Ephemeral Postgres for the browser e2e; the dev server connects to it. docker run -d --rm --name "${CI_E2E_PG_CONTAINER}" \ @@ -298,7 +299,8 @@ pipeline { WEB_POSTMORTEM="${PWD}/e2e-web-exit.log" WEB_PIDS="${PWD}/e2e-web.pids" HOST_STATS="${PWD}/e2e-host-stats.log" - rm -f "${WEB_EXIT}" "${WEB_POSTMORTEM}" "${WEB_PIDS}" "${HOST_STATS}" "${PWD}"/e2e-shard-*.exit + PW_EXIT="${PWD}/e2e-playwright.exit" # the Playwright run's exit status, once it ends + rm -f "${WEB_EXIT}" "${WEB_POSTMORTEM}" "${WEB_PIDS}" "${HOST_STATS}" "${PW_EXIT}" # A server opted out of the process-tree killer can outlive a hard-aborted build. Stop # one left by a previous build (only if that pid still is an InnoBox dev server — pids @@ -364,11 +366,11 @@ pipeline { ' e2e-web-wrapper "${WEB_EXIT}" "${WEB_PIDS}" "${WEB_POSTMORTEM}" "${OOM_KILLS_AT_START}" "${WEB_PIDFILE}" - # Removes every shard's Playwright container (they're named ${CI_E2E_PW_CONTAINER}-N). - stop_shards() { - docker ps -aq --filter "name=${CI_E2E_PW_CONTAINER}-" | xargs -r docker rm -f >/dev/null 2>&1 || true + # Removes the Playwright container. + stop_playwright() { + docker rm -f "${CI_E2E_PW_CONTAINER}" >/dev/null 2>&1 || true } - trap 'kill -TERM -"${WEB_PID}" >/dev/null 2>&1 || kill "${WEB_PID}" >/dev/null 2>&1 || true; stop_shards' EXIT + trap 'kill -TERM -"${WEB_PID}" >/dev/null 2>&1 || kill "${WEB_PID}" >/dev/null 2>&1 || true; stop_playwright' EXIT for _ in $(seq 1 90); do curl -sf "${E2E_BASE_URL}/api/auth/csrf" >/dev/null && break; sleep 2; done curl -sf "${E2E_BASE_URL}/api/auth/csrf" >/dev/null || { echo "dev server did not become ready"; cat e2e-web.log; exit 1; } @@ -405,13 +407,11 @@ pipeline { # browsers or system libraries — Docker is already an agent prerequisite. The tag is # derived from the installed @playwright/test so image browsers always match the driver. # - # E2E_SHARDS SHARDS AT ONCE, all against the one dev server + database above (Next allows - # one `next dev` per checkout, and more servers would multiply the memory). E2E_SHARD=i + - # E2E_SHARD_TOTAL=n make the config pick its slice: specs that change platform-wide - # state all stay in shard 1, the rest are balanced by test count (see - # e2e/playwright.config.ts). Each shard writes its own test-results/ and - # playwright-report/ subfolder; its output lines are prefixed [shard i]. They run in the - # background so the watchdog below can stop them. + # One run with E2E_WORKERS workers, all against the one dev server + database above (Next + # allows one `next dev` per checkout, and more servers would multiply the memory). The + # config keeps the specs that change platform-wide state to one worker between them, so + # they run one after another while the other workers share the rest (see + # e2e/playwright.config.ts). It runs in the background so the watchdog below can stop it. # --name : so the watchdog and cleanup can stop the container # --network host : the container reaches the dev server on 127.0.0.1:3000 # --ipc=host : Playwright's Chromium recommendation (avoids /dev/shm crashes) @@ -425,35 +425,26 @@ pipeline { # QUICK_ARGS : the quick run's picked test locations (empty on a full run) # The test runner itself never touches the DB, so only the base-URL env goes in. PW_VERSION="$(node -p "require('./packages/web/node_modules/@playwright/test/package.json').version")" - run_shard() { - { - rc=0 - docker run --rm --name "${CI_E2E_PW_CONTAINER}-$1" --network host --ipc=host \ - --user "$(id -u):$(id -g)" \ - -e HOME=/tmp -e CI=1 -e E2E_NO_SANDBOX=1 -e E2E_NO_WEBSERVER=1 -e E2E_BASE_URL \ - -e E2E_SHARD="$1" -e E2E_SHARD_TOTAL="${SHARDS}" \ - -v "${PWD}:${PWD}" -w "${PWD}/packages/web" \ - "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" \ - ./node_modules/.bin/playwright test -c e2e/playwright.config.ts ${QUICK_ARGS} || rc=$? - # (QUICK_ARGS is unquoted on purpose: one argument per picked location.) - echo "${rc}" > "${PWD}/e2e-shard-$1.exit" - } 2>&1 | sed -u "s/^/[shard $1] /" - } if [ -n "${QUICK_ARGS}" ]; then - echo "=== running the quick e2e: $(wc -l < e2e-quick.txt) randomly picked test(s), in 1 shard ===" + echo "=== running the quick e2e: $(wc -l < e2e-quick.txt) randomly picked test(s), ${WORKERS} worker(s) ===" else - echo "=== running the e2e in ${SHARDS} shard(s) ===" + echo "=== running the e2e with ${WORKERS} worker(s) ===" fi - SHARD_PIDS="" - for n in $(seq 1 "${SHARDS}"); do - run_shard "${n}" & - SHARD_PIDS="${SHARD_PIDS} $!" - done - - # How many shards have written their exit status so far. - shards_done() { ls e2e-shard-*.exit 2>/dev/null | wc -l; } - - # Watchdog: if the dev server dies mid-suite, stop every shard at once and say so, + { + rc=0 + docker run --rm --name "${CI_E2E_PW_CONTAINER}" --network host --ipc=host \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp -e CI=1 -e E2E_NO_SANDBOX=1 -e E2E_NO_WEBSERVER=1 -e E2E_BASE_URL \ + -e E2E_WORKERS="${WORKERS}" \ + -v "${PWD}:${PWD}" -w "${PWD}/packages/web" \ + "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" \ + ./node_modules/.bin/playwright test -c e2e/playwright.config.ts ${QUICK_ARGS} || rc=$? + # (QUICK_ARGS is unquoted on purpose: one argument per picked location.) + echo "${rc}" > "${PW_EXIT}" + } & + PW_PID=$! + + # Watchdog: if the dev server dies mid-suite, stop Playwright at once and say so, # rather than letting every remaining spec fail on "connection refused". Every tick # also appends a host sample to HOST_STATS (archived), so a death can be read against # the memory curve that led to it. Two ways to die: @@ -479,7 +470,7 @@ pipeline { PORT_CLOSED=0 CLOSED_TICKS=0 echo "# utc | host free / available / swap used (MB) | next parent pid:RSS | server child pid:RSS | load 1/5/15 | kernel OOM kills so far (${OOM_KILLS_AT_START} at server start)" > "${HOST_STATS}" - while [ "$(shards_done)" -lt "${SHARDS}" ]; do + while [ ! -f "${PW_EXIT}" ]; do # stdout only: stderr carries the step's xtrace, which would bury the samples (the # 20-line tail below would show trace lines instead of ticks). host_stats >> "${HOST_STATS}" 2>/dev/null || true @@ -487,7 +478,7 @@ pipeline { if [ -f "${WEB_EXIT}" ]; then SERVER_DIED=1 echo "=== the dev server exited mid-suite (status $(sed -n 1p "${WEB_EXIT}") at $(sed -n 2p "${WEB_EXIT}")) - stopping Playwright ===" - stop_shards + stop_playwright else probe=0 curl -sf --max-time 5 "${E2E_BASE_URL}/api/auth/csrf" >/dev/null 2>&1 || probe=$? @@ -497,24 +488,18 @@ pipeline { PORT_CLOSED=1 echo "=== the dev server's port has refused connections for 3 ticks with no exit status written: its server child died and the next dev parent (pid ${NEXT_PID:-?}) lingered - killing that parent and stopping Playwright ===" [ -z "${NEXT_PID}" ] || kill -TERM "${NEXT_PID}" >/dev/null 2>&1 || true - stop_shards + stop_playwright fi fi fi sleep 5 done - # shellcheck disable=SC2086 # word-splitting the PID list is intended - wait ${SHARD_PIDS} || true - - FAILED_SHARDS="" - for n in $(seq 1 "${SHARDS}"); do - rc="$(cat "e2e-shard-${n}.exit")" - echo "=== shard ${n} exit: ${rc} ===" - [ "${rc}" = 0 ] || FAILED_SHARDS="${FAILED_SHARDS} ${n}" - done + wait "${PW_PID}" || true + + PW_RC="$(cat "${PW_EXIT}")" + echo "=== Playwright exit: ${PW_RC} ===" - if [ "${SERVER_DIED}" = 1 ] || [ -n "${FAILED_SHARDS}" ]; then - [ -z "${FAILED_SHARDS}" ] || echo "=== failed shard(s):${FAILED_SHARDS} ===" + if [ "${SERVER_DIED}" = 1 ] || [ "${PW_RC}" != 0 ]; then echo "=== dev server log (e2e-web.log) ===" cat e2e-web.log if [ "${SERVER_DIED}" = 1 ]; then @@ -598,8 +583,7 @@ pipeline { kill -TERM -"$(cat e2e-web.pid)" >/dev/null 2>&1 || true rm -f e2e-web.pid fi - docker rm -f "${CI_E2E_PG_CONTAINER}" >/dev/null 2>&1 || true - docker ps -aq --filter "name=${CI_E2E_PW_CONTAINER}-" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker rm -f "${CI_E2E_PG_CONTAINER}" "${CI_E2E_PW_CONTAINER}" >/dev/null 2>&1 || true ''' // Kept even when the build is aborted (where the in-step `cat` never runs). archiveArtifacts artifacts: 'packages/web/playwright-report/**, e2e-web.log, e2e-quick.txt, e2e-host-stats.log, e2e-web-exit.log, e2e-web.pids', allowEmptyArchive: true @@ -711,8 +695,7 @@ MINIO_SCRIPT post { always { sh ''' - docker rm -f "${CI_PG_CONTAINER}" "${CI_E2E_PG_CONTAINER}" >/dev/null 2>&1 || true - docker ps -aq --filter "name=${CI_E2E_PW_CONTAINER}-" | xargs -r docker rm -f >/dev/null 2>&1 || true + docker rm -f "${CI_PG_CONTAINER}" "${CI_E2E_PG_CONTAINER}" "${CI_E2E_PW_CONTAINER}" >/dev/null 2>&1 || true ''' } success { echo "innobox pipeline OK — ${env.GIT_COMMIT}" } diff --git a/packages/web/e2e/access-control.spec.ts b/packages/web/e2e/access-control.spec.ts index a52b0fd..ec70033 100644 --- a/packages/web/e2e/access-control.spec.ts +++ b/packages/web/e2e/access-control.spec.ts @@ -4,6 +4,7 @@ // is refused the /admin console in-page. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("access control: an unauthenticated visitor is redirected to the sign-in landing", async ({ browser }) => { const ctx = await browser.newContext(); // no sign-in @@ -33,7 +34,7 @@ test("sign-in landing: signed-out Home shows the Entra button, no nav links, and }); test("access control: a non-admin member is refused the Administration console", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Member ${stamp}` }); // admin flag off → plain member const page = await ctx.newPage(); diff --git a/packages/web/e2e/admin-ai.spec.ts b/packages/web/e2e/admin-ai.spec.ts index 5f014e2..2880aa2 100644 --- a/packages/web/e2e/admin-ai.spec.ts +++ b/packages/web/e2e/admin-ai.spec.ts @@ -10,6 +10,7 @@ import http from "node:http"; import type { AddressInfo } from "node:net"; import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; const TOKEN = "sk-e2e-stub-key-0042"; @@ -45,7 +46,7 @@ function startStub(): Promise<{ base: string; close: () => Promise }> { } test("AI provider: platform-admin only; configure stub → load models → test → save → enable → failing save → remove", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const memberCtx = await browser.newContext(); diff --git a/packages/web/e2e/admin-audit-verify.spec.ts b/packages/web/e2e/admin-audit-verify.spec.ts index 4bebe5f..089ae49 100644 --- a/packages/web/e2e/admin-audit-verify.spec.ts +++ b/packages/web/e2e/admin-audit-verify.spec.ts @@ -3,9 +3,10 @@ // under the Admin chip; a plain member is refused (403) at the API. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("audit integrity: an admin verifies the chain and the run is audited", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const memberCtx = await browser.newContext(); await signIn(memberCtx, { name: `E2E Verify Member ${stamp}` }); diff --git a/packages/web/e2e/admin-audit.spec.ts b/packages/web/e2e/admin-audit.spec.ts index 1d6c5d2..4caa524 100644 --- a/packages/web/e2e/admin-audit.spec.ts +++ b/packages/web/e2e/admin-audit.spec.ts @@ -6,9 +6,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("audit log: an audited status change is filterable and its before/after is inspectable", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); // A member raises a challenge; the admin makes a distinctive transition (the audited action). const memberCtx = await browser.newContext(); diff --git a/packages/web/e2e/admin-banner.spec.ts b/packages/web/e2e/admin-banner.spec.ts index 300ffcf..d3977fd 100644 --- a/packages/web/e2e/admin-banner.spec.ts +++ b/packages/web/e2e/admin-banner.spec.ts @@ -4,9 +4,10 @@ // only — a member's direct API call is refused. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("system banner: publish → everyone sees it → replace → clear", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const adminCtx = await browser.newContext(); diff --git a/packages/web/e2e/admin-delete.spec.ts b/packages/web/e2e/admin-delete.spec.ts index 923d029..fec7b5a 100644 --- a/packages/web/e2e/admin-delete.spec.ts +++ b/packages/web/e2e/admin-delete.spec.ts @@ -11,9 +11,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, proposeSolutionViaApi, setChallengeStatusViaApi, setSolutionStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("a member never sees the danger zone, and their DELETE is a 404 that changes nothing", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const memberCtx = await browser.newContext(); await signIn(memberCtx, { name: `E2E Delete Member ${stamp}` }); @@ -41,7 +42,7 @@ test("a member never sees the danger zone, and their DELETE is a 404 that change }); test("the danger zone arms only on the typed number plus a reason, then deletes for good", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); await signIn(adminCtx, { name: "E2E Delete Admin", admin: true }); @@ -115,7 +116,7 @@ test("the danger zone arms only on the typed number plus a reason, then deletes }); test("a blank reason is refused by the API", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); await signIn(adminCtx, { name: "E2E Delete Admin", admin: true }); diff --git a/packages/web/e2e/admin-erasure.spec.ts b/packages/web/e2e/admin-erasure.spec.ts index dd1fd78..605399e 100644 --- a/packages/web/e2e/admin-erasure.spec.ts +++ b/packages/web/e2e/admin-erasure.spec.ts @@ -8,9 +8,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, createNamespaceViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("erasing a user hands visible open assignments to the chosen successor and lists the rest", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const json = { "content-type": "application/json", accept: "application/json" }; diff --git a/packages/web/e2e/admin-identity-sync.spec.ts b/packages/web/e2e/admin-identity-sync.spec.ts index f7dfaa3..7aae4e1 100644 --- a/packages/web/e2e/admin-identity-sync.spec.ts +++ b/packages/web/e2e/admin-identity-sync.spec.ts @@ -6,9 +6,10 @@ // admins, and the counts depend on SCIM-written rows the e2e stack does not provision. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("identity sync: platform-admin only — a member sees no card and gets 403 from the API", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const memberCtx = await browser.newContext(); await signIn(memberCtx, { name: `E2E Idsync Member ${stamp}` }); @@ -23,7 +24,7 @@ test("identity sync: platform-admin only — a member sees no card and gets 403 }); test("identity sync: the card shows counts, request times, the never-arrived list and a summary chip", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); await signIn(adminCtx, { name: `E2E Idsync Admin ${stamp}`, admin: true }); diff --git a/packages/web/e2e/admin-presence.spec.ts b/packages/web/e2e/admin-presence.spec.ts index 8f456c8..91171f8 100644 --- a/packages/web/e2e/admin-presence.spec.ts +++ b/packages/web/e2e/admin-presence.spec.ts @@ -6,9 +6,10 @@ // last_route, which the 60-second write throttle makes unreliable to stage through the UI. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("presence panel: platform-admin only, and a namespace-less member gets 403 from the API", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const memberCtx = await browser.newContext(); await signIn(memberCtx, { name: `E2E Presence Member ${stamp}` }); @@ -29,7 +30,7 @@ test("presence panel: platform-admin only, and a namespace-less member gets 403 }); test("presence panel: lists who is active, with the window, search and Refresh — and no Reach out", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); // A member whose sign-in lands them on the dashboard — an allowlisted request, so they are // located at "Overview". This is the allowlist working end to end. @@ -109,7 +110,7 @@ test("presence panel: lists who is active, with the window, search and Refresh }); test("presence panel: opening it writes one presence.view audit row (§14.5, §15)", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); const { userId } = await signIn(adminCtx, { name: `E2E Presence Auditor ${stamp}`, admin: true }); diff --git a/packages/web/e2e/admin-settings.spec.ts b/packages/web/e2e/admin-settings.spec.ts index c56e6d8..cae9b09 100644 --- a/packages/web/e2e/admin-settings.spec.ts +++ b/packages/web/e2e/admin-settings.spec.ts @@ -5,6 +5,7 @@ import { test, expect, type Page, type Locator } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, getImpactAreaId } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; // Impact-area rows all share the aria-label "Impact area name" (exact, to exclude the "New impact // area name" create box), so a row is identified by its input's current value. @@ -29,7 +30,7 @@ async function impactAreaRow(page: Page, value: string): Promise { } test("platform settings are refused to a non-platform-admin", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Settings Member ${stamp}` }); // plain member const page = await ctx.newPage(); @@ -40,7 +41,7 @@ test("platform settings are refused to a non-platform-admin", async ({ browser } test("impact areas: create, retire, then delete a zero-reference area", async ({ browser }) => { test.slow(); // first hit to each of the create/retire/delete impact-area routes compiles cold - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const name = `E2E ZeroRef Area ${stamp}`; const ctx = await browser.newContext(); @@ -74,7 +75,7 @@ test("impact areas: create, retire, then delete a zero-reference area", async ({ test("impact areas: a retired area with challenges is deleted only after reassignment", async ({ browser }) => { test.slow(); // create + submit + retire + delete each may hit a cold-compiled route - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const name = `E2E RefArea ${stamp}`; const ctx = await browser.newContext(); @@ -114,7 +115,7 @@ test("impact areas: a retired area with challenges is deleted only after reassig }); test("date format: switching between EU and US persists across a reload", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E DateFmt Admin ${stamp}`, admin: true }); const page = await ctx.newPage(); diff --git a/packages/web/e2e/admin-system-log.spec.ts b/packages/web/e2e/admin-system-log.spec.ts index fdbf651..bd5131d 100644 --- a/packages/web/e2e/admin-system-log.spec.ts +++ b/packages/web/e2e/admin-system-log.spec.ts @@ -4,9 +4,10 @@ // locked out of the page itself. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("system log: a member's 403 is recorded and visible to a platform admin", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const memberCtx = await browser.newContext(); diff --git a/packages/web/e2e/admin-trash.spec.ts b/packages/web/e2e/admin-trash.spec.ts index c22999e..996c31a 100644 --- a/packages/web/e2e/admin-trash.spec.ts +++ b/packages/web/e2e/admin-trash.spec.ts @@ -8,9 +8,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("a member never sees Move to trash, and the trash endpoints answer them 404", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const memberCtx = await browser.newContext(); await signIn(memberCtx, { name: `E2E Trash Member ${stamp}` }); const challenge = await createChallengeViaApi(memberCtx.request, { @@ -35,7 +36,7 @@ test("a member never sees Move to trash, and the trash endpoints answer them 404 }); test("move a challenge to the trash, see it gone from the list, restore it from the trash page", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const title = `E2E trash-me challenge ${stamp}`; const authorCtx = await browser.newContext(); diff --git a/packages/web/e2e/admin-triage.spec.ts b/packages/web/e2e/admin-triage.spec.ts index 528e9c0..29c8070 100644 --- a/packages/web/e2e/admin-triage.spec.ts +++ b/packages/web/e2e/admin-triage.spec.ts @@ -5,9 +5,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("triage queue: filter to a challenge by author, navigate to it, and switch tabs", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const memberName = `E2E Queue Member ${stamp}`; const title = `E2E triage-queue challenge ${stamp}`; @@ -45,7 +46,7 @@ test("triage queue: filter to a challenge by author, navigate to it, and switch }); test("triage queue: the CSV export is offered and produces a CSV", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); await signIn(adminCtx, { name: `E2E Export Admin ${stamp}`, admin: true }); const admin = await adminCtx.newPage(); diff --git a/packages/web/e2e/admin-webhooks.spec.ts b/packages/web/e2e/admin-webhooks.spec.ts index 46d371a..84a62d1 100644 --- a/packages/web/e2e/admin-webhooks.spec.ts +++ b/packages/web/e2e/admin-webhooks.spec.ts @@ -6,9 +6,10 @@ // public IP literal, so saving needs no DNS; no Send test is run (it would leave the machine). import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("channel webhooks: platform-admin only; add → hint only → switch off → edit keeps URL → delete", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const memberCtx = await browser.newContext(); diff --git a/packages/web/e2e/agent-access.spec.ts b/packages/web/e2e/agent-access.spec.ts index bd7b6b2..377c5ac 100644 --- a/packages/web/e2e/agent-access.spec.ts +++ b/packages/web/e2e/agent-access.spec.ts @@ -7,6 +7,7 @@ import { createHash, randomBytes } from "node:crypto"; import { test, expect, type APIRequestContext } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; const base = () => process.env.E2E_BASE_URL || "http://localhost:3000"; const REDIRECT = "http://127.0.0.1:59999/callback"; @@ -19,7 +20,7 @@ async function mcpCall(request: APIRequestContext, token: string, method: string } test("connect from /agents through consent; the agent's challenge shows via agent; revoke stops it", async ({ browser, playwright }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const clientName = `E2E Agent ${stamp}`; const adminCtx = await browser.newContext(); const personCtx = await browser.newContext(); diff --git a/packages/web/e2e/ai-draft.spec.ts b/packages/web/e2e/ai-draft.spec.ts index b4cacc9..9dbec98 100644 --- a/packages/web/e2e/ai-draft.spec.ts +++ b/packages/web/e2e/ai-draft.spec.ts @@ -7,9 +7,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi } from "./helpers/api"; import { aiKeyPresent, disableAiStub, enableAiStub, startAiStub } from "./helpers/ai-stub"; +import { uniqueStamp } from "./helpers/stamp"; test("Improve with AI: confirm, replace, hint, undo, duplicate check re-armed", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const adminCtx = await browser.newContext(); diff --git a/packages/web/e2e/ai-similar.spec.ts b/packages/web/e2e/ai-similar.spec.ts index 1aef09c..16df6b3 100644 --- a/packages/web/e2e/ai-similar.spec.ts +++ b/packages/web/e2e/ai-similar.spec.ts @@ -5,9 +5,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi } from "./helpers/api"; import { aiKeyPresent, disableAiStub, enableAiStub, startAiStub } from "./helpers/ai-stub"; +import { uniqueStamp } from "./helpers/stamp"; test("similar challenges, explained: reasons in the duplicate banner", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const adminCtx = await browser.newContext(); diff --git a/packages/web/e2e/ai-summary.spec.ts b/packages/web/e2e/ai-summary.spec.ts index 2cfde62..6cebcdc 100644 --- a/packages/web/e2e/ai-summary.spec.ts +++ b/packages/web/e2e/ai-summary.spec.ts @@ -6,9 +6,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi } from "./helpers/api"; import { aiKeyPresent, disableAiStub, enableAiStub, startAiStub } from "./helpers/ai-stub"; +import { uniqueStamp } from "./helpers/stamp"; test("discussion summary: summarize a long thread, then it goes stale", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const adminCtx = await browser.newContext(); diff --git a/packages/web/e2e/ai-triage.spec.ts b/packages/web/e2e/ai-triage.spec.ts index 536922f..728de80 100644 --- a/packages/web/e2e/ai-triage.spec.ts +++ b/packages/web/e2e/ai-triage.spec.ts @@ -7,9 +7,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi } from "./helpers/api"; import { aiKeyPresent, disableAiStub, enableAiStub, startAiStub } from "./helpers/ai-stub"; +import { uniqueStamp } from "./helpers/stamp"; test("triage assist: admins see the pending note and the queue indicator; the author never does", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const adminCtx = await browser.newContext(); diff --git a/packages/web/e2e/anonymity.spec.ts b/packages/web/e2e/anonymity.spec.ts index c284de6..ca1b1c9 100644 --- a/packages/web/e2e/anonymity.spec.ts +++ b/packages/web/e2e/anonymity.spec.ts @@ -4,9 +4,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { submitNewChallenge } from "./helpers/forms"; +import { uniqueStamp } from "./helpers/stamp"; test("anonymity: an anonymous challenge is masked as 'Anonymous', even to its author", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const authorName = `E2E Anon Author ${stamp}`; const title = `E2E anonymous challenge ${stamp}`; diff --git a/packages/web/e2e/attachment-drop.spec.ts b/packages/web/e2e/attachment-drop.spec.ts index dcdbee1..d4f200f 100644 --- a/packages/web/e2e/attachment-drop.spec.ts +++ b/packages/web/e2e/attachment-drop.spec.ts @@ -5,6 +5,7 @@ import { test, expect, type Page } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, proposeSolutionViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; interface Posted { name: string; @@ -81,7 +82,7 @@ async function paste(page: Page, opts: { files: { name: string; type: string }[] } test("challenge form: the whole page takes a drop — pre-checks, a sequential queue, Submit gated", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Drop Author ${stamp}` }); const page = await ctx.newPage(); @@ -115,7 +116,7 @@ test("challenge form: the whole page takes a drop — pre-checks, a sequential q }); test("challenge form: a file-only paste attaches (renamed); a paste with text stays text", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Paste Author ${stamp}` }); const page = await ctx.newPage(); @@ -139,7 +140,7 @@ test("challenge form: a file-only paste attaches (renamed); a paste with text st }); test("detail page: one overlay target per control; drops and pastes go where they're aimed", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Drop Admin ${stamp}`, admin: true }); const challenge = await createChallengeViaApi(ctx.request, { title: `Drop targets ${stamp}`, description: "Where do dropped files go?" }); @@ -176,7 +177,7 @@ test("detail page: one overlay target per control; drops and pastes go where the }); test("a file drag with no available control raises no overlay but never lets the browser open the file", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Drop Viewer ${stamp}`, admin: true }); const challenge = await createChallengeViaApi(ctx.request, { title: `No targets ${stamp}`, description: "Nothing editable here." }); diff --git a/packages/web/e2e/avatar-photo.spec.ts b/packages/web/e2e/avatar-photo.spec.ts index 095689a..9294ad3 100644 --- a/packages/web/e2e/avatar-photo.spec.ts +++ b/packages/web/e2e/avatar-photo.spec.ts @@ -3,10 +3,11 @@ // photo-less bubbles does not re-ask the gateway on every render. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("photo gateway: a user with no photo gets a short-lived cacheable 404", async ({ browser }) => { const base = process.env.E2E_BASE_URL || "http://localhost:3000"; - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); const { userId } = await signIn(ctx, { name: `E2E Photo Less ${stamp}` }); diff --git a/packages/web/e2e/challenges-list-view.spec.ts b/packages/web/e2e/challenges-list-view.spec.ts index d97d0dd..f8dbb1f 100644 --- a/packages/web/e2e/challenges-list-view.spec.ts +++ b/packages/web/e2e/challenges-list-view.spec.ts @@ -5,9 +5,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("the list view persists across reloads, rows navigate, and new items keep their tag", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const viewerCtx = await browser.newContext(); await signIn(viewerCtx, { name: `E2E ListView Viewer ${stamp}` }); diff --git a/packages/web/e2e/comment-replies.spec.ts b/packages/web/e2e/comment-replies.spec.ts index ddc3233..042e1aa 100644 --- a/packages/web/e2e/comment-replies.spec.ts +++ b/packages/web/e2e/comment-replies.spec.ts @@ -5,9 +5,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { submitNewChallenge } from "./helpers/forms"; +import { uniqueStamp } from "./helpers/stamp"; test("comment replies: reply, reply to a reply, show more replies", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const name = `E2E Replier ${stamp}`; const ctx = await browser.newContext(); await signIn(ctx, { name }); diff --git a/packages/web/e2e/core-journey.spec.ts b/packages/web/e2e/core-journey.spec.ts index 3684072..ef2e355 100644 --- a/packages/web/e2e/core-journey.spec.ts +++ b/packages/web/e2e/core-journey.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type Page } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { submitNewChallenge } from "./helpers/forms"; +import { uniqueStamp } from "./helpers/stamp"; // The challenge admin-override , if present). @@ -19,7 +20,7 @@ function challengeStatusSelect(page: Page) { } test("core journey: submit → triage → propose", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const title = `E2E journey challenge ${stamp}`; // ── 1. A regular member submits an org-visible challenge (starts at awaiting_triage). ── diff --git a/packages/web/e2e/detail-controls.spec.ts b/packages/web/e2e/detail-controls.spec.ts index f33fe19..4f82e4c 100644 --- a/packages/web/e2e/detail-controls.spec.ts +++ b/packages/web/e2e/detail-controls.spec.ts @@ -11,9 +11,10 @@ import { setNamespaceArchivedViaApi, setSolutionStatusViaApi, } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("Propose a solution is visible but disabled off `valid`; likes freeze while the challenge is closed", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); await signIn(adminCtx, { name: `E2E Freeze Admin ${stamp}`, admin: true }); const challenge = await createChallengeViaApi(adminCtx.request, { @@ -76,7 +77,7 @@ test("Propose a solution is visible but disabled off `valid`; likes freeze while }); test("Propose a solution is disabled with its own hint while the challenge's namespace is archived", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); await signIn(adminCtx, { name: `E2E Archive Admin ${stamp}`, admin: true }); const ns = await createNamespaceViaApi(adminCtx.request, { slug: `e2e-arch-${stamp}`, displayName: `E2E Archive ${stamp}` }); @@ -100,7 +101,7 @@ test("Propose a solution is disabled with its own hint while the challenge's nam }); test("an admin can unassign a challenge from the detail page", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const assigneeName = `E2E Unassign Target ${stamp}`; const assigneeCtx = await browser.newContext(); const { userId: assigneeId } = await signIn(assigneeCtx, { name: assigneeName }); diff --git a/packages/web/e2e/directory-card.spec.ts b/packages/web/e2e/directory-card.spec.ts index 8be0f3a..a76c366 100644 --- a/packages/web/e2e/directory-card.spec.ts +++ b/packages/web/e2e/directory-card.spec.ts @@ -15,11 +15,12 @@ import { test, expect, type Locator } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("directory card: hover opens it, keyboard reaches it, and an anonymous author never gets one", async ({ browser }) => { test.slow(); // Next dev-mode first-hit route compilation - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const authorName = `E2E Card Author ${stamp}`; const ctx = await browser.newContext(); @@ -111,7 +112,7 @@ test("directory card: hover opens it, keyboard reaches it, and an anonymous auth test("directory card: Tab routes into and out of the portalled card; your own bubble links to /profile", async ({ browser }) => { test.slow(); - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const authorName = `E2E Card Keys ${stamp}`; const ctx = await browser.newContext(); const { userId: authorId } = await signIn(ctx, { name: authorName }); @@ -184,7 +185,7 @@ test("directory card: Tab routes into and out of the portalled card; your own bu test("directory card: a scrubbed author renders the Deleted User bubble from the flag, with no profile and no link", async ({ browser }) => { test.slow(); - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const json = { "content-type": "application/json", accept: "application/json" }; const authorCtx = await browser.newContext(); const { userId: authorId } = await signIn(authorCtx, { name: `E2E Card Leaver ${stamp}` }); @@ -223,7 +224,7 @@ test("directory card: a scrubbed author renders the Deleted User bubble from the test("directory card: the read-only assignee on a terminal triage row opens the card without opening the row", async ({ browser }) => { test.slow(); - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const json = { "content-type": "application/json", accept: "application/json" }; const assigneeName = `E2E Card Assignee ${stamp}`; const assigneeCtx = await browser.newContext(); @@ -270,7 +271,7 @@ test("directory card: the read-only assignee on a terminal triage row opens the test("directory card: press and hold opens it on touch; a tap, a drag or an anonymous bubble never does", async ({ browser }) => { test.slow(); - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const authorName = `E2E Card Holder ${stamp}`; const ctx = await browser.newContext({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } }); await signIn(ctx, { name: authorName }); diff --git a/packages/web/e2e/discovery.spec.ts b/packages/web/e2e/discovery.spec.ts index 89e449b..4581f6c 100644 --- a/packages/web/e2e/discovery.spec.ts +++ b/packages/web/e2e/discovery.spec.ts @@ -5,9 +5,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi } from "./helpers/api"; import { UPCOMING } from "../src/app/whats-new/upcoming"; +import { uniqueStamp } from "./helpers/stamp"; test("dashboard: a signed-in member sees KPIs, quick links, and the account menu", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Dash Member ${stamp}` }); const page = await ctx.newPage(); @@ -37,7 +38,7 @@ test("dashboard: a signed-in member sees KPIs, quick links, and the account menu }); test("leaderboard: metric and window toggles change the active selection", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Board Member ${stamp}` }); const page = await ctx.newPage(); @@ -56,7 +57,7 @@ test("leaderboard: metric and window toggles change the active selection", async }); test("what's new: the changelog lists released versions newest-first", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Notes Member ${stamp}` }); const page = await ctx.newPage(); @@ -71,7 +72,7 @@ test("what's new: the changelog lists released versions newest-first", async ({ }); test("what's new: Coming soon starts collapsed, expands on click, and hides admin-only teasers from members", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Soon Member ${stamp}` }); const page = await ctx.newPage(); @@ -109,7 +110,7 @@ test("what's new: Coming soon starts collapsed, expands on click, and hides admi }); test("profile: the e-mail-notifications toggle round-trips, and the public profile loads", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const name = `E2E Profile Member ${stamp}`; const ctx = await browser.newContext(); const { userId } = await signIn(ctx, { name }); @@ -143,7 +144,7 @@ test("profile: the e-mail-notifications toggle round-trips, and the public profi }); test("profile: the e-mail delivery choice round-trips, names the next digest, and waits on the e-mail switch", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Digest Member ${stamp}` }); const page = await ctx.newPage(); @@ -174,7 +175,7 @@ test("profile: the e-mail delivery choice round-trips, names the next digest, an test("search results: an exact number lookup finds a challenge; gibberish yields the empty state", async ({ browser, }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Find Member ${stamp}` }); // The searcher is the author, so they can see their own (still-untriaged) challenge. diff --git a/packages/web/e2e/duplicate-warning.spec.ts b/packages/web/e2e/duplicate-warning.spec.ts index 6f4a7bc..f181456 100644 --- a/packages/web/e2e/duplicate-warning.spec.ts +++ b/packages/web/e2e/duplicate-warning.spec.ts @@ -4,9 +4,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("duplicate warning: similar challenges are suggested, edits re-arm, Submit anyway proceeds", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); // A distinctive word pair so only this run's fixture can match. const w1 = `flumox${stamp.replace(/[^a-z]/g, "q")}`; const w2 = `gribbly${stamp.replace(/[^a-z]/g, "q")}`; diff --git a/packages/web/e2e/engagement.spec.ts b/packages/web/e2e/engagement.spec.ts index 9e80c69..6cdcd91 100644 --- a/packages/web/e2e/engagement.spec.ts +++ b/packages/web/e2e/engagement.spec.ts @@ -5,9 +5,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { submitNewChallenge } from "./helpers/forms"; +import { uniqueStamp } from "./helpers/stamp"; test("engagement: comment (post + edit), like, and follow a challenge", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Engager ${stamp}` }); const page = await ctx.newPage(); diff --git a/packages/web/e2e/featured.spec.ts b/packages/web/e2e/featured.spec.ts index 036fd97..a1838ca 100644 --- a/packages/web/e2e/featured.spec.ts +++ b/packages/web/e2e/featured.spec.ts @@ -11,6 +11,7 @@ import { test, expect, type APIRequestContext } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; const TITLE_PREFIX = "E2E featured pin"; @@ -25,7 +26,7 @@ async function clearLeftoverPins(request: APIRequestContext): Promise { } test("a platform admin features a challenge and it appears on Home for everyone who can see it", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const title = `${TITLE_PREFIX} ${stamp}`; const adminCtx = await browser.newContext(); diff --git a/packages/web/e2e/form-lock.spec.ts b/packages/web/e2e/form-lock.spec.ts index c2e4c48..5f4b303 100644 --- a/packages/web/e2e/form-lock.spec.ts +++ b/packages/web/e2e/form-lock.spec.ts @@ -6,6 +6,7 @@ // otherwise passed through untouched to the real API. import { test, expect, type BrowserContext, type Page } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; const BASE = process.env.E2E_BASE_URL || "http://localhost:3000"; @@ -54,13 +55,13 @@ async function countMine(ctx: BrowserContext, title: string): Promise { } function uniqueTitle(tag: string): string { - const stamp = Date.now().toString(36).replace(/[^a-z]/g, "q"); + const stamp = uniqueStamp().replace(/[^a-z]/g, "q"); return `Zorbleq ${tag} vantrix ${stamp}`; } test("form lock: a double click creates exactly one challenge and shows Working…", async ({ browser }) => { const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Lock Click ${Date.now().toString(36)}` }); + await signIn(ctx, { name: `E2E Lock Click ${uniqueStamp()}` }); const title = uniqueTitle("click"); const { page, counts } = await openFilledForm(ctx, title); @@ -86,7 +87,7 @@ test("form lock: a double click creates exactly one challenge and shows Working test("form lock: a double Enter creates exactly one challenge", async ({ browser }) => { const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Lock Enter ${Date.now().toString(36)}` }); + await signIn(ctx, { name: `E2E Lock Enter ${uniqueStamp()}` }); const title = uniqueTitle("enter"); const { page, counts } = await openFilledForm(ctx, title); @@ -104,7 +105,7 @@ test("form lock: a double Enter creates exactly one challenge", async ({ browser test("form lock: two submit events in the same tick are guarded by the handler itself", async ({ browser }) => { const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Lock Tick ${Date.now().toString(36)}` }); + await signIn(ctx, { name: `E2E Lock Tick ${uniqueStamp()}` }); const title = uniqueTitle("tick"); const { page, counts } = await openFilledForm(ctx, title); diff --git a/packages/web/e2e/gamification.spec.ts b/packages/web/e2e/gamification.spec.ts index 3111d76..d53ae6c 100644 --- a/packages/web/e2e/gamification.spec.ts +++ b/packages/web/e2e/gamification.spec.ts @@ -5,11 +5,12 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, proposeSolutionViaApi, setChallengeStatusViaApi, setSolutionStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; const base = () => process.env.E2E_BASE_URL || "http://localhost:3000"; test("an implemented solution → the author's toast, the profile badge and the inbox row", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); const authorCtx = await browser.newContext(); await signIn(adminCtx, { name: `E2E Gamification Admin ${stamp}`, admin: true }); diff --git a/packages/web/e2e/helpers/stamp.ts b/packages/web/e2e/helpers/stamp.ts new file mode 100644 index 0000000..003520a --- /dev/null +++ b/packages/web/e2e/helpers/stamp.ts @@ -0,0 +1,13 @@ +// The unique tag in the names of a spec's own fixtures (INNOBOX_SPEC.md §17). Several workers run +// tests at once against one database (E2E_WORKERS in e2e/playwright.config.ts), so a millisecond +// timestamp alone can repeat in two of them. The worker's parallel index — unique among the workers +// running at the same time, and a letter so that callers keeping only the letters still have it — +// separates those, and the counter keeps two calls within one millisecond apart in one worker. +let last = 0; + +/** A base-36 timestamp plus a letter for this worker, unique across the tests running together. */ +export function uniqueStamp(): string { + last = Math.max(Date.now(), last + 1); + const worker = Number(process.env.TEST_PARALLEL_INDEX ?? 0); + return `${last.toString(36)}${String.fromCharCode(97 + (worker % 26))}`; +} diff --git a/packages/web/e2e/hype.spec.ts b/packages/web/e2e/hype.spec.ts index 929bf61..b4ccd68 100644 --- a/packages/web/e2e/hype.spec.ts +++ b/packages/web/e2e/hype.spec.ts @@ -6,11 +6,12 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, proposeSolutionViaApi, setChallengeStatusViaApi, setSolutionStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; const base = () => process.env.E2E_BASE_URL || "http://localhost:3000"; test("hype: spend one from the detail page, see it on Home, notify the author, earn Talent Scout", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const adminCtx = await browser.newContext(); const authorCtx = await browser.newContext(); const fanCtx = await browser.newContext(); diff --git a/packages/web/e2e/lifecycle.spec.ts b/packages/web/e2e/lifecycle.spec.ts index f9a9264..f10543e 100644 --- a/packages/web/e2e/lifecycle.spec.ts +++ b/packages/web/e2e/lifecycle.spec.ts @@ -18,9 +18,10 @@ import { setChallengeStatusViaApi, setSolutionStatusViaApi, } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("single-winner: implementing a solution solves the challenge and closes its siblings", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); // An admin raises a challenge and opens it for solutions. const adminCtx = await browser.newContext(); @@ -79,7 +80,7 @@ test("single-winner: implementing a solution solves the challenge and closes its }); test("needs-improvement unlocks the author's Edit + Resubmit and resubmitting returns to review", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); // A member raises a challenge (born awaiting_triage). const authorCtx = await browser.newContext(); @@ -116,7 +117,7 @@ test("needs-improvement unlocks the author's Edit + Resubmit and resubmitting re }); test("withdraw: the author withdraws their challenge, making it terminal and read-only", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); // NB: the persona name must not contain a control label ("Withdraw", "Edit", …) — the account // menu trigger is named after the persona, so a substring match would collide with the button. diff --git a/packages/web/e2e/new-markers.spec.ts b/packages/web/e2e/new-markers.spec.ts index 2c8eeb0..f5adb48 100644 --- a/packages/web/e2e/new-markers.spec.ts +++ b/packages/web/e2e/new-markers.spec.ts @@ -4,9 +4,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("new challenges light up the nav bubble and the card tag until the visitor leaves", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const viewerCtx = await browser.newContext(); await signIn(viewerCtx, { name: `E2E NewMarker Viewer ${stamp}` }); diff --git a/packages/web/e2e/notifications.spec.ts b/packages/web/e2e/notifications.spec.ts index 9f456cc..2204e66 100644 --- a/packages/web/e2e/notifications.spec.ts +++ b/packages/web/e2e/notifications.spec.ts @@ -6,9 +6,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("bell: a challenge author is notified when an admin triages their challenge", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const title = `E2E notify challenge ${stamp}`; // A member raises a challenge (auto-follows themselves → becomes a recipient of its events). @@ -37,7 +38,7 @@ test("bell: a challenge author is notified when an admin triages their challenge }); test("bell: a namespace admin is notified when a challenge is submitted for triage", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const title = `E2E triage-notify challenge ${stamp}`; // The admin exists first, so it's a recipient when the submission event fans out to ns admins. diff --git a/packages/web/e2e/playwright.config.ts b/packages/web/e2e/playwright.config.ts index ed4af6e..9eb8704 100644 --- a/packages/web/e2e/playwright.config.ts +++ b/packages/web/e2e/playwright.config.ts @@ -6,26 +6,20 @@ // The suites run against whatever database the dev server points at and create uniquely-named // (timestamped) fixtures, asserting on those specific rows — so they don't depend on a clean // database. (A fully isolated e2e database + server, like the dbtest harness, is a later CI step.) -import { readdirSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { defineConfig, devices } from "@playwright/test"; -import { specTests } from "../scripts/e2e-specs.mjs"; const baseURL = process.env.E2E_BASE_URL || "http://localhost:3000"; -// ── Sharding (Jenkins) ──────────────────────────────────────────────────────────────────────── -// E2E_SHARD=i with E2E_SHARD_TOTAL=n (1 ≤ i ≤ n ≤ 9) runs the i-th of n slices of the suite; -// Jenkins (its E2E_SHARDS parameter) runs all n at once against the SAME dev server and database -// (one `next dev` per checkout is all Next allows, and the agent can't afford more). Playwright's -// own --shard can't be used: it splits blindly, and these specs below change PLATFORM-WIDE state -// (the AI provider + its switches, the system banner, platform settings, channel webhooks, the -// featured list) that another spec running concurrently would observe. They are pinned to shard -// 1, where they keep running serially as before; every other spec only touches its own -// uniquely-named fixtures. The rest are dealt out greedily by test count so the slices come out -// roughly equal (shard 1 gets no more once its pinned specs exceed a fair share). Every spec -// lands in exactly one shard. E2E_SHARD unset → the whole suite, as before. (Jenkins' quick run, -// E2E_MODE=QUICK_E2E_TESTS, is shard 1 of 1 narrowed by test-location arguments — see scripts/e2e-specs.mjs.) +// ── Workers ─────────────────────────────────────────────────────────────────────────────────── +// E2E_WORKERS (1-16, default 4; Jenkins passes its E2E_WORKERS parameter) spec files run at once, +// all against the SAME dev server and database (one `next dev` per checkout is all Next allows, and +// the agent can't afford more). Every spec only touches its own uniquely-named fixtures +// (helpers/stamp.ts) — except these, which change PLATFORM-WIDE state (the AI provider + its +// switches, the system banner, platform settings, channel webhooks, the featured list, …) that +// another of them running at the same time would observe. They form the "platform-state" project, +// held to one worker so they run one after another, while the other workers share the rest of the +// suite. Run back to back they are about a quarter of the tests, which sets the floor on the +// suite's wall time: past about 4 workers, more only adds load on the one dev server. // A NEW spec that changes platform-wide state must be added here. const PLATFORM_STATE_SPECS = [ "admin-ai.spec.ts", @@ -43,42 +37,12 @@ const PLATFORM_STATE_SPECS = [ "survey.spec.ts", ]; -const e2eDir = path.dirname(fileURLToPath(import.meta.url)); +const PLATFORM_STATE_GLOBS = PLATFORM_STATE_SPECS.map((f) => `**/${f}`); -/** The spec files of shard `shard` of `total`. */ -function shardSpecs(shard: number, total: number): string[] { - const counts = new Map(); - for (const t of specTests(e2eDir)) counts.set(t.file, (counts.get(t.file) ?? 0) + 1); - const testCount = (file: string) => counts.get(file) ?? 0; - const specs = readdirSync(e2eDir).filter((f) => f.endsWith(".spec.ts")).sort(); - const slices = Array.from({ length: total }, () => ({ files: [] as string[], tests: 0 })); - for (const f of specs.filter((s) => PLATFORM_STATE_SPECS.includes(s))) { - slices[0].files.push(f); - slices[0].tests += testCount(f); - } - const rest = specs - .filter((s) => !PLATFORM_STATE_SPECS.includes(s)) - .map((f) => ({ f, n: testCount(f) })) - .sort((a, b) => b.n - a.n || a.f.localeCompare(b.f)); - for (const { f, n } of rest) { - // The lightest slice so far; ties go to the lowest index, so the split is deterministic. - const target = slices.reduce((min, s) => (s.tests < min.tests ? s : min)); - target.files.push(f); - target.tests += n; - } - return slices[shard - 1].files; -} - -const shardEnv = process.env.E2E_SHARD; -const shardTotalEnv = process.env.E2E_SHARD_TOTAL ?? "2"; -let shard: { index: number; total: number } | null = null; -if (shardEnv !== undefined) { - const index = Number(shardEnv); - const total = Number(shardTotalEnv); - if (!Number.isInteger(total) || total < 1 || total > 9 || !Number.isInteger(index) || index < 1 || index > total) { - throw new Error(`E2E_SHARD must be 1..E2E_SHARD_TOTAL and E2E_SHARD_TOTAL 1..9, got "${shardEnv}" of "${shardTotalEnv}"`); - } - shard = { index, total }; +const workersEnv = process.env.E2E_WORKERS ?? "4"; +const workers = Number(workersEnv); +if (!Number.isInteger(workers) || workers < 1 || workers > 16) { + throw new Error(`E2E_WORKERS must be a whole number from 1 to 16, got "${workersEnv}"`); } // Whether Playwright manages the dev server itself. Omitted when E2E_NO_WEBSERVER=1 — set on the @@ -110,17 +74,11 @@ const webServer = export default defineConfig({ testDir: ".", - ...(shard - ? { - testMatch: shardSpecs(shard.index, shard.total).map((f) => `**/${f}`), - // All shards run at once from the same checkout, so each writes its own results. - outputDir: `../test-results/shard-${shard.index}`, - } - : {}), - // Journey specs share database state and drive multi-actor flows, so run serially for - // determinism rather than in parallel. + // Whole spec files are dealt out to the workers, and a file's tests run in order in one worker, + // so two tests of the same file never overlap. Splitting files would buy nothing: the + // platform-state chain is far longer than any one file. fullyParallel: false, - workers: 1, + workers, // Generous timeouts absorb Next.js dev-mode's first-hit route compilation (a cold API route // can take several seconds to compile on its first request) so a fresh server doesn't flake. timeout: 120_000, @@ -133,9 +91,7 @@ export default defineConfig({ // In CI, stop once the suite is clearly broken rather than retrying every spec until the // Jenkins pipeline timeout aborts the build (which also swallows the error summary). maxFailures: process.env.CI ? 10 : 0, - reporter: process.env.CI - ? [["list"], ["html", { open: "never", ...(shard ? { outputFolder: `../playwright-report/shard-${shard.index}` } : {}) }]] - : "list", + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", use: { baseURL, // §2.4 CSRF: every state-changing /api/* request must carry an Origin equal to the app's own @@ -150,6 +106,11 @@ export default defineConfig({ // (throwaway CI container). Unset locally. ...(process.env.E2E_NO_SANDBOX === "1" ? { chromiumSandbox: false } : {}), }, - projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + projects: [ + // Listed first, so its files head the queue: whenever its one worker frees up it takes the + // next of them at once, and the chain that bounds the run never waits behind other files. + { name: "platform-state", testMatch: PLATFORM_STATE_GLOBS, workers: 1, use: { ...devices["Desktop Chrome"] } }, + { name: "isolated", testIgnore: PLATFORM_STATE_GLOBS, use: { ...devices["Desktop Chrome"] } }, + ], webServer, }); diff --git a/packages/web/e2e/public-submissions.spec.ts b/packages/web/e2e/public-submissions.spec.ts index 2a0c672..0e2df6e 100644 --- a/packages/web/e2e/public-submissions.spec.ts +++ b/packages/web/e2e/public-submissions.spec.ts @@ -5,12 +5,14 @@ // queue, and the challenge sits in the triage queue labelled "External submission"; and the §24.3 // visibility request — Specific people with its tag field, pre-filled into the Accept dialog, // shared once the challenge leaves triage (§4.3, §12.1 event 15). The switch is platform-wide -// (this spec is pinned to shard 1) and is put back as it was whatever happens. Needs a server with +// (this spec is in the one-at-a-time platform-state project) and is put back as it was whatever +// happens. Needs a server with // PUBLIC_INTAKE_KEY and an e-mail transport (Jenkins sets both) — it self-skips otherwise — and // reads the confirmation token from the dev seam, which is on wherever dev sign-in is. import { test, expect, type BrowserContext } from "@playwright/test"; import { devSlug, signIn } from "./helpers/auth"; import { setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; const base = () => process.env.E2E_BASE_URL || "http://localhost:3000"; const JSON_HEADERS = { "content-type": "application/json" }; @@ -34,7 +36,7 @@ const patchCard = (admin: BrowserContext, patch: Partial) => test.describe.configure({ mode: "serial" }); test("while the switch is off, /submit and every public route answer 404", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const admin = await browser.newContext(); const outsider = await browser.newContext(); await signIn(admin, { name: `E2E Public Off Admin ${stamp}`, admin: true }); @@ -81,7 +83,7 @@ test("while the switch is off, /submit and every public route answer 404", async }); test("submit on /submit, confirm through the link, accept in the queue → External submission in triage", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const admin = await browser.newContext(); const outsider = await browser.newContext(); // never signed in await signIn(admin, { name: `E2E Public Admin ${stamp}`, admin: true }); @@ -181,7 +183,7 @@ test("submit on /submit, confirm through the link, accept in the queue → Exter }); test("Specific people: the tag field, the pre-filled Accept, and sharing once the challenge leaves triage", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const admin = await browser.newContext(); const listed = await browser.newContext(); const unlisted = await browser.newContext(); diff --git a/packages/web/e2e/quick-start.spec.ts b/packages/web/e2e/quick-start.spec.ts index 122d955..c5e026e 100644 --- a/packages/web/e2e/quick-start.spec.ts +++ b/packages/web/e2e/quick-start.spec.ts @@ -4,9 +4,10 @@ // (freshOnboarding omitted) must NOT be redirected — every other e2e spec depends on that. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; test("first sign-in redirects to /quick-start; Continue marks it seen and returns to Home", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Onboarding ${stamp}`, freshOnboarding: true }); const page = await ctx.newPage(); @@ -34,7 +35,7 @@ test("first sign-in redirects to /quick-start; Continue marks it seen and return }); test("an ordinary dev sign-in (freshOnboarding omitted) is never redirected to /quick-start", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Regular ${stamp}` }); const page = await ctx.newPage(); @@ -46,7 +47,7 @@ test("an ordinary dev sign-in (freshOnboarding omitted) is never redirected to / }); test("the account menu's Quick start link is always reachable, even once seen", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E MenuLink ${stamp}` }); const page = await ctx.newPage(); diff --git a/packages/web/e2e/reveal.spec.ts b/packages/web/e2e/reveal.spec.ts index d50a835..ea105c7 100644 --- a/packages/web/e2e/reveal.spec.ts +++ b/packages/web/e2e/reveal.spec.ts @@ -9,9 +9,10 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, proposeSolutionViaApi, setChallengeStatusViaApi, setSolutionStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("an admin can reveal an anonymous author transiently (to themselves only)", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const realName = `E2E Hidden Person ${stamp}`; const authorCtx = await browser.newContext(); @@ -50,7 +51,7 @@ test("an admin can reveal an anonymous author transiently (to themselves only)", }); test("an anonymous author can permanently reveal themselves", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const realName = `E2E Owning Person ${stamp}`; const authorCtx = await browser.newContext(); @@ -80,7 +81,7 @@ test("an anonymous author can permanently reveal themselves", async ({ browser } }); test("a solution's anonymous author: admin reveal in the dialog, and one-way self-reveal", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const proposerName = `E2E Quiet Proposer ${stamp}`; const adminCtx = await browser.newContext(); diff --git a/packages/web/e2e/search-shortcut.spec.ts b/packages/web/e2e/search-shortcut.spec.ts index 010d094..c70cd8c 100644 --- a/packages/web/e2e/search-shortcut.spec.ts +++ b/packages/web/e2e/search-shortcut.spec.ts @@ -2,11 +2,12 @@ // anywhere while signed in, without stealing focus from an in-progress edit elsewhere. import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; const searchInput = (page: import("@playwright/test").Page) => page.getByPlaceholder("Search challenges & solutions…"); test("Ctrl+K focuses the topbar search from any page", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Shortcut ${stamp}` }); const page = await ctx.newPage(); @@ -20,7 +21,7 @@ test("Ctrl+K focuses the topbar search from any page", async ({ browser }) => { }); test("Ctrl+K does not steal focus from an in-progress edit in another field", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E ShortcutGuard ${stamp}` }); const page = await ctx.newPage(); @@ -36,7 +37,7 @@ test("Ctrl+K does not steal focus from an in-progress edit in another field", as }); test("a grey shortcut-hint pill advertises the combo and hides once the field is focused", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E ShortcutHint ${stamp}` }); const page = await ctx.newPage(); @@ -60,7 +61,7 @@ test("a grey shortcut-hint pill advertises the combo and hides once the field is const clearButton = (page: import("@playwright/test").Page) => page.getByRole("button", { name: "Clear search" }); test("a clear button appears from the first character and clears the field, keeping focus", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E SearchClear ${stamp}` }); const page = await ctx.newPage(); @@ -84,7 +85,7 @@ test("a clear button appears from the first character and clears the field, keep }); test("Escape progressively clears the field, then exits it when already empty", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E SearchEscape ${stamp}` }); const page = await ctx.newPage(); @@ -105,7 +106,7 @@ test("Escape progressively clears the field, then exits it when already empty", }); test("the clear button is suppressed on the mobile layout", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E SearchClearMobile ${stamp}` }); const page = await ctx.newPage(); diff --git a/packages/web/e2e/security-baseline.spec.ts b/packages/web/e2e/security-baseline.spec.ts index c5443d5..b1f6d21 100644 --- a/packages/web/e2e/security-baseline.spec.ts +++ b/packages/web/e2e/security-baseline.spec.ts @@ -4,6 +4,7 @@ // requests (a foreign or missing Origin is refused before the route runs). import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; const base = process.env.E2E_BASE_URL || "http://localhost:3000"; @@ -33,7 +34,7 @@ test("pages carry a per-request nonce CSP and the static security headers; no X- }); test("a signed-in page renders and hydrates under the CSP with no violations", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E CSP ${stamp}` }); const page = await ctx.newPage(); @@ -52,7 +53,7 @@ test("a signed-in page renders and hydrates under the CSP with no violations", a }); test("state-changing API requests from a foreign or missing Origin are refused with 403", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E CSRF ${stamp}` }); const body = { quickStartSeen: true }; diff --git a/packages/web/e2e/sign-out.spec.ts b/packages/web/e2e/sign-out.spec.ts index 8866276..ce2823d 100644 --- a/packages/web/e2e/sign-out.spec.ts +++ b/packages/web/e2e/sign-out.spec.ts @@ -4,6 +4,7 @@ // CSRF-rejected sign-out sweeps nothing and keeps the session. import { test, expect, type BrowserContext } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; const base = process.env.E2E_BASE_URL || "http://localhost:3000"; const host = new URL(base).hostname; @@ -20,7 +21,7 @@ const authCookies = async (ctx: BrowserContext) => (await ctx.cookies(base)).map((c) => c.name).filter((n) => n.replace(/^__(Secure|Host)-/, "").startsWith("next-auth.")); test("Sign out from the account menu clears every Auth.js cookie and keeps non-auth state", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Signout ${stamp}` }); await plantLeftovers(ctx); @@ -51,7 +52,7 @@ test("Sign out from the account menu clears every Auth.js cookie and keeps non-a }); test("a CSRF-rejected sign-out sweeps nothing and keeps the session", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Signout CSRF ${stamp}` }); await plantLeftovers(ctx); diff --git a/packages/web/e2e/survey.spec.ts b/packages/web/e2e/survey.spec.ts index 9ab8d2a..0dcdc49 100644 --- a/packages/web/e2e/survey.spec.ts +++ b/packages/web/e2e/survey.spec.ts @@ -11,13 +11,14 @@ // opt-out. One of them flips the Bug reports switch and always flips it back. import { test, expect, type Page } from "@playwright/test"; import { signIn } from "./helpers/auth"; +import { uniqueStamp } from "./helpers/stamp"; async function openAccountMenu(page: Page) { await page.locator(".user-trigger").click(); } test("a first use shows the card; ✕ keeps the offer in the menu; reopening and submitting thanks and clears it", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Surveyed ${stamp}`, survey: true }); const page = await ctx.newPage(); @@ -58,7 +59,7 @@ test("a first use shows the card; ✕ keeps the offer in the menu; reopening and }); test("Don't ask me again turns the profile switch off", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Survey OptOut ${stamp}`, survey: true }); const page = await ctx.newPage(); @@ -77,7 +78,7 @@ test("Don't ask me again turns the profile switch off", async ({ browser }) => { }); test("at mobile width the card is a bottom sheet with ✕ and Submit visible", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext({ viewport: { width: 375, height: 740 } }); await signIn(ctx, { name: `E2E Survey Mobile ${stamp}`, survey: true }); const page = await ctx.newPage(); @@ -104,7 +105,7 @@ test("at mobile width the card is a bottom sheet with ✕ and Submit visible", a }); test("with the What's new notice due, a first use produces no survey — What's new always wins", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); // An old marker makes the update notice due on this load. await signIn(ctx, { name: `E2E Survey Notice ${stamp}`, survey: true, whatsNewSeen: "0.1.0" }); @@ -118,7 +119,7 @@ test("with the What's new notice due, a first use produces no survey — What's }); test("a persona with surveys off never sees the card", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E No Survey ${stamp}` }); const page = await ctx.newPage(); @@ -130,7 +131,7 @@ test("a persona with surveys off never sees the card", async ({ browser }) => { }); test("admin: the Survey results card shows the funnel; its switch works while collapsed; members get 403", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Survey Admin ${stamp}`, admin: true }); const page = await ctx.newPage(); @@ -170,7 +171,7 @@ test("admin: the Survey results card shows the funnel; its switch works while co // These personas sign in WITHOUT the survey flag: the on-demand path bypasses the random opt-out. test("Give feedback now opens the on-demand card; a picked feature adds its questions; then the cooldown", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Give Feedback ${stamp}` }); const page = await ctx.newPage(); @@ -211,7 +212,7 @@ test("the colophon's Have your say: absent signed out; opens the card; survives await expect(anonPage.locator(".colophon .colophon-sub").nth(1)).toHaveText("Powered by the community"); await anon.close(); - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Have Your Say ${stamp}` }); const page = await ctx.newPage(); @@ -252,7 +253,7 @@ test("the bug button: absent signed out; opens Report a bug with no stars; Submi await expect(anonPage.getByTestId("report-bug")).toHaveCount(0); await anon.close(); - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Bug Reporter ${stamp}` }); const page = await ctx.newPage(); @@ -293,7 +294,7 @@ test("the bug button: absent signed out; opens Report a bug with no stars; Submi }); test("from the bug card, another category starts an on-demand survey; afterwards the other categories wait for the cooldown", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Bug Then Survey ${stamp}` }); const page = await ctx.newPage(); @@ -323,7 +324,7 @@ test("from the bug card, another category starts an on-demand survey; afterwards }); test("Give feedback, then Bug: Report a bug mode, and the submit ends the offer", async ({ browser }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Feedback Then Bug ${stamp}` }); const page = await ctx.newPage(); @@ -352,7 +353,7 @@ test("Give feedback, then Bug: Report a bug mode, and the submit ends the offer" test("admin: Bug reports as a Source and in the funnel; the Bug reports switch hides the button", async ({ browser }) => { // Results lag by a day, so a report sent here is not listed yet; the feed's Bug tag, page path // and version are covered by the integration suite with backdated rows. - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const ctx = await browser.newContext(); await signIn(ctx, { name: `E2E Bug Admin ${stamp}`, admin: true }); const page = await ctx.newPage(); diff --git a/packages/web/e2e/visibility.spec.ts b/packages/web/e2e/visibility.spec.ts index 6bc0abd..6029ec9 100644 --- a/packages/web/e2e/visibility.spec.ts +++ b/packages/web/e2e/visibility.spec.ts @@ -9,11 +9,12 @@ import { test, expect } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { createChallengeViaApi, createNamespaceViaApi, setChallengeStatusViaApi } from "./helpers/api"; +import { uniqueStamp } from "./helpers/stamp"; test("a namespace-restricted challenge is hidden from a non-member across gallery, search, and direct link", async ({ browser, }) => { - const stamp = Date.now().toString(36); + const stamp = uniqueStamp(); const title = `E2E restricted challenge ${stamp}`; // ── Admin sets up a fresh namespace + a namespace-visible challenge, opened for solutions. ── diff --git a/packages/web/e2e/whats-new-notice.spec.ts b/packages/web/e2e/whats-new-notice.spec.ts index ec33d25..8da1011 100644 --- a/packages/web/e2e/whats-new-notice.spec.ts +++ b/packages/web/e2e/whats-new-notice.spec.ts @@ -5,6 +5,7 @@ import { test, expect, type Page } from "@playwright/test"; import { signIn } from "./helpers/auth"; import { CHANGELOG } from "../src/app/whats-new/changelog"; import { UPCOMING } from "../src/app/whats-new/upcoming"; +import { uniqueStamp } from "./helpers/stamp"; const CURRENT = CHANGELOG[0]!; const PREVIOUS = CHANGELOG[1]!; @@ -17,7 +18,7 @@ async function highlightedVersions(page: Page): Promise { test("a release below the marker shows the notice; it survives a reload and closes only on ✕", async ({ browser }) => { const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Notice Dismiss ${Date.now().toString(36)}`, whatsNewSeen: PREVIOUS.version }); + await signIn(ctx, { name: `E2E Notice Dismiss ${uniqueStamp()}`, whatsNewSeen: PREVIOUS.version }); const page = await ctx.newPage(); await page.goto("/"); @@ -54,7 +55,7 @@ test("the link lands on the page with exactly the missed entries highlighted abo const seen = CHANGELOG[3]!.version; const missed = CHANGELOG.slice(0, 3).map((e) => e.version); const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Notice Link ${Date.now().toString(36)}`, whatsNewSeen: seen }); + await signIn(ctx, { name: `E2E Notice Link ${uniqueStamp()}`, whatsNewSeen: seen }); const page = await ctx.newPage(); await page.goto("/"); @@ -87,7 +88,7 @@ test("the link lands on the page with exactly the missed entries highlighted abo test("opening the page from the account menu closes the notice and highlights from the marker", async ({ browser }) => { const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Notice Menu ${Date.now().toString(36)}`, whatsNewSeen: CHANGELOG[2]!.version }); + await signIn(ctx, { name: `E2E Notice Menu ${uniqueStamp()}`, whatsNewSeen: CHANGELOG[2]!.version }); const page = await ctx.newPage(); await page.goto("/"); await expect(notice(page)).toBeVisible(); @@ -112,7 +113,7 @@ test("rollout: a null marker counts and highlights from the boundary date", asyn const boundary = CHANGELOG[2]!.date; const expected = CHANGELOG.filter((e) => e.date >= boundary).map((e) => e.version); const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Notice Rollout ${Date.now().toString(36)}`, whatsNewSeen: "none", whatsNewSinceDate: boundary }); + await signIn(ctx, { name: `E2E Notice Rollout ${uniqueStamp()}`, whatsNewSeen: "none", whatsNewSinceDate: boundary }); const page = await ctx.newPage(); await page.goto("/"); @@ -133,7 +134,7 @@ for (const viewport of [ test(`overflow at ${viewport.name}: the card fits, the list scrolls, ✕ and link stay visible`, async ({ browser }) => { const ctx = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height } }); // Far enough back that three long summaries overflow the 60vh cap. - await signIn(ctx, { name: `E2E Notice Overflow ${viewport.width} ${Date.now().toString(36)}`, whatsNewSeen: "0.0.0" }); + await signIn(ctx, { name: `E2E Notice Overflow ${viewport.width} ${uniqueStamp()}`, whatsNewSeen: "0.0.0" }); const page = await ctx.newPage(); await page.goto("/"); await expect(notice(page)).toBeVisible(); @@ -161,7 +162,7 @@ for (const viewport of [ test("Quick start's Continue leaves no notice behind", async ({ browser }) => { const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Notice Onboard ${Date.now().toString(36)}`, freshOnboarding: true, whatsNewSeen: "none" }); + await signIn(ctx, { name: `E2E Notice Onboard ${uniqueStamp()}`, freshOnboarding: true, whatsNewSeen: "none" }); const page = await ctx.newPage(); await page.goto("/"); await expect(page).toHaveURL(/\/quick-start$/); @@ -179,7 +180,7 @@ test("Quick start's Continue leaves no notice behind", async ({ browser }) => { test("the page opens with Coming soon greyed out above the releases, filtered by role", async ({ browser }) => { const ctx = await browser.newContext(); - await signIn(ctx, { name: `E2E Soon Member ${Date.now().toString(36)}` }); + await signIn(ctx, { name: `E2E Soon Member ${uniqueStamp()}` }); const page = await ctx.newPage(); await page.goto("/whats-new"); diff --git a/packages/web/scripts/e2e-specs.mjs b/packages/web/scripts/e2e-specs.mjs index b9ca97e..68d912b 100644 --- a/packages/web/scripts/e2e-specs.mjs +++ b/packages/web/scripts/e2e-specs.mjs @@ -1,7 +1,7 @@ -// The e2e suite's test index (INNOBOX_SPEC.md §17), shared by e2e/playwright.config.ts (it balances -// the Jenkins shards by test count) and the quick run (Jenkins' E2E_MODE=QUICK_E2E_TESTS). +// The e2e suite's test index (INNOBOX_SPEC.md §17), for the quick run (Jenkins' +// E2E_MODE=QUICK_E2E_TESTS). // -// A quick run executes a random 10% of the suite's tests in a single shard — a smoke signal in a +// A quick run executes a random 10% of the suite's tests — a smoke signal in a // fraction of the time, not a substitute for the full run. Picking individual tests (not whole // spec files) is safe because no spec chains tests together: there is no serial mode, no // beforeAll, and every test creates its own uniquely-named fixtures.