diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d37772938..4576ea7e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,22 @@ jobs: if: ${{ failure() }} uses: ./.github/actions/upload-testlogs + web: + name: Web + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Run web checks + run: make web-check + + - name: Upload Bazel failure logs + if: ${{ failure() }} + uses: ./.github/actions/upload-testlogs + # --------------------------------------------------------------------------- # INTEGRATION TESTS (e2e, gateway, orchestrator) # --------------------------------------------------------------------------- @@ -126,6 +142,22 @@ jobs: if: ${{ failure() }} uses: ./.github/actions/upload-testlogs + web-e2e: + name: Web E2E + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Run real-stack web E2E + run: make web-e2e-test + + - name: Upload Bazel failure logs + if: ${{ failure() }} + uses: ./.github/actions/upload-testlogs + gateway-integration-test: name: Gateway Integration Test if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} @@ -310,6 +342,8 @@ jobs: - queue-integration-test - storage-integration-test - consumer-integration-test + - web + - web-e2e - workflow-security steps: - name: Fail if any required check did not succeed diff --git a/Makefile b/Makefile index 45d64dc4c..ff99d0430 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ COMPOSE = docker-compose # SubmitQueue compose files COMPOSE_FILE = service/submitqueue/docker-compose.yml +WEB_COMPOSE_FILE = service/submitqueue/docker-compose.web.yml GATEWAY_COMPOSE_FILE = service/submitqueue/gateway/server/docker-compose.yml ORCHESTRATOR_COMPOSE_FILE = service/submitqueue/orchestrator/server/docker-compose.yml @@ -41,6 +42,10 @@ PROTO_PACKAGES = api/base/change api/base/hook api/base/mergestrategy api/base/m # Set REPO_ROOT for docker-compose export REPO_ROOT := $(shell pwd) +# Local-only credential for the reference web host. Override it for manual +# testing; production authentication is deliberately outside this demo. +export SUBMITQUEUE_WEB_TOKEN ?= test + # Which provider the demo stack targets, and the only difference between a free # local run and a live one. Selects a configuration directory rather than a code # path, so adding a provider is mostly adding a directory — see @@ -148,7 +153,7 @@ define assert_clean fi endef -.PHONY: build build-all-linux build-runway-linux build-submitqueue-gateway-client build-submitqueue-gateway-linux build-submitqueue-gateway-server build-submitqueue-orchestrator-linux build-stovepipe-linux build-stovepipe-linux-debug check-gazelle check-mocks check-tidy clean clean-proto demo-requests deps e2e-test fmt gazelle integration-test integration-test-submitqueue-consumer integration-test-extensions integration-test-submitqueue-gateway integration-test-submitqueue-orchestrator license-fix lint lint-binary lint-fmt lint-license local-init-runway-queue-schema local-init-stovepipe-schemas local-runway-start local-runway-stop local-submitqueue-stop local-submitqueue-clean local-submitqueue-gateway-start local-submitqueue-gateway-stop local-init-submitqueue-schemas local-submitqueue-logs local-submitqueue-orchestrator-start local-submitqueue-orchestrator-stop local-submitqueue-ps local-submitqueue-restart local-submitqueue-start local-stop local-stovepipe-debug-start local-stovepipe-logs local-stovepipe-start local-stovepipe-stop mocks proto query-deps query-targets run-client-runway run-client-submitqueue-gateway run-client-submitqueue-orchestrator run-client-stovepipe run-queue-admin test test-no-cache test-race tidy tidy-bazel tidy-go help +.PHONY: build build-all-linux build-runway-linux build-submitqueue-gateway-client build-submitqueue-gateway-linux build-submitqueue-gateway-server build-submitqueue-orchestrator-linux build-stovepipe-linux build-stovepipe-linux-debug check-gazelle check-mocks check-tidy clean clean-proto demo-requests deps e2e-test fmt gazelle integration-test integration-test-submitqueue-consumer integration-test-extensions integration-test-submitqueue-gateway integration-test-submitqueue-orchestrator license-fix lint lint-binary lint-fmt lint-license local-init-runway-queue-schema local-init-stovepipe-schemas local-runway-start local-runway-stop local-submitqueue-stop local-submitqueue-clean local-submitqueue-gateway-start local-submitqueue-gateway-stop local-init-submitqueue-schemas local-submitqueue-logs local-submitqueue-orchestrator-start local-submitqueue-orchestrator-stop local-submitqueue-ps local-submitqueue-restart local-submitqueue-start local-stop local-stovepipe-debug-start local-stovepipe-logs local-stovepipe-start local-stovepipe-stop mocks proto query-deps query-targets run-client-runway run-client-submitqueue-gateway run-client-submitqueue-orchestrator run-client-stovepipe run-queue-admin test test-no-cache test-race tidy tidy-bazel tidy-go web-build web-check web-e2e-test web-image-load web-install web-proto help build: ## Build all services and examples @@ -361,7 +366,7 @@ local-submitqueue-clean: ## Stop the stack and remove its volumes, images, and P @echo "Cleaning all services and data..." @# The overlay is named so that volumes it declares — Runway's checkouts — @# are removed too, rather than surviving as an orphan. - @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down -v --rmi local + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down -v --rmi local @rm -rf "$(SQ_GIT_SANDBOX_DIR)" @echo "All services, volumes, images, and $(SQ_GIT_SANDBOX_DIR) removed." @@ -450,7 +455,7 @@ local-runway-stop: ## Stop Runway service @echo "Runway services stopped." local-submitqueue-logs: ## View logs from all running services - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) logs -f + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) logs -f local-submitqueue-orchestrator-start: build-submitqueue-orchestrator-linux ## Start Orchestrator service locally (Orchestrator + 2 MySQL databases) @echo "Starting Orchestrator with docker-compose..." @@ -474,11 +479,12 @@ local-submitqueue-orchestrator-stop: ## Stop Orchestrator service local-submitqueue-ps: ## Show running containers and their ports @echo "Running containers and ports:" @echo "" - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) ps + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) ps @echo "" @echo "📡 Service Endpoints:" @echo " Gateway gRPC: localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-gateway-service-1 8080 2>/dev/null | cut -d: -f2 || echo 'not running')" @echo " Orchestrator gRPC: localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-orchestrator-service-1 8080 2>/dev/null | cut -d: -f2 || echo 'not running')" + @echo " Web UX: http://localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-web-service-1 3000 2>/dev/null | cut -d: -f2 || echo 'not running')" @echo "" @echo "🗄️ Database Endpoints:" @echo " MySQL App: localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-mysql-app-1 3306 2>/dev/null | cut -d: -f2 || echo 'not running')" @@ -496,11 +502,11 @@ local-submitqueue-ps: ## Show running containers and their ports local-submitqueue-restart: build-all-linux ## Restart all services (rebuild and restart) @echo "Restarting all services..." - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) restart + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) restart @echo "Services restarted!" @make local-submitqueue-ps -local-submitqueue-start: build-all-linux ## Start full stack (PROVIDER=fake|git|github; github needs GITHUB_TOKEN) +local-submitqueue-start: build-all-linux web-image-load ## Start full stack (PROVIDER=fake|git|github; github needs GITHUB_TOKEN) @echo "Starting full stack against provider '$(PROVIDER)' ($(SQ_PROVIDER_CONFIG_DIR))..." @test -f "$(SQ_PROVIDER_CONFIG_DIR)/merge.yaml" \ || { echo "No such provider '$(PROVIDER)': $(SQ_PROVIDER_CONFIG_DIR)/merge.yaml not found"; exit 2; } @@ -519,13 +525,16 @@ local-submitqueue-start: build-all-linux ## Start full stack (PROVIDER=fake|git| else \ export SQ_CONTAINER_USER=$$(id -u):$$(id -g); \ fi; \ - $(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) up -d --build --wait + $(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) up -d --build --wait @echo "Applying database schemas..." @$(MAKE) -s local-init-submitqueue-schemas @echo "" @echo "✅ Stack is running against provider '$(PROVIDER)'." @echo "" @echo "Gateway gRPC port: $$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-gateway-service-1 8080 2>/dev/null | cut -d: -f2 || echo 'unknown')" + @echo "Web UX: http://localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-web-service-1 3000 2>/dev/null | cut -d: -f2 || echo 'unknown')" + @echo "Web username: test" + @echo "Web token: $(SUBMITQUEUE_WEB_TOKEN)" @if [ "$(PROVIDER)" = "git" ]; then \ echo "Land target: $(SQ_GIT_SANDBOX_DIR)/sandbox.git"; \ fi @@ -535,7 +544,7 @@ local-submitqueue-start: build-all-linux ## Start full stack (PROVIDER=fake|git| local-submitqueue-stop: ## Stop the SubmitQueue stack (keeps PROVIDER=git's sandbox; the databases do not survive) @echo "Stopping SubmitQueue services..." - @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down @# Both MySQL services mount anonymous volumes, so `down` detaches them and @# the next `up` creates fresh ones. Saying "data preserved" here would be @# read as "your requests are still there", which they are not. @@ -547,7 +556,7 @@ local-submitqueue-stop: ## Stop the SubmitQueue stack (keeps PROVIDER=git's sand local-stop: ## Stop every local stack — SubmitQueue, Stovepipe, and Runway @echo "Stopping all services..." - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) down @$(COMPOSE) -f $(RUNWAY_COMPOSE_FILE) -p $(RUNWAY_LOCAL_PROJECT) down @echo "Services stopped. Anonymous database volumes are not reused on the next start." @@ -677,6 +686,25 @@ tidy-go: ## Run go mod tidy @echo "Running go mod tidy..." @$(BAZEL) run @rules_go//go -- mod tidy -e +web-build: ## Build the web packages and deployable Next.js image with Bazel + @$(BAZEL) build //web/api:pkg //web/submitqueue:pkg //web/service/submitqueue:image + +web-check: ## Run web generation, packaging, lint, type, unit, and image checks with Bazel + @$(BAZEL) test //web:check --test_output=errors + @$(BAZEL) build //web/service/submitqueue:image + +web-e2e-test: ## Run the Bazel-managed real-stack Playwright and axe web test + @$(BAZEL) test //web/test/e2e:web_test --test_output=errors + +web-image-load: ## Build and load the deployable web image into Docker with Bazel + @$(BAZEL) run //web/service/submitqueue:image_load + +web-install: ## Install the pinned web workspace dependencies + @corepack pnpm@10.17.1 --dir web install --frozen-lockfile + +web-proto: ## Regenerate the committed TypeScript protobuf API with Bazel + @$(BAZEL) run //web/api:generate + help: ## Show this help message @echo "Available targets:" @echo "" diff --git a/doc/howto/DEVELOPMENT.md b/doc/howto/DEVELOPMENT.md index a37cca1df..b7f45f968 100644 --- a/doc/howto/DEVELOPMENT.md +++ b/doc/howto/DEVELOPMENT.md @@ -108,6 +108,10 @@ CI runs `make lint`, `make check-tidy`, and `make check-gazelle`. `make lint` in | `make gazelle` | Update `BUILD.bazel` files | | `make mocks` | Regenerate mockgen files | | `make check-mocks` | Fail if generated mocks are stale | +| `make web-build` | Build the web packages and deployable OCI image with Bazel | +| `make web-check` | Run web generation drift, package, lint, type, unit, and production image checks with Bazel | +| `make web-e2e-test` | Run the Bazel-managed real-stack Playwright and axe test | +| `make web-proto` | Regenerate the committed TypeScript protobuf API with Bazel | | `make proto` | Regenerate protobuf files | | `make clean` | Remove the Bazel cache and `bin/` | | `make clean-proto` | Remove generated protobuf Go files | @@ -118,6 +122,8 @@ CI runs `make lint`, `make check-tidy`, and `make check-gazelle`. `make lint` in | `make local-stop` | Stop SubmitQueue, Stovepipe, and Runway | | `make help` | List every target | +The web build does not require a host Node.js or pnpm installation: Bazel supplies Node, npm packages, Buf, TypeScript, Next.js, Playwright, Chromium, and the container image toolchain. `make web-install` is an optional convenience for editors and direct pnpm development only. + ## Running Specific Tests ```bash diff --git a/doc/howto/QUICKSTART.md b/doc/howto/QUICKSTART.md index d8892b71b..9fcc5d622 100644 --- a/doc/howto/QUICKSTART.md +++ b/doc/howto/QUICKSTART.md @@ -22,7 +22,7 @@ The queue's own logic is real on every rung; what changes is how much of the wor make local-submitqueue-start ``` -This builds the Linux binaries, brings up Gateway, Orchestrator, Runway and two MySQL databases, and applies their schemas. The first run spends most of its time in the Bazel build; later ones start in seconds. +This uses Bazel to build the Linux binaries and load the web OCI image, brings up Gateway, Orchestrator, Runway, the web host, and two MySQL databases, and applies their schemas. The first run spends most of its time in the Bazel build; later ones start in seconds. Compose publishes each service on a **random** host port so several stacks can run side by side, which means there is no fixed address to hard-code. The start-up output ends with the ports, and `make local-submitqueue-ps` prints them again at any time: @@ -30,10 +30,15 @@ Compose publishes each service on a **random** host port so several stacks can r ✅ Stack is running against provider 'fake'. Gateway gRPC port: 58537 +Web UX: http://localhost:58538 +Web username: test +Web token: test ``` You do not have to note it down. Every command below finds the running stack's port for itself, which matters because Compose picks a fresh one on every start — a number copied from an earlier run is the most common reason a demo command cannot connect. Set `GATEWAY_ADDR=host:port` only to reach a gateway this Makefile did not start. +The Web UX is a read-only reference host for the same gateway. Run `make demo-requests`, then open the printed URL with username `test` and password `test` and select a row to see its current status and retained lifecycle history. The list uses a rolling 24-hour receipt window; Refresh includes newly created traffic without adding time bounds to the URL. The host and its default credentials are deliberately local-demo wiring, not a production authentication design. Override the password with `SUBMITQUEUE_WEB_TOKEN= make local-submitqueue-start` when desired. + ## Put traffic through it ```bash diff --git a/doc/howto/TESTING.md b/doc/howto/TESTING.md index 8f0f33630..b15dc5d45 100644 --- a/doc/howto/TESTING.md +++ b/doc/howto/TESTING.md @@ -59,6 +59,7 @@ make integration-test # All integration tests # E2E tests (Docker required) make e2e-test +make web-e2e-test # Build make build # Build all targets @@ -84,15 +85,24 @@ make build-all-linux # Build Linux binaries for the local docker- - Containers: Each suite's required services and dependencies; SubmitQueue E2E includes Gateway, Orchestrator, Runway, and MySQL - Tests end-to-end behavior, including cross-service communication where applicable +**4. Web E2E Test** - Browser behavior against the complete SubmitQueue stack +- Location: `web/test/e2e/` +- Run: `make web-e2e-test` +- Containers: Bazel-built Gateway, Orchestrator, Runway, web OCI image, and MySQL +- Coverage: Basic authentication, h2c gateway access, newest-first queue requests, encoded SQID navigation, ordered history, and axe accessibility checks +- Tooling: Bazel supplies Node, Playwright, and Chromium; no host Node install or browser download is required + ### How Automated Tests Work -Tests use **docker-compose** via `ComposeStack` to spin up containers automatically: +Go integration and domain E2E tests use **docker-compose** via `ComposeStack` to spin up containers automatically: 1. `NewComposeStack()` registers cleanup (stop log tailing, tear down containers) 2. `Up()` starts containers, waits for healthchecks (`--wait`), and auto-tails container logs to stderr 3. Tests run against those containers with **real-time log output** 4. On cleanup, containers are torn down automatically (set `SKIP_CLEANUP=true` to keep them for inspection) +The web E2E target uses the Bazel `py_test` runner in `web/test/e2e/run_e2e.py`. It stages runfiles into a temporary Compose context, loads the Bazel-built web image, applies the gateway and orchestrator schemas, runs Playwright, and removes its `e2e-submitqueue-web-` project unless `SKIP_CLEANUP=true`. It does not auto-tail service logs; inspect retained containers with `docker ps` and `docker logs` when cleanup is disabled. + --- ## Container Naming diff --git a/doc/rfc/web-library.md b/doc/rfc/web-library.md index 444ccd9a5..9b464e333 100644 --- a/doc/rfc/web-library.md +++ b/doc/rfc/web-library.md @@ -79,7 +79,7 @@ Submissions for a PR or revision, with version selection and separate status/his | Internal link helpers and a structural gateway-diagnostic hook | Queue names, per-queue gateway routing, credentials, deadlines, and the diagnostic backend | | Serializable view models and polling controls | Session authorization, configuration, and process lifecycle | -The host configures the queues it serves and selects each generated client with `(queue) => client`. It builds those clients with `@connectrpc/connect-node` `createGrpcTransport` over HTTP/2: TLS by default, plaintext h2c only as an explicit local option. It installs the library's tracing interceptor while constructing the transport. The gateway stays reachable only by trusted hosts. +The host configures the queues it serves and selects each generated client with `(queue) => client`. It builds those clients with `@connectrpc/connect-node` `createGrpcTransport` over HTTP/2: TLS by default, plaintext h2c only as an explicit local option. The host supplies the library's diagnostic hook and keeps the gateway reachable only by trusted hosts. Components render serializable props and do not fetch. Sessions, gateway clients, and protobuf messages stay on the server. The library does not depend on Next.js: host routes call `connection()` before gateway I/O, render dynamically, and do not cache gateway results. The host supplies a refresh callback that completes only when its framework has finished refreshing the view. @@ -87,11 +87,11 @@ Components render serializable props and do not fetch. Sessions, gateway clients `List` requires a queue and a half-open receipt window. The host recalculates the default trailing 24-hour window on refresh and keeps the URL free of timestamps. Pagination preserves the original bounds inside a signed, queue-scoped cursor; refreshing an older page returns to the live first page. -A client component starts `router.refresh()` inside a React transition and does not schedule the next refresh until that transition finishes. The wait is the terminal client's poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops only after the summary is terminal and successfully loaded history contains the same terminal status. A queue list keeps polling for the life of its fixed window. +A client component starts `router.refresh()` inside a React transition and does not schedule the next refresh until that transition finishes. The wait is the terminal client's poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view normally stops only after the summary is terminal and successfully loaded history contains the same terminal status; it also stops when a terminal summary is paired with a non-retryable history error, because polling cannot make that authorization or request-shape failure converge. A queue list keeps polling for the life of its fixed window. ## Package shape -The pnpm workspace lives under `web/`, with a nested `web/go.mod` so the Go build graph does not index it: +The web workspace lives under `web/`. Bazel owns the pinned Node toolchain, npm dependency graph, generated protobuf API, TypeScript compilation, tests, Next production build, OCI image, and browser E2E. The pnpm workspace metadata remains for optional editor and direct local-development workflows; Gazelle excludes `web/` because it contains no Go packages. ``` web/ @@ -112,9 +112,9 @@ Generated TypeScript is committed beside the Go stubs. Gateway stubs and the bas - Vitest tests cover proto drift, timestamp conversion, stable list bounds, queue-scoped readable paths, error classification, host-controlled deadlines, structured diagnostics, readiness configuration, and polling controls including transition-aware single-flight, progressive backoff, hidden/offline pause, and history-aware terminal stop. - Every protected host layout and route repeats the session check rather than relying only on `proxy.ts`. -- A Node-owned Compose check, outside Bazel and in required checks, runs the reference host against the real grpc-go gateway as `e2e-submitqueue-web` ([testing guide](../howto/TESTING.md#container-naming)). It covers the Basic-auth challenge, explicit h2c, newest-first list navigation, a slash-containing sqid, lifecycle/build history, and axe-core checks on list and detail pages. -- `pnpm pack` tarballs install into an external TypeScript consumer and typecheck the root, `./server`, and `./testing` export map. The repository's Next 16 reference host production build separately verifies the framework integration. -- Browser fakes are a build-time alias. The production artifact contains none. +- The Bazel target `//web/test/e2e:web_test` loads Bazel-built scratch OCI images for the grpc-go services, the Bazel-built web OCI image, and a digest-pinned MySQL image before running the stack as `e2e-submitqueue-web-*` with pulls and Dockerfile builds disabled. It covers the Basic-auth challenge, explicit h2c, newest-first list navigation, a slash-containing sqid, lifecycle/build history, and axe-core checks on list and detail pages. +- Bazel-built package tarballs are inspected and linked into an isolated TypeScript consumer that typechecks the root, `./server`, and `./testing` export map. The repository's Bazel-built Next 16 production host separately verifies framework integration. +- Deterministic gateway fakes ship only through the explicit `./testing` entry point, which the reference host does not import. ## Deferred @@ -125,6 +125,6 @@ Generated TypeScript is committed beside the Go stubs. Gateway stubs and the bas ## Rejected -- **A repository-owned application or container.** Another deployer would have to fork routes, authentication, telemetry, and transport. +- **A production application contract owned by this repository.** The checked-in app and OCI image are a local reference host; a production deployer still owns routes, authentication, telemetry, transport, and release policy. - **Static export or embedding in a Go binary.** Request routes are dynamic, and the gateway stays private to a server that can authorize the caller. - **A generic runtime, DI container, or web-extension layer.** Next's filesystem and the generated gateway client are the composition boundaries. diff --git a/service/runway/server/BUILD.bazel b/service/runway/server/BUILD.bazel index 8cdde8575..8629c0d6d 100644 --- a/service/runway/server/BUILD.bazel +++ b/service/runway/server/BUILD.bazel @@ -61,6 +61,7 @@ go_cross_binary( name = "runway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":runway", + visibility = ["//web/test/e2e:__pkg__"], ) # Everything a Docker-based test needs to build the runway image from a staged @@ -73,7 +74,10 @@ filegroup( "Dockerfile", ":runway_linux", ], - visibility = ["//test:__subpackages__"], + visibility = [ + "//test:__subpackages__", + "//web/test/e2e:__pkg__", + ], ) go_test( diff --git a/service/submitqueue/BUILD.bazel b/service/submitqueue/BUILD.bazel index 87007b302..830f63efb 100644 --- a/service/submitqueue/BUILD.bazel +++ b/service/submitqueue/BUILD.bazel @@ -1,7 +1,15 @@ exports_files( [ "docker-compose.git.yml", + "docker-compose.fake.yml", + "docker-compose.web.yml", "docker-compose.yml", ], visibility = ["//visibility:public"], ) + +filegroup( + name = "fake_provider_config", + srcs = glob(["demo/provider/fake/*.yaml"]), + visibility = ["//web/test/e2e:__pkg__"], +) diff --git a/service/submitqueue/docker-compose.web.yml b/service/submitqueue/docker-compose.web.yml new file mode 100644 index 000000000..e9a3b0962 --- /dev/null +++ b/service/submitqueue/docker-compose.web.yml @@ -0,0 +1,32 @@ +# Compose overlay for the local, read-only SubmitQueue web demo. +# +# The browser talks only to this Next.js host. The host reaches the private +# grpc-go gateway over Compose's internal network using explicit plaintext +# HTTP/2, which is intentionally limited to the local demo. + +services: + web-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-web-service:latest + platform: linux/amd64 + ports: + - "3000" + environment: + - PORT=3000 + - HOSTNAME=0.0.0.0 + - SUBMITQUEUE_GATEWAY_URL=http://gateway-service:8080 + - SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT=true + - SUBMITQUEUE_WEB_TOKEN=${SUBMITQUEUE_WEB_TOKEN:?set SUBMITQUEUE_WEB_TOKEN} + depends_on: + gateway-service: + condition: service_started + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 2s + timeout: 2s + retries: 30 diff --git a/service/submitqueue/gateway/server/BUILD.bazel b/service/submitqueue/gateway/server/BUILD.bazel index 0ff81f653..a809e831f 100644 --- a/service/submitqueue/gateway/server/BUILD.bazel +++ b/service/submitqueue/gateway/server/BUILD.bazel @@ -5,6 +5,11 @@ exports_files( visibility = ["//visibility:public"], ) +exports_files( + ["queues.yaml"], + visibility = ["//web/test/e2e:__pkg__"], +) + go_library( name = "gateway_lib", srcs = ["main.go"], @@ -55,6 +60,7 @@ go_cross_binary( name = "gateway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":gateway", + visibility = ["//web/test/e2e:__pkg__"], ) # Everything a Docker-based test needs to build the gateway image from a staged @@ -68,7 +74,10 @@ filegroup( "queues.yaml", ":gateway_linux", ], - visibility = ["//test:__subpackages__"], + visibility = [ + "//test:__subpackages__", + "//web/test/e2e:__pkg__", + ], ) go_test( diff --git a/service/submitqueue/orchestrator/server/BUILD.bazel b/service/submitqueue/orchestrator/server/BUILD.bazel index 5bd25680f..c4b4d887e 100644 --- a/service/submitqueue/orchestrator/server/BUILD.bazel +++ b/service/submitqueue/orchestrator/server/BUILD.bazel @@ -91,6 +91,7 @@ go_cross_binary( name = "orchestrator_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":orchestrator", + visibility = ["//web/test/e2e:__pkg__"], ) # Everything a Docker-based test needs to build the orchestrator image from a @@ -103,7 +104,10 @@ filegroup( "Dockerfile", ":orchestrator_linux", ], - visibility = ["//test:__subpackages__"], + visibility = [ + "//test:__subpackages__", + "//web/test/e2e:__pkg__", + ], ) go_test( diff --git a/web/BUILD.bazel b/web/BUILD.bazel index a77a14e67..1181163de 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -13,11 +13,12 @@ test_suite( "//web/api:test", "//web/package_test:consumer_typecheck_typecheck_test", "//web/package_test:pack_contents_test", - "//web/submitqueue:test", - "//web/submitqueue:typecheck_typecheck_test", "//web/service/submitqueue:lint", "//web/service/submitqueue:test", "//web/service/submitqueue:typecheck_typecheck_test", + "//web/submitqueue:test", + "//web/submitqueue:typecheck_typecheck_test", + ":e2e_typecheck_typecheck_test", ], ) @@ -30,8 +31,10 @@ js_library( exports_files([ "buf.gen.yaml", "package.json", + "playwright.config.ts", "pnpm-lock.yaml", "pnpm-workspace.yaml", + "tsconfig.e2e.json", "vitest.config.ts", ]) @@ -39,3 +42,28 @@ ts_config( name = "tsconfig_base", src = "tsconfig.base.json", ) + +ts_config( + name = "tsconfig_e2e", + src = "tsconfig.e2e.json", + deps = [":tsconfig_base"], +) + +ts_project( + name = "e2e_typecheck", + srcs = [ + "playwright.config.ts", + "//web/test/e2e:srcs_for_typecheck", + ], + declaration = True, + no_emit = True, + tsconfig = ":tsconfig_e2e", + deps = [ + ":node_modules/@axe-core/playwright", + ":node_modules/@connectrpc/connect", + ":node_modules/@connectrpc/connect-node", + ":node_modules/@playwright/test", + ":node_modules/@types/node", + ":node_modules/@submitqueue/api", + ], +) diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..13724fc4a --- /dev/null +++ b/web/README.md @@ -0,0 +1,29 @@ +# SubmitQueue web workspace + +Bazel is the canonical build and test interface for the web workspace. It pins Node 22, translates the pnpm lockfile into Bazel npm repositories, generates and drift-checks the protobuf TypeScript API, compiles and packages both public packages, builds the Next.js reference host and OCI image, and runs Playwright with a Bazel-managed Chromium. + +## Modules + +| Path | Purpose | +|---|---| +| [`api/`](api/) | Generated TypeScript bindings for the published SubmitQueue protobuf API | +| [`submitqueue/`](submitqueue/) | Reusable request models, gateway loaders, components, paths, polling, and test fakes | +| [`service/submitqueue/`](service/submitqueue/) | Local/demo Next.js reference host, authentication, gateway transport, and Bazel OCI image | +| [`test/e2e/`](test/e2e/) | Real-stack Playwright and axe coverage using Bazel-built services and Chromium | +| [`package_test/`](package_test/) | Package-tarball contents and external-consumer export checks | +| [`tool/`](tool/) | Starlark rules used by the web build, currently protobuf generation | + +```bash +make web-build +make web-check +make web-e2e-test # requires Docker +make web-proto +``` + +The deployable image is `//web/service/submitqueue:image`; `make web-image-load` loads it into Docker as `submitqueue-web-service:latest`. `make local-submitqueue-start` performs that load before Compose starts the local stack. + +The web image has no Dockerfile. `js_image_layer` collects the standalone Next server and its Bazel-managed Node runtime, and `oci_image` places those layers over the pinned Node base image. Keeping the image definition in Bazel avoids a second build path that would reinstall dependencies and rebuild Next outside the validated target graph. Local development still uses the Go services' Dockerfiles, while the web E2E target packages their static Linux binaries into test-only scratch images so its required stack has no live Dockerfile or APT inputs. + +The real-stack browser test lives at `web/test/e2e/`. It starts the Bazel-built Go services and web image, applies the real schemas, submits requests through the gateway, and checks HTTP Basic authentication, queue ordering, encoded SQID navigation, lifecycle history, and axe accessibility. + +The pnpm workspace remains for optional editor integration and direct local iteration. Run `make web-install` only when a local editor or pnpm command needs physical `node_modules`; Bazel and CI do not require it. diff --git a/web/package.json b/web/package.json index f61e4b3f2..70d4bd2c8 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,7 @@ "check": "pnpm build && pnpm lint && pnpm typecheck && pnpm test", "lint": "pnpm --recursive --if-present run lint", "test": "pnpm --recursive --if-present run test", - "typecheck": "pnpm --recursive --if-present run typecheck" + "typecheck": "tsc --project tsconfig.e2e.json && pnpm --recursive --if-present run typecheck" }, "devDependencies": { "@axe-core/playwright": "4.10.2", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..cb9c07a8e --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "@playwright/test"; + +const baseURL = process.env.SUBMITQUEUE_E2E_WEB_URL; +const gatewayURL = process.env.SUBMITQUEUE_E2E_GATEWAY_URL; +const token = process.env.SUBMITQUEUE_WEB_TOKEN; +const chromiumExecutablePath = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + +if (!baseURL || !gatewayURL || !token || !chromiumExecutablePath) { + throw new Error( + "SUBMITQUEUE_E2E_WEB_URL, SUBMITQUEUE_E2E_GATEWAY_URL, SUBMITQUEUE_WEB_TOKEN, and PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH are required", + ); +} + +export default defineConfig({ + outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR ?? "test-results", + testDir: "./test/e2e", + fullyParallel: false, + workers: 1, + reporter: "line", + use: { + baseURL, + httpCredentials: { + username: "test", + password: token, + }, + launchOptions: { + executablePath: chromiumExecutablePath, + }, + trace: "retain-on-failure", + }, +}); diff --git a/web/service/submitqueue/README.md b/web/service/submitqueue/README.md index 2ad380fe4..ae38b46ca 100644 --- a/web/service/submitqueue/README.md +++ b/web/service/submitqueue/README.md @@ -8,6 +8,8 @@ The host owns the `/` landing page and `//request/ Path: + runfiles_dir = os.environ.get("RUNFILES_DIR") + if runfiles_dir: + return Path(runfiles_dir) / "_main" / path + + manifest_path = os.environ.get("RUNFILES_MANIFEST_FILE") + if not manifest_path: + raise RuntimeError("Bazel runfiles are unavailable") + + key = f"_main/{path}" + with open(manifest_path, encoding="utf-8") as manifest: + for line in manifest: + logical, separator, physical = line.rstrip("\n").partition(" ") + if logical == key: + return Path(physical if separator else logical) + raise RuntimeError(f"Bazel runfile not found: {path}") + + +def _copy(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + +def _link(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.symlink_to(source, target_is_directory=True) + + +def _run(command: list[str], *, env: dict[str, str], stdin=None, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=check, env=env, stdin=stdin, text=True) + + +def _output(command: list[str], *, env: dict[str, str]) -> str: + return subprocess.check_output(command, env=env, text=True).strip() + + +def _published_port(container: str, container_port: str, *, env: dict[str, str]) -> str: + address = _output(["docker", "port", container, container_port], env=env) + return address.splitlines()[0].rsplit(":", 1)[-1] + + +def _stage_provider_configuration(workspace: Path, context: Path) -> None: + copies = { + "service/submitqueue/demo/provider/fake/merge.yaml": "provider/merge.yaml", + "service/submitqueue/demo/provider/fake/profiles.yaml": "provider/profiles.yaml", + } + for source, destination in copies.items(): + _copy(workspace / source, context / destination) + + +def _stage_playwright(workspace: Path, context: Path) -> Path: + playwright_root = context / "playwright" + _copy(workspace / "web/package.json", playwright_root / "package.json") + _copy(workspace / "web/playwright.config.ts", playwright_root / "playwright.config.ts") + _copy(workspace / "web/test/e2e/submitqueue.spec.ts", playwright_root / "test/e2e/submitqueue.spec.ts") + + links = { + "web/node_modules/@axe-core/playwright": "node_modules/@axe-core/playwright", + "web/node_modules/@bufbuild/protobuf": "node_modules/@bufbuild/protobuf", + "web/node_modules/@connectrpc/connect": "node_modules/@connectrpc/connect", + "web/node_modules/@connectrpc/connect-node": "node_modules/@connectrpc/connect-node", + "web/node_modules/@playwright/test": "node_modules/@playwright/test", + "web/test/e2e/node_modules/@submitqueue/api": "node_modules/@submitqueue/api", + } + for source, destination in links.items(): + _link(workspace / source, playwright_root / destination) + return playwright_root + + +def _stage_docker_config(destination: Path) -> None: + destination.mkdir() + source = Path.home() / ".docker" + config = source / "config.json" + if config.is_file(): + _copy(config, destination / "config.json") + contexts = source / "contexts" + if contexts.is_dir(): + shutil.copytree(contexts, destination / "contexts", symlinks=True) + + +def _load_project_images(image_loaders: list[Path], *, env: dict[str, str]) -> None: + lock_path = Path(tempfile.gettempdir()) / "submitqueue-web-e2e-image.lock" + with lock_path.open("w", encoding="utf-8") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + for image_loader in image_loaders: + _run([str(image_loader)], env=env) + _run( + [ + "docker", + "tag", + "submitqueue-web-e2e-mysql:8.0", + f"{_PROJECT}-mysql:8.0", + ], + env=env, + ) + for service in ("gateway", "orchestrator", "runway", "web"): + _run( + [ + "docker", + "tag", + f"submitqueue-web-e2e-{service}-service:latest" + if service != "web" + else "submitqueue-web-service:latest", + f"{_PROJECT}-{service}-service:latest", + ], + env=env, + ) + + +def _apply_schemas(workspace: Path, *, env: dict[str, str]) -> None: + schema_groups = ( + (workspace / "submitqueue/gateway/extension/storage/mysql/schema", f"{_PROJECT}-mysql-app-1"), + (workspace / "submitqueue/orchestrator/extension/storage/mysql/schema", f"{_PROJECT}-mysql-app-1"), + (workspace / "platform/extension/counter/mysql/schema", f"{_PROJECT}-mysql-app-1"), + (workspace / "platform/extension/messagequeue/mysql/schema", f"{_PROJECT}-mysql-queue-1"), + ) + for schema_dir, container in schema_groups: + for schema in sorted(schema_dir.glob("*.sql")): + with schema.open("r", encoding="utf-8") as statements: + _run( + ["docker", "exec", "-i", container, "mysql", "-uroot", "-proot", "submitqueue"], + env=env, + stdin=statements, + ) + + +def _chromium_executable(chromium: Path) -> Path: + candidates = ( + chromium / "chrome-linux/chrome", + chromium / "chrome-mac/Chromium.app/Contents/MacOS/Chromium", + ) + for candidate in candidates: + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + raise RuntimeError(f"unable to find the Bazel-managed Chromium executable under {chromium}") + + +def _capture_compose_logs(compose: list[str], *, env: dict[str, str]) -> None: + try: + result = subprocess.run( + [*compose, "logs", "--no-color"], + check=False, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except OSError as cause: + print(f"Unable to collect Compose logs: {cause}", file=sys.stderr) + return + sys.stderr.write(result.stdout) + output_dir = env.get("TEST_UNDECLARED_OUTPUTS_DIR") + if output_dir: + destination = Path(output_dir) / "compose.log" + try: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(result.stdout, encoding="utf-8") + except OSError as cause: + print(f"Unable to preserve Compose logs at {destination}: {cause}", file=sys.stderr) + + +def main() -> int: + if len(sys.argv) != 8: + raise RuntimeError("expected five image loaders, Playwright, and Chromium runfile paths") + + image_loaders = [_runfile(path) for path in sys.argv[1:6]] + playwright = _runfile(sys.argv[6]) + chromium = _runfile(sys.argv[7]) + workspace = _runfile("web/package.json").parents[1] + context = Path(tempfile.mkdtemp(prefix="submitqueue-web-e2e.")) + gate_dir = context / "consumer-gate" + gate_dir.mkdir() + + env = os.environ.copy() + docker_config = context / "docker-config" + _stage_docker_config(docker_config) + env["DOCKER_CONFIG"] = str(docker_config) + + compose = [ + "docker-compose", + "-f", + str(workspace / "service/submitqueue/docker-compose.yml"), + "-f", + str(workspace / "service/submitqueue/docker-compose.fake.yml"), + "-f", + str(workspace / "service/submitqueue/docker-compose.web.yml"), + "-f", + str(workspace / "web/test/e2e/docker-compose.e2e.yml"), + "-p", + _PROJECT, + ] + + def handle_signal(signum, _frame): + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + try: + _stage_provider_configuration(workspace, context) + playwright_root = _stage_playwright(workspace, context) + _load_project_images(image_loaders, env=env) + + env.update( + { + "REPO_ROOT": str(context), + "SQ_PROVIDER_CONFIG_DIR": str(context / "provider"), + "SQ_CONSUMER_GATE_DIR": str(gate_dir), + "SQ_DOCKER_IMAGE_PREFIX": _PROJECT, + "SUBMITQUEUE_WEB_TOKEN": _TOKEN, + "SQ_MYSQL_INITDB_SKIP_TZINFO": "1", + } + ) + security_options = _output(["docker", "info", "--format", "{{json .SecurityOptions}}"], env=env) + env["SQ_CONTAINER_USER"] = "0:0" if "name=rootless" in security_options else f"{os.getuid()}:{os.getgid()}" + + _run([*compose, "up", "-d", "--no-build", "--pull", "never", "--wait"], env=env) + _apply_schemas(workspace, env=env) + + gateway_port = _published_port(f"{_PROJECT}-gateway-service-1", "8080/tcp", env=env) + web_port = _published_port(f"{_PROJECT}-web-service-1", "3000/tcp", env=env) + env.update( + { + "SUBMITQUEUE_E2E_GATEWAY_URL": f"http://127.0.0.1:{gateway_port}", + "SUBMITQUEUE_E2E_WEB_URL": f"http://127.0.0.1:{web_port}", + "PLAYWRIGHT_OUTPUT_DIR": str(Path(env.get("TEST_UNDECLARED_OUTPUTS_DIR", context)) / "playwright"), + "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH": str(_chromium_executable(chromium)), + "JS_BINARY__PATCH_NODE_FS": "0", + } + ) + _run([str(playwright), "test", "--config", str(playwright_root / "playwright.config.ts")], env=env) + return 0 + except BaseException: + _capture_compose_logs(compose, env=env) + raise + finally: + if env.get("SKIP_CLEANUP") != "true": + _run([*compose, "down", "-v", "--remove-orphans"], env=env, check=False) + _run( + [ + "docker", + "image", + "rm", + "-f", + f"{_PROJECT}-gateway-service:latest", + f"{_PROJECT}-orchestrator-service:latest", + f"{_PROJECT}-runway-service:latest", + f"{_PROJECT}-web-service:latest", + f"{_PROJECT}-mysql:8.0", + ], + env=env, + check=False, + ) + shutil.rmtree(context, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/test/e2e/run_e2e_main.py b/web/test/e2e/run_e2e_main.py new file mode 100644 index 000000000..db95a6eed --- /dev/null +++ b/web/test/e2e/run_e2e_main.py @@ -0,0 +1,5 @@ +from web.test.e2e.run_e2e import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/test/e2e/run_e2e_test.py b/web/test/e2e/run_e2e_test.py new file mode 100644 index 000000000..f1deb7ac3 --- /dev/null +++ b/web/test/e2e/run_e2e_test.py @@ -0,0 +1,35 @@ +import io +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from web.test.e2e.run_e2e import _capture_compose_logs + + +class CaptureComposeLogsTest(unittest.TestCase): + def test_writes_failure_output_and_bazel_artifact(self) -> None: + with tempfile.TemporaryDirectory() as output_dir: + stderr = io.StringIO() + result = subprocess.CompletedProcess( + args=["docker-compose", "logs"], + returncode=0, + stdout="web-service | gateway readiness check failed\n", + ) + + with patch("web.test.e2e.run_e2e.subprocess.run", return_value=result), patch( + "web.test.e2e.run_e2e.sys.stderr", stderr + ): + _capture_compose_logs( + ["docker-compose", "-p", "e2e-submitqueue-web-test"], + env={"TEST_UNDECLARED_OUTPUTS_DIR": output_dir}, + ) + + expected = "web-service | gateway readiness check failed\n" + self.assertEqual(stderr.getvalue(), expected) + self.assertEqual((Path(output_dir) / "compose.log").read_text(), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/test/e2e/submitqueue.spec.ts b/web/test/e2e/submitqueue.spec.ts new file mode 100644 index 000000000..701049499 --- /dev/null +++ b/web/test/e2e/submitqueue.spec.ts @@ -0,0 +1,231 @@ +import AxeBuilder from "@axe-core/playwright"; +import { createClient } from "@connectrpc/connect"; +import { createGrpcTransport } from "@connectrpc/connect-node"; +import { expect, test } from "@playwright/test"; + +import { Strategy } from "@submitqueue/api/base/mergestrategy"; +import { SubmitQueueGateway } from "@submitqueue/api/submitqueue/gateway"; + +const queue = "demo-queue"; +const gatewayURL = process.env.SUBMITQUEUE_E2E_GATEWAY_URL as string; +const webURL = process.env.SUBMITQUEUE_E2E_WEB_URL as string; + +const gateway = createClient( + SubmitQueueGateway, + createGrpcTransport({ baseUrl: gatewayURL }), +); + +async function submitRequest(uri: string): Promise { + const response = await gateway.land({ + queue, + change: { uris: [uri] }, + strategy: Strategy.SQUASH_REBASE, + }); + expect(response.sqid).not.toBe(""); + await expect + .poll( + async () => { + const summary = await gateway.getRequestSummaryByID({ + queue, + sqid: response.sqid, + }); + return summary.request?.status; + }, + { timeout: 0 }, + ) + .toBe("landed"); + return response.sqid; +} + +function expectOrderedHistory(values: readonly string[], expected: readonly string[]): void { + let position = 0; + for (const value of values) { + if (value.includes(expected[position] ?? "\u0000")) { + position += 1; + } + } + expect(position).toBe(expected.length); +} + +test("challenges anonymous requests", async () => { + const response = await fetch(new URL(`/${queue}`, webURL), { + redirect: "manual", + }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain("Basic"); +}); + +test("shows newest requests and an accessible readable detail history", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Queues", exact: true })).toBeVisible(); + await page.getByRole("link", { name: queue, exact: true }).click(); + await expect(page.getByText("No requests were received in this window.")).toBeVisible(); + expect(new URL(page.url()).search).toBe(""); + + const firstSqid = await submitRequest( + "github://github.com/uber/submitqueue/pull/123/1111111111111111111111111111111111111111", + ); + const secondSqid = await submitRequest( + "github://github.com/uber/submitqueue/pull/123/2222222222222222222222222222222222222222", + ); + const repeatedSqid = await submitRequest( + "github://github.com/uber/submitqueue/pull/123/2222222222222222222222222222222222222222", + ); + + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + const requestLinks = page.locator(".sq-request-list__item td:first-child a"); + await expect(requestLinks).toHaveCount(3, { timeout: 0 }); + await expect(requestLinks.nth(0)).toHaveText(repeatedSqid); + await expect(requestLinks.nth(1)).toHaveText(secondSqid); + await expect(requestLinks.nth(2)).toHaveText(firstSqid); + expect(new URL(page.url()).search).toBe(""); + + const listAccessibility = await new AxeBuilder({ page }).analyze(); + expect(listAccessibility.violations).toEqual([]); + await page.screenshot({ path: test.info().outputPath("queue.png"), fullPage: true }); + + await page.getByRole("searchbox").fill("1111111111111111111111111111111111111111"); + await expect(requestLinks).toHaveCount(1); + await page.getByRole("searchbox").fill(""); + + await requestLinks.nth(0).click(); + await expect(page.getByRole("heading", { name: repeatedSqid, exact: true })).toBeVisible(); + await expect(page.locator(".sq-request-detail .sq-status").first()).toHaveText("Landed"); + + expect(new URL(page.url()).pathname).toBe( + `/${queue}/request/${repeatedSqid}`, + ); + + await expect(page.getByRole("button", { name: "Copy ID" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Summary", exact: true })).toHaveAttribute("aria-current", "page"); + const actionBoxes = await Promise.all(["Copy ID", "Copy link", "Refresh"].map(name => + page.getByRole("button", { name, exact: true }).boundingBox())); + expect(actionBoxes.every(box => box !== null)).toBe(true); + expect(actionBoxes.map(box => box!.y)).toEqual([actionBoxes[0]!.y, actionBoxes[0]!.y, actionBoxes[0]!.y]); + expect(actionBoxes.map(box => box!.height)).toEqual([actionBoxes[0]!.height, actionBoxes[0]!.height, actionBoxes[0]!.height]); + await page.screenshot({ path: test.info().outputPath("request-summary.png"), fullPage: true }); + await page.getByRole("link", { name: /^History/ }).click(); + expect(new URL(page.url()).searchParams.get("view")).toBe("history"); + + const history = await page.locator(".sq-history__item").allTextContents(); + expectOrderedHistory(history, [ + "Accepted", + "Started", + "Validating", + "Batched", + "Speculating", + "Building", + "Built", + "Speculated", + "Landing", + "Landed", + ]); + + const detailAccessibility = await new AxeBuilder({ page }).analyze(); + expect(detailAccessibility.violations).toEqual([]); + await page.screenshot({ path: test.info().outputPath("request-history.png"), fullPage: true }); + + await page.getByRole("combobox").selectOption("event"); + await expect(page.getByRole("list", { name: "Request history" }).getByText("Accepted")).toHaveCount(0); + await page.getByRole("link", { name: "Summary", exact: true }).click(); + await page.getByRole("link", { name: "github://github.com/uber/submitqueue/pull/123/2222222222222222222222222222222222222222", exact: true }).click(); + await expect(page.getByRole("heading", { name: "PR #123" })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(3); + expect(new URL(page.url()).search).toBe(""); + await page.screenshot({ path: test.info().outputPath("change-submissions.png"), fullPage: true }); + await page.getByRole("combobox", { name: "Filter submissions by version" }).selectOption({ label: "2222222222222222222222222222222222222222" }); + await expect(page.getByText("Retained submissions for this exact version")).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(2); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + + await page.goto(`/${queue}?from=1&to=2`); + await expect(page.getByRole("heading", { name: queue, exact: true })).toBeVisible(); + expect(new URL(page.url()).search).toBe(""); + const gitUri = "git://git.example.com/demo/refs%2Fheads%2Fweb-demo/3333333333333333333333333333333333333333"; + const gitRequest = await submitRequest(`${gitUri}?sq-files=demo%2F00%2Ffile.txt`); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByRole("link", { name: gitRequest, exact: true })).toBeVisible(); + const changeLink = page.getByRole("link", { name: gitUri, exact: true }); + await expect(changeLink).toBeVisible(); + await expect(changeLink).toHaveAttribute("href", `/${queue}/change/git/git.example.com/demo/refs%2Fheads%2Fweb-demo`); + await expect(page.getByText(/sq-files=/)).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("queue-live.png"), fullPage: true }); + await changeLink.click(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(1); + expect(new URL(page.url()).search).toBe(""); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.screenshot({ path: test.info().outputPath("git-change.png"), fullPage: true }); + const logicalChangeUrl = page.url(); + await page.reload(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); + await page.getByRole("combobox", { name: "Filter submissions by version" }).selectOption({ + label: "3333333333333333333333333333333333333333", + }); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(1); + await page.goto(logicalChangeUrl); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); +}); + +test("preserves literal percent escapes in Git refs when opening change history", async ({ page }) => { + const uri = "git://git.example.com/demo/refs%2Fheads%2Fweb-demo%252Fpercent/4444444444444444444444444444444444444444"; + const sqid = await submitRequest(`${uri}?sq-files=demo%2F00%2Fpercent.txt`); + await page.goto(`/${queue}`); + await page.getByRole("link", { name: uri, exact: true }).click(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo%2Fpercent", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).getByRole("link", { name: sqid, exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo%2Fpercent", exact: true })).toBeVisible(); +}); + +test("paginates change history through more than 500 queue requests", async ({ page }) => { + const changePath = `/${queue}/change/git/git.example.com/demo/refs%2Fheads%2Fpagination`; + const oldest = await submitRequest("git://git.example.com/demo/refs%2Fheads%2Fpagination/5555555555555555555555555555555555555555"); + const expectedIds = [oldest]; + for (let offset = 0; offset < 501; offset += 25) { + const replies = await Promise.all(Array.from({ length: Math.min(25, 501 - offset) }, (_, index) => gateway.land({ + queue, + change: { uris: [`github://github.com/uber/submitqueue/pull/${1000 + offset + index}/6666666666666666666666666666666666666666`] }, + strategy: Strategy.SQUASH_REBASE, + }))); + expectedIds.push(...replies.map(reply => reply.sqid)); + } + const newest = (await gateway.land({ + queue, + change: { uris: ["git://git.example.com/demo/refs%2Fheads%2Fpagination/7777777777777777777777777777777777777777"] }, + strategy: Strategy.SQUASH_REBASE, + })).sqid; + expectedIds.push(newest); + await expect.poll(async () => { + const ids = new Set(); + const toMs = BigInt(Date.now()); + let pageToken = ""; + do { + const response = await gateway.list({ + queue, receivedAtOrAfterMs: toMs - 86_400_000n, receivedBeforeMs: toMs, + pageSize: 50, pageToken, + }); + response.requests.forEach(request => ids.add(request.sqid)); + pageToken = response.nextPageToken; + } while (pageToken); + return expectedIds.every(id => ids.has(id)); + }, { timeout: 0 }).toBe(true); + + await page.goto(changePath); + await expect(page.getByRole("heading", { name: "refs/heads/pagination", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).getByRole("link", { name: newest, exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: oldest, exact: true })).toHaveCount(0); + await expect(page.getByText("More queue requests remain to be searched.", { exact: false })).toBeVisible(); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.getByRole("link", { name: "Continue history scan", exact: true }).click(); + await expect(page.getByRole("table", { name: "Change submissions" }).getByRole("link", { name: oldest, exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Continue history scan", exact: true })).toHaveCount(0); + expect(new URL(page.url()).searchParams.has("page")).toBe(true); + await page.reload(); + await expect(page.getByRole("link", { name: oldest, exact: true })).toBeVisible(); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByRole("link", { name: newest, exact: true })).toBeVisible(); + expect(new URL(page.url()).search).toBe(""); +}); diff --git a/web/tsconfig.e2e.json b/web/tsconfig.e2e.json new file mode 100644 index 000000000..f27ebabbe --- /dev/null +++ b/web/tsconfig.e2e.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["test/e2e/**/*.ts", "playwright.config.ts"] +}