diff --git a/Makefile b/Makefile index b30b91d2e..386a2b03b 100644 --- a/Makefile +++ b/Makefile @@ -712,8 +712,8 @@ web-check: ## Run web generation, packaging, lint, type, unit, and image checks @$(BAZEL) test //web:check --test_output=errors @$(BAZEL) build //web/service/submitqueue:image //web/service/stovepipe: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-e2e-test: ## Run real-stack browser tests with Playwright and axe + @$(BAZEL) test //web/test/e2e/submitqueue:web_test //web/test/e2e/stovepipe:web_test --test_output=errors web-image-load: ## Build and load the deployable web image into Docker with Bazel (also tagged with SQ_DOCKER_IMAGE_PREFIX) @$(BAZEL) run //web/service/submitqueue:image_load diff --git a/doc/howto/TESTING.md b/doc/howto/TESTING.md index b2c649c6a..a5f70cc31 100644 --- a/doc/howto/TESTING.md +++ b/doc/howto/TESTING.md @@ -85,11 +85,11 @@ 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/` +**4. Web E2E Tests** - Browser behavior against each domain stack +- Location: `web/test/e2e/{submitqueue,stovepipe}/` - 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 +- Containers: Each domain's Bazel-built services, web OCI image, and MySQL +- Coverage: Authentication, queue and change navigation, pagination, status and history, project JSON export, and axe accessibility checks - Tooling: Bazel supplies Node, Playwright, and Chromium; no host Node install or browser download is required ### Live GitHub Tests @@ -122,7 +122,7 @@ Go integration and domain E2E tests use **docker-compose** via `ComposeStack` to 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. +The SubmitQueue web E2E target uses the Bazel `py_test` runner in `web/test/e2e/submitqueue/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. --- diff --git a/doc/rfc/web-ui.md b/doc/rfc/web-ui.md index 56686f9f6..298174829 100644 --- a/doc/rfc/web-ui.md +++ b/doc/rfc/web-ui.md @@ -94,7 +94,7 @@ The workspace lives under `web/` and repeats the Go root layout; the UI is a gat - Unit tests cover route round-trips, readable paths, timestamp conversion, stable list bounds, cursor scoping and tampering, error classification and host classifiers, catalog caching, request-scoped logging, gateway and page metrics, mount paths and authorization, client navigation, and polling (transition-aware single flight, progressive backoff, hidden and offline pause, history-aware terminal stop). - Every protected host layout and route repeats the authentication check rather than relying only on the Next.js proxy. -- `//web/test/e2e:web_test` runs the Bazel-built Go services, the Bazel-built web image, and a digest-pinned MySQL image with pulls and Dockerfile builds disabled. It covers the Basic-auth challenge, explicit h2c, newest-first lists, client-side navigation, a slash-containing request ID, lifecycle and build history, paginated change history, and axe checks. +- `//web/test/e2e/submitqueue:web_test` runs the Bazel-built Go services, the Bazel-built web image, and a digest-pinned MySQL image with pulls and Dockerfile builds disabled. It covers the Basic-auth challenge, explicit h2c, newest-first lists, client-side navigation, a slash-containing request ID, lifecycle and build history, paginated change history, and axe checks. - Built package tarballs are inspected for contents and forbidden imports, typechecked by a consumer written only against public entry points, rendered by a plain Node consumer through `handle`, and server-rendered and hydrated under React 18. ## Deferred diff --git a/service/runway/server/BUILD.bazel b/service/runway/server/BUILD.bazel index 69e1adda3..7cf39dd5d 100644 --- a/service/runway/server/BUILD.bazel +++ b/service/runway/server/BUILD.bazel @@ -65,7 +65,7 @@ go_cross_binary( name = "runway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":runway", - visibility = ["//web/test/e2e:__pkg__"], + visibility = ["//web/test/e2e/submitqueue:__pkg__"], ) # Everything a Docker-based test needs to build the runway image from a staged @@ -80,7 +80,7 @@ filegroup( ], visibility = [ "//test:__subpackages__", - "//web/test/e2e:__pkg__", + "//web/test/e2e/submitqueue:__pkg__", ], ) diff --git a/service/submitqueue/demo/BUILD.bazel b/service/submitqueue/demo/BUILD.bazel index e4a684b51..310683901 100644 --- a/service/submitqueue/demo/BUILD.bazel +++ b/service/submitqueue/demo/BUILD.bazel @@ -3,7 +3,7 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") filegroup( name = "fake_provider_config", srcs = glob(["provider/fake/*.yaml"]), - visibility = ["//web/test/e2e:__pkg__"], + visibility = ["//web/test/e2e/submitqueue:__pkg__"], ) go_library( diff --git a/service/submitqueue/gateway/server/BUILD.bazel b/service/submitqueue/gateway/server/BUILD.bazel index a809e831f..28b4b4364 100644 --- a/service/submitqueue/gateway/server/BUILD.bazel +++ b/service/submitqueue/gateway/server/BUILD.bazel @@ -7,7 +7,7 @@ exports_files( exports_files( ["queues.yaml"], - visibility = ["//web/test/e2e:__pkg__"], + visibility = ["//web/test/e2e/submitqueue:__pkg__"], ) go_library( @@ -60,7 +60,7 @@ go_cross_binary( name = "gateway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":gateway", - visibility = ["//web/test/e2e:__pkg__"], + visibility = ["//web/test/e2e/submitqueue:__pkg__"], ) # Everything a Docker-based test needs to build the gateway image from a staged @@ -76,7 +76,7 @@ filegroup( ], visibility = [ "//test:__subpackages__", - "//web/test/e2e:__pkg__", + "//web/test/e2e/submitqueue:__pkg__", ], ) diff --git a/service/submitqueue/orchestrator/server/BUILD.bazel b/service/submitqueue/orchestrator/server/BUILD.bazel index c4b4d887e..c5ea7e27d 100644 --- a/service/submitqueue/orchestrator/server/BUILD.bazel +++ b/service/submitqueue/orchestrator/server/BUILD.bazel @@ -91,7 +91,7 @@ go_cross_binary( name = "orchestrator_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":orchestrator", - visibility = ["//web/test/e2e:__pkg__"], + visibility = ["//web/test/e2e/submitqueue:__pkg__"], ) # Everything a Docker-based test needs to build the orchestrator image from a @@ -106,7 +106,7 @@ filegroup( ], visibility = [ "//test:__subpackages__", - "//web/test/e2e:__pkg__", + "//web/test/e2e/submitqueue:__pkg__", ], ) diff --git a/web/BUILD.bazel b/web/BUILD.bazel index dd62ef985..f222101e7 100644 --- a/web/BUILD.bazel +++ b/web/BUILD.bazel @@ -1,5 +1,5 @@ load("@aspect_rules_js//js:defs.bzl", "js_library") -load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") load("@npm//:defs.bzl", "npm_link_all_packages") package(default_visibility = ["//visibility:public"]) @@ -33,8 +33,12 @@ test_suite( "//web/stovepipe:test", "//web/stovepipe:typecheck_typecheck_test", "//web/submitqueue:lint", + "//web/test/e2e/stovepipe:run_e2e_test", + "//web/test/e2e/stovepipe:typecheck_typecheck_test", + "//web/test/testutil:compose_test", "//web/test/package:stovepipe_pack_contents_test", - ":e2e_typecheck_typecheck_test", + "//web/test/e2e/submitqueue:run_e2e_test", + "//web/test/e2e/submitqueue:typecheck_typecheck_test", ], ) @@ -46,10 +50,8 @@ js_library( exports_files([ "package.json", - "playwright.config.ts", "pnpm-lock.yaml", "pnpm-workspace.yaml", - "tsconfig.e2e.json", "vitest.config.ts", ]) @@ -57,28 +59,3 @@ 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/api/BUILD.bazel b/web/api/BUILD.bazel index f149a6e3d..45004091d 100644 --- a/web/api/BUILD.bazel +++ b/web/api/BUILD.bazel @@ -13,7 +13,8 @@ package(default_visibility = [ "//web:__pkg__", "//web/service/submitqueue:__pkg__", "//web/service/stovepipe:__pkg__", - "//web/test/e2e:__pkg__", + "//web/test/e2e/stovepipe:__pkg__", + "//web/test/e2e/submitqueue:__pkg__", ]) npm_link_all_packages(name = "node_modules") diff --git a/web/package.json b/web/package.json index c5f84eeb2..d4410a228 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": "tsc --project tsconfig.e2e.json && pnpm --recursive --if-present run typecheck" + "typecheck": "pnpm --recursive --if-present run typecheck" }, "devDependencies": { "@axe-core/playwright": "4.10.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f38c58742..0af1e54cb 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -368,6 +368,42 @@ importers: specifier: 4.0.7 version: 4.0.7(@types/node@22.18.6)(jsdom@26.1.0) + test/e2e/stovepipe: + dependencies: + '@axe-core/playwright': + specifier: 4.10.2 + version: 4.10.2(playwright-core@1.55.1) + '@connectrpc/connect': + specifier: 2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-node': + specifier: 2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0)) + '@playwright/test': + specifier: 1.55.1 + version: 1.55.1 + '@submitqueue/api': + specifier: workspace:* + version: link:../../../api + + test/e2e/submitqueue: + dependencies: + '@axe-core/playwright': + specifier: 4.10.2 + version: 4.10.2(playwright-core@1.55.1) + '@connectrpc/connect': + specifier: 2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-node': + specifier: 2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0)) + '@playwright/test': + specifier: 1.55.1 + version: 1.55.1 + '@submitqueue/api': + specifier: workspace:* + version: link:../../../api + test/package/react18: dependencies: jsdom: diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 77d66261e..633b7a1a4 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -4,6 +4,8 @@ packages: - service/* - submitqueue - stovepipe + - test/e2e/stovepipe + - test/e2e/submitqueue - test/package/react18 hoist: false diff --git a/web/service/stovepipe/README.md b/web/service/stovepipe/README.md index 1dfc415d5..96c4993e1 100644 --- a/web/service/stovepipe/README.md +++ b/web/service/stovepipe/README.md @@ -20,4 +20,6 @@ A queue such as `monorepo/main` appears at `/monorepo%2Fmain`; request `7` appea ## Validation and images -Run `./tool/bazel test //web:check` for library, host, lint and package checks. Build the deployment image with `./tool/bazel build //web/service/stovepipe:image`, or load it into Docker with `./tool/bazel run //web/service/stovepipe:image_load`. +Run `./tool/bazel test //web:check` for library, host, lint and package checks. Run `./tool/bazel test //web/test/e2e/stovepipe:web_test` for browser checks against a real backend. Build the deployment image with `./tool/bazel build //web/service/stovepipe:image`, or load it into Docker with `./tool/bazel run //web/service/stovepipe:image_load`. + +To retain the seeded browser-test stack for inspection, add `--test_env=SKIP_CLEANUP=true --nocache_test_results` to the browser-test command. Its log prints the project and web URL. Stop it with `REPO_ROOT="$PWD" ./tool/bazel run @rules_docker_compose//docker_compose -- -f "$PWD/service/stovepipe/docker-compose.yml" -f "$PWD/web/service/stovepipe/docker-compose.yml" -f "$PWD/web/test/e2e/stovepipe/docker-compose.yml" -p down -v`. diff --git a/web/test/e2e/BUILD.bazel b/web/test/e2e/BUILD.bazel index 927cc2071..9bd5035fe 100644 --- a/web/test/e2e/BUILD.bazel +++ b/web/test/e2e/BUILD.bazel @@ -1,200 +1,15 @@ load("@npm//web:@playwright/test/package_json.bzl", playwright = "bin") -load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") -load("@aspect_rules_js//npm:defs.bzl", "npm_link_package") -load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load") -load("@rules_pkg//pkg:mappings.bzl", "pkg_attributes", "pkg_files") -load("@rules_pkg//pkg:tar.bzl", "pkg_tar") -load("@rules_python//python:defs.bzl", "py_library", "py_test") +load("@rules_oci//oci:defs.bzl", "oci_load") -package(default_visibility = ["//visibility:public"]) - -filegroup( - name = "srcs", - srcs = ["submitqueue.spec.ts"], -) - -copy_to_bin( - name = "srcs_for_typecheck", - srcs = [":srcs"], +package( + default_testonly = True, + default_visibility = ["//web:__subpackages__"], ) playwright.playwright_binary(name = "playwright") -npm_link_package( - name = "node_modules/@submitqueue/api", - src = "//web/api:pkg", -) - -pkg_files( - name = "gateway_binary", - srcs = ["//service/submitqueue/gateway/server:gateway_linux"], - attributes = pkg_attributes(mode = "0555"), - prefix = "app", - renames = { - "//service/submitqueue/gateway/server:gateway_linux": "gateway", - }, -) - -pkg_files( - name = "gateway_configuration", - srcs = ["//service/submitqueue/gateway/server:queues.yaml"], - attributes = pkg_attributes(mode = "0444"), - prefix = "app", - renames = { - "//service/submitqueue/gateway/server:queues.yaml": "queues.yaml", - }, -) - -pkg_tar( - name = "gateway_layer", - srcs = [ - ":gateway_binary", - ":gateway_configuration", - ], -) - -oci_image( - name = "gateway_image", - architecture = "amd64", - entrypoint = ["/app/gateway"], - os = "linux", - tars = [":gateway_layer"], - workdir = "/app", -) - -oci_load( - name = "gateway_image_load", - image = ":gateway_image", - repo_tags = ["submitqueue-web-e2e-gateway-service:latest"], -) - -pkg_files( - name = "orchestrator_binary", - srcs = ["//service/submitqueue/orchestrator/server:orchestrator_linux"], - attributes = pkg_attributes(mode = "0555"), - prefix = "app", - renames = { - "//service/submitqueue/orchestrator/server:orchestrator_linux": "orchestrator", - }, -) - -pkg_tar( - name = "orchestrator_layer", - srcs = [":orchestrator_binary"], -) - -oci_image( - name = "orchestrator_image", - architecture = "amd64", - entrypoint = ["/app/orchestrator"], - os = "linux", - tars = [":orchestrator_layer"], - workdir = "/app", -) - -oci_load( - name = "orchestrator_image_load", - image = ":orchestrator_image", - repo_tags = ["submitqueue-web-e2e-orchestrator-service:latest"], -) - -pkg_files( - name = "runway_binary", - srcs = ["//service/runway/server:runway_linux"], - attributes = pkg_attributes(mode = "0555"), - prefix = "app", - renames = { - "//service/runway/server:runway_linux": "runway", - }, -) - -pkg_tar( - name = "runway_layer", - srcs = [":runway_binary"], -) - -oci_image( - name = "runway_image", - architecture = "amd64", - entrypoint = ["/app/runway"], - os = "linux", - tars = [":runway_layer"], - workdir = "/app", -) - -oci_load( - name = "runway_image_load", - image = ":runway_image", - repo_tags = ["submitqueue-web-e2e-runway-service:latest"], -) - oci_load( name = "mysql_image_load", image = "@mysql_8_0_linux_amd64", repo_tags = ["submitqueue-web-e2e-mysql:8.0"], ) - -py_library( - name = "run_e2e_lib", - srcs = ["run_e2e.py"], -) - -py_test( - name = "run_e2e_test", - srcs = ["run_e2e_test.py"], - deps = [":run_e2e_lib"], - legacy_create_init = 0, - main = "run_e2e_test.py", -) - -py_test( - name = "web_test", - legacy_create_init = 0, - size = "enormous", - srcs = ["run_e2e_main.py"], - deps = [":run_e2e_lib"], - main = "run_e2e_main.py", - args = [ - "$(rootpath :gateway_image_load)", - "$(rootpath :orchestrator_image_load)", - "$(rootpath :runway_image_load)", - "$(rootpath :mysql_image_load)", - "$(rootpath //web/service/submitqueue:image_load)", - "$(rootpath :playwright)", - "$(rootpath @playwright//:chromium)", - ], - data = [ - ":playwright", - ":srcs", - "//platform/extension/counter/mysql/schema", - "//platform/extension/messagequeue/mysql/schema", - "//service/submitqueue:docker-compose.fake.yml", - "//service/submitqueue:docker-compose.web.yml", - "//service/submitqueue:docker-compose.yml", - "//service/submitqueue/demo:fake_provider_config", - "//submitqueue/gateway/extension/storage/mysql/schema", - "//submitqueue/orchestrator/extension/storage/mysql/schema", - "//web:node_modules/@axe-core/playwright", - "//web:node_modules/@bufbuild/protobuf", - "//web:node_modules/@connectrpc/connect", - "//web:node_modules/@connectrpc/connect-node", - "//web:node_modules/@playwright/test", - ":node_modules/@submitqueue/api", - "//web:package.json", - "//web:playwright.config.ts", - "//web/api:lib", - "//web/api:lib_types", - "//web/service/submitqueue:image_load", - ":docker-compose.e2e.yml", - ":gateway_image_load", - ":mysql_image_load", - ":orchestrator_image_load", - ":runway_image_load", - "@playwright//:chromium", - ], - tags = [ - "e2e", - "integration", - "requires-network", - ], -) diff --git a/web/test/e2e/stovepipe/BUILD.bazel b/web/test/e2e/stovepipe/BUILD.bazel new file mode 100644 index 000000000..7e1c791a5 --- /dev/null +++ b/web/test/e2e/stovepipe/BUILD.bazel @@ -0,0 +1,123 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@rules_go//go:def.bzl", "go_cross_binary") +load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load") +load("@rules_pkg//pkg:mappings.bzl", "pkg_attributes", "pkg_files") +load("@rules_pkg//pkg:tar.bzl", "pkg_tar") +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +package( + default_testonly = True, + default_visibility = ["//web:__subpackages__"], +) + +npm_link_all_packages(name = "node_modules") + +go_cross_binary( + name = "stovepipe_linux", + platform = "@rules_go//go/toolchain:linux_amd64", + target = "//service/stovepipe/server:stovepipe", +) + +pkg_files( + name = "stovepipe_binary", + srcs = [":stovepipe_linux"], + attributes = pkg_attributes(mode = "0555"), + prefix = "app", + renames = {":stovepipe_linux": "stovepipe"}, +) + +pkg_tar( + name = "stovepipe_layer", + srcs = [":stovepipe_binary"], +) + +oci_image( + name = "stovepipe_image", + architecture = "amd64", + entrypoint = ["/app/stovepipe"], + os = "linux", + tars = [":stovepipe_layer"], + workdir = "/app", +) + +oci_load( + name = "stovepipe_image_load", + image = ":stovepipe_image", + repo_tags = ["stovepipe-web-e2e-service:latest"], +) + +py_library( + name = "run_e2e_lib", + srcs = ["run_e2e.py"], + deps = ["//web/test/testutil:compose"], +) + +py_test( + name = "run_e2e_test", + srcs = ["run_e2e_test.py"], + legacy_create_init = 0, + main = "run_e2e_test.py", + deps = [":run_e2e_lib"], +) + +py_test( + name = "web_test", + size = "large", + srcs = ["run_e2e_main.py"], + args = [ + "$(rootpath :stovepipe_image_load)", + "$(rootpath //web/test/e2e:mysql_image_load)", + "$(rootpath //web/service/stovepipe:image_load)", + "$(rootpath @playwright//:chromium)", + "$(rootpath //web/test/e2e:playwright)", + ], + data = [ + "docker-compose.yml", + "package.json", + "playwright.config.ts", + "stovepipe.spec.ts", + ":node_modules", + ":stovepipe_image_load", + "//platform/extension/counter/mysql/schema", + "//platform/extension/messagequeue/mysql/schema", + "//service/stovepipe:docker-compose.yml", + "//stovepipe/extension/storage/mysql/schema", + "//web/api:lib", + "//web/api:lib_types", + "//web/service/stovepipe:docker-compose.yml", + "//web/service/stovepipe:image_load", + "//web/test/e2e:mysql_image_load", + "//web/test/e2e:playwright", + "@playwright//:chromium", + ], + legacy_create_init = 0, + main = "run_e2e_main.py", + tags = [ + "e2e", + "integration", + "requires-network", + ], + deps = [":run_e2e_lib"], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = ["//web:tsconfig_base"], +) + +ts_project( + name = "typecheck", + srcs = [ + "playwright.config.ts", + "stovepipe.spec.ts", + ], + declaration = True, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules", + "//web:node_modules/@types/node", + ], +) diff --git a/web/test/e2e/stovepipe/docker-compose.yml b/web/test/e2e/stovepipe/docker-compose.yml new file mode 100644 index 000000000..6b8e2d1f8 --- /dev/null +++ b/web/test/e2e/stovepipe/docker-compose.yml @@ -0,0 +1,5 @@ +services: + mysql-app: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue-web-e2e}-mysql:8.0 + mysql-queue: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue-web-e2e}-mysql:8.0 diff --git a/web/test/e2e/stovepipe/package.json b/web/test/e2e/stovepipe/package.json new file mode 100644 index 000000000..6cff43263 --- /dev/null +++ b/web/test/e2e/stovepipe/package.json @@ -0,0 +1,15 @@ +{ + "name": "@submitqueue/stovepipe-web-e2e", + "private": true, + "type": "module", + "dependencies": { + "@submitqueue/api": "workspace:*", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-node": "2.1.1", + "@playwright/test": "1.55.1", + "@axe-core/playwright": "4.10.2" + }, + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/web/test/e2e/stovepipe/playwright.config.ts b/web/test/e2e/stovepipe/playwright.config.ts new file mode 100644 index 000000000..13c890698 --- /dev/null +++ b/web/test/e2e/stovepipe/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: ".", + testMatch: "*.spec.ts", + timeout: 0, + workers: 1, + reporter: "line", + outputDir: process.env.TEST_UNDECLARED_OUTPUTS_DIR ?? "test-results", + use: { + baseURL: process.env.STOVEPIPE_E2E_WEB_URL, + httpCredentials: { username: "test", password: "test" }, + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }, + trace: "retain-on-failure", + }, +}); diff --git a/web/test/e2e/stovepipe/run_e2e.py b/web/test/e2e/stovepipe/run_e2e.py new file mode 100644 index 000000000..9399ee2ab --- /dev/null +++ b/web/test/e2e/stovepipe/run_e2e.py @@ -0,0 +1,182 @@ +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import tempfile + +from web.test.testutil.compose import ( + compose_command, + load_project_images, + apply_schemas, + published_port, +) + + +def run(command, env, **kwargs): + return subprocess.run(command, env=env, check=True, text=True, **kwargs) + + +def exit_on_signal(signum, _frame): + raise SystemExit(128 + signum) + + +def _pagination_fixture_sql() -> str: + # Retained projections exercise pagination independently of the fake source's single HEAD. + statements = [] + for i in range(1, 52): + uri = f"git://web/pagination/{i}" + ( + "?file=src%2Fmain#fragment" if i == 51 else "" + ) + statements.append( + f"INSERT INTO request_summary VALUES ('web/pagination','{i}','{uri}','','succeeded',1,{1000+i},1,{1000+i},'');" + ) + statements.append( + f"INSERT INTO request_acceptance VALUES ('web/pagination',{1000+i},'{i}');" + ) + statements.append( + "INSERT INTO request_log VALUES ('web/pagination','51','accepted',1000,'accepted','',1,'','{}');" + ) + return "\n".join(statements) + + +def main(): + root = Path(os.environ["RUNFILES_DIR"]) / "_main" + project = f"sq-test-e2e-stovepipe-web-{os.getpid()}" + with tempfile.TemporaryDirectory(prefix="stovepipe-web-") as temporary: + context = Path(temporary) + env = dict( + os.environ, + REPO_ROOT=str(context), + SQ_DOCKER_IMAGE_PREFIX=project, + STOVEPIPE_WEB_IMAGE=f"{project}-web-service:latest", + SQ_MYSQL_INITDB_SKIP_TZINFO="1", + MQ_TENANTS="monorepo/main,web/pagination,web/empty", + ) + env["STOVEPIPE_WEB_QUEUES"] = json.dumps( + [ + {"name": "monorepo/main"}, + {"name": "web/pagination"}, + {"name": "web/empty"}, + ] + ) + compose = [ + *compose_command(), + "-p", + project, + "-f", + str(root / "service/stovepipe/docker-compose.yml"), + "-f", + str(root / "web/service/stovepipe/docker-compose.yml"), + "-f", + str(root / "web/test/e2e/stovepipe/docker-compose.yml"), + ] + signal.signal(signal.SIGTERM, exit_on_signal) + signal.signal(signal.SIGINT, exit_on_signal) + try: + load_project_images( + [root / loader for loader in sys.argv[1:4]], + { + "stovepipe-web-e2e-service:latest": f"{project}-stovepipe-service:latest", + "submitqueue-web-e2e-mysql:8.0": f"{project}-mysql:8.0", + "stovepipe-web-service:latest": f"{project}-web-service:latest", + }, + env=env, + ) + run([*compose, "up", "-d", "--no-build", "--pull", "never", "--wait"], env) + apply_schemas( + [ + ( + root / "platform/extension/counter/mysql/schema", + f"{project}-mysql-app-1", + ), + ( + root / "stovepipe/extension/storage/mysql/schema", + f"{project}-mysql-app-1", + ), + ( + root / "platform/extension/messagequeue/mysql/schema", + f"{project}-mysql-queue-1", + ), + ], + env=env, + ) + run( + [ + "docker", + "exec", + "-i", + f"{project}-mysql-app-1", + "mysql", + "-uroot", + "-proot", + "submitqueue", + ], + env, + input=_pagination_fixture_sql(), + ) + for key, service, port in [ + ("STOVEPIPE_E2E_WEB_URL", "web-service", "3000"), + ("STOVEPIPE_E2E_URL", "stovepipe-service", "8080"), + ]: + port_number = published_port(f"{project}-{service}-1", port, env=env) + env[key] = "http://127.0.0.1:" + port_number + env["JS_BINARY__PATCH_NODE_FS"] = "0" + env["PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"] = str( + root / sys.argv[4] / "chrome-linux/chrome" + ) + browser_context = context / "playwright" + browser_context.mkdir() + for name in ["package.json", "playwright.config.ts", "stovepipe.spec.ts"]: + shutil.copy2( + root / "web/test/e2e/stovepipe" / name, browser_context / name + ) + (browser_context / "node_modules").symlink_to( + root / "web/test/e2e/stovepipe/node_modules", target_is_directory=True + ) + run( + [ + str(root / sys.argv[5]), + "test", + "--config", + str(browser_context / "playwright.config.ts"), + ], + env, + ) + finally: + output = Path(os.environ["TEST_UNDECLARED_OUTPUTS_DIR"]) / "compose.log" + with output.open("w") as logs: + subprocess.run( + [*compose, "logs", "--no-color"], + env=env, + stdout=logs, + stderr=subprocess.STDOUT, + ) + if env.get("SKIP_CLEANUP") != "true": + subprocess.run( + [*compose, "down", "-v", "--remove-orphans"], env=env, check=False + ) + subprocess.run( + [ + "docker", + "image", + "rm", + "-f", + f"{project}-stovepipe-service:latest", + f"{project}-mysql:8.0", + f"{project}-web-service:latest", + ], + env=env, + check=False, + ) + else: + print( + f"Retained stack: {project}; web URL: {env.get('STOVEPIPE_E2E_WEB_URL', 'not started')}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/test/e2e/stovepipe/run_e2e_main.py b/web/test/e2e/stovepipe/run_e2e_main.py new file mode 100644 index 000000000..faae93a1c --- /dev/null +++ b/web/test/e2e/stovepipe/run_e2e_main.py @@ -0,0 +1,6 @@ +import sys + +from web.test.e2e.stovepipe.run_e2e import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/test/e2e/stovepipe/run_e2e_test.py b/web/test/e2e/stovepipe/run_e2e_test.py new file mode 100644 index 000000000..3978a944d --- /dev/null +++ b/web/test/e2e/stovepipe/run_e2e_test.py @@ -0,0 +1,78 @@ +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from web.test.e2e.stovepipe.run_e2e import main + + +class RunnerCleanupTest(unittest.TestCase): + def test_termination_during_playwright_cleans_up_the_stack(self): + for signum in (signal.SIGTERM, signal.SIGINT): + with self.subTest( + signal=signum + ), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "_main" + test_dir = root / "web/test/e2e/stovepipe" + test_dir.mkdir(parents=True) + for name in ( + "package.json", + "playwright.config.ts", + "stovepipe.spec.ts", + ): + (test_dir / name).touch() + commands = [] + + def run(command, **kwargs): + commands.append(command) + if command[0] == str(root / "playwright"): + signal.raise_signal(signum) + return subprocess.CompletedProcess(command, 0) + + handlers = { + sig: signal.getsignal(sig) + for sig in (signal.SIGTERM, signal.SIGINT) + } + try: + with patch.dict( + os.environ, + { + "RUNFILES_DIR": directory, + "TEST_UNDECLARED_OUTPUTS_DIR": directory, + "SKIP_CLEANUP": "false", + }, + ), patch( + "sys.argv", + [ + "runner", + "backend-loader", + "mysql-loader", + "web-loader", + "chromium", + "playwright", + ], + ), patch( + "subprocess.run", side_effect=run + ), patch( + "subprocess.check_output", return_value="127.0.0.1:12345" + ): + with self.assertRaises(SystemExit) as exit: + main() + self.assertEqual(exit.exception.code, 128 + signum) + finally: + for sig, handler in handlers.items(): + signal.signal(sig, handler) + + self.assertTrue( + any( + command[-3:] == ["down", "-v", "--remove-orphans"] + for command in commands + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/test/e2e/stovepipe/stovepipe.spec.ts b/web/test/e2e/stovepipe/stovepipe.spec.ts new file mode 100644 index 000000000..60597ab6d --- /dev/null +++ b/web/test/e2e/stovepipe/stovepipe.spec.ts @@ -0,0 +1,124 @@ +import { test, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { createClient } from "@connectrpc/connect"; +import { createGrpcTransport } from "@connectrpc/connect-node"; +import { Stovepipe } from "@submitqueue/api/stovepipe"; +const client = createClient( + Stovepipe, + createGrpcTransport({ + baseUrl: process.env.STOVEPIPE_E2E_URL!, + defaultTimeoutMs: 5_000, + }), +); +test("real ingestion, queue selection, cursors, request summaries, history and refresh", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const id = + await test.step("Ingest a real request and wait for its projection", async () => { + const { id } = await client.ingest({ queue: "monorepo/main" }); + await expect + .poll( + async () => + (await client.list({ queue: "monorepo/main" })).requests.some( + (request) => request.requestId === id, + ), + { timeout: 0 }, + ) + .toBe(true); + return id; + }); + const requests = page.getByRole("table", { name: "Requests", exact: true }); + const queues = page.getByRole("navigation", { name: "Queues" }); + const refresh = page.getByRole("button", { name: "Refresh", exact: true }); + + await test.step("Open the ingested request, read history and refresh", async () => { + await page.goto("/monorepo%2Fmain"); + await expect( + page.getByRole("heading", { name: "monorepo/main", exact: true }), + ).toBeVisible(); + await page + .getByRole("table", { name: "Requests", exact: true }) + .getByRole("row") + .filter({ has: page.getByRole("cell", { name: id, exact: true }) }) + .getByRole("link", { name: id, exact: true }) + .click(); + await expect( + page.getByRole("heading", { name: `Request ${id}`, exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("table", { name: "Request history" }).locator("tbody tr"), + ).not.toHaveCount(0); + await expect(page).toHaveURL(new RegExp(`/monorepo%2Fmain/request/${id}$`)); + await expect( + page.getByRole("region", { name: "Request status" }), + ).toBeVisible(); + await refresh.click(); + await expect(refresh).toBeEnabled(); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + }); + await test.step("Select the populated queue", async () => { + await queues + .getByRole("link", { name: "web/pagination", exact: true }) + .click(); + await expect(requests.locator("tbody tr")).toHaveCount(50); + await page.screenshot({ + path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-queue.png`, + fullPage: true, + }); + }); + await test.step("Reload a request ID and display its summary above history", async () => { + await requests + .getByRole("link", { + name: "51", + exact: true, + }) + .click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe( + "/web%2Fpagination/request/51", + ); + await page.reload(); + await expect( + page.getByRole("heading", { name: "Request 51", exact: true }), + ).toBeVisible(); + const status = page.getByRole("region", { name: "Request status" }); + await expect(status.getByText("succeeded", { exact: true })).toBeVisible(); + await expect( + status.getByText("git://web/pagination/51?file=src%2Fmain#fragment", { + exact: true, + }), + ).toBeVisible(); + await expect(status.getByText("Accepted", { exact: true })).toBeVisible(); + await expect(status.getByText("Updated", { exact: true })).toBeVisible(); + await expect( + page.getByRole("table", { name: "Request history" }).getByText("accepted", { + exact: true, + }), + ).toBeVisible(); + await expect(page.getByRole("heading", { level: 2 })).toHaveText([ + "Request status", + "History", + ]); + await page.screenshot({ + path: `${process.env.TEST_UNDECLARED_OUTPUTS_DIR}/stovepipe-request.png`, + fullPage: true, + }); + }); + await test.step("Follow and reload the queue cursor, then select an empty queue", async () => { + await page.getByRole("link", { name: "← Queue requests" }).click(); + await page.getByRole("link", { name: "Older requests →" }).click(); + await expect(requests.locator("tbody tr")).toHaveCount(1); + expect(new URL(page.url()).searchParams.has("page")).toBe(true); + await page.reload(); + await expect(requests.locator("tbody tr")).toHaveCount(1); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await queues.getByRole("link", { name: "web/empty", exact: true }).click(); + await expect( + page.getByText("No retained requests on this queue."), + ).toBeVisible(); + }); + expect(errors).toEqual([]); +}); diff --git a/web/test/e2e/stovepipe/tsconfig.json b/web/test/e2e/stovepipe/tsconfig.json new file mode 100644 index 000000000..be57a454f --- /dev/null +++ b/web/test/e2e/stovepipe/tsconfig.json @@ -0,0 +1 @@ +{"extends":"../../../tsconfig.base.json","compilerOptions":{"types":["node"]},"include":["*.ts"]} diff --git a/web/test/e2e/submitqueue/BUILD.bazel b/web/test/e2e/submitqueue/BUILD.bazel new file mode 100644 index 000000000..7f1509c18 --- /dev/null +++ b/web/test/e2e/submitqueue/BUILD.bazel @@ -0,0 +1,209 @@ +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load") +load("@rules_pkg//pkg:mappings.bzl", "pkg_attributes", "pkg_files") +load("@rules_pkg//pkg:tar.bzl", "pkg_tar") +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +package( + default_testonly = True, + default_visibility = ["//web:__subpackages__"], +) + +filegroup( + name = "srcs", + srcs = ["submitqueue.spec.ts"], +) + +npm_link_all_packages(name = "node_modules") + +pkg_files( + name = "gateway_binary", + srcs = ["//service/submitqueue/gateway/server:gateway_linux"], + attributes = pkg_attributes(mode = "0555"), + prefix = "app", + renames = { + "//service/submitqueue/gateway/server:gateway_linux": "gateway", + }, +) + +pkg_files( + name = "gateway_configuration", + srcs = ["//service/submitqueue/gateway/server:queues.yaml"], + attributes = pkg_attributes(mode = "0444"), + prefix = "app", + renames = { + "//service/submitqueue/gateway/server:queues.yaml": "queues.yaml", + }, +) + +pkg_tar( + name = "gateway_layer", + srcs = [ + ":gateway_binary", + ":gateway_configuration", + ], +) + +oci_image( + name = "gateway_image", + architecture = "amd64", + entrypoint = ["/app/gateway"], + os = "linux", + tars = [":gateway_layer"], + workdir = "/app", +) + +oci_load( + name = "gateway_image_load", + image = ":gateway_image", + repo_tags = ["submitqueue-web-e2e-gateway-service:latest"], +) + +pkg_files( + name = "orchestrator_binary", + srcs = ["//service/submitqueue/orchestrator/server:orchestrator_linux"], + attributes = pkg_attributes(mode = "0555"), + prefix = "app", + renames = { + "//service/submitqueue/orchestrator/server:orchestrator_linux": "orchestrator", + }, +) + +pkg_tar( + name = "orchestrator_layer", + srcs = [":orchestrator_binary"], +) + +oci_image( + name = "orchestrator_image", + architecture = "amd64", + entrypoint = ["/app/orchestrator"], + os = "linux", + tars = [":orchestrator_layer"], + workdir = "/app", +) + +oci_load( + name = "orchestrator_image_load", + image = ":orchestrator_image", + repo_tags = ["submitqueue-web-e2e-orchestrator-service:latest"], +) + +pkg_files( + name = "runway_binary", + srcs = ["//service/runway/server:runway_linux"], + attributes = pkg_attributes(mode = "0555"), + prefix = "app", + renames = { + "//service/runway/server:runway_linux": "runway", + }, +) + +pkg_tar( + name = "runway_layer", + srcs = [":runway_binary"], +) + +oci_image( + name = "runway_image", + architecture = "amd64", + entrypoint = ["/app/runway"], + os = "linux", + tars = [":runway_layer"], + workdir = "/app", +) + +oci_load( + name = "runway_image_load", + image = ":runway_image", + repo_tags = ["submitqueue-web-e2e-runway-service:latest"], +) + +py_library( + name = "run_e2e_lib", + srcs = ["run_e2e.py"], + testonly = True, + deps = ["//web/test/testutil:compose"], +) + +py_test( + name = "run_e2e_test", + srcs = ["run_e2e_test.py"], + deps = [":run_e2e_lib"], + legacy_create_init = 0, + main = "run_e2e_test.py", +) + +py_test( + name = "web_test", + legacy_create_init = 0, + size = "enormous", + srcs = ["run_e2e_main.py"], + deps = [":run_e2e_lib"], + main = "run_e2e_main.py", + args = [ + "$(rootpath :gateway_image_load)", + "$(rootpath :orchestrator_image_load)", + "$(rootpath :runway_image_load)", + "$(rootpath //web/test/e2e:mysql_image_load)", + "$(rootpath //web/service/submitqueue:image_load)", + "$(rootpath //web/test/e2e:playwright)", + "$(rootpath @playwright//:chromium)", + ], + data = [ + "//web/test/e2e:playwright", + ":srcs", + "//platform/extension/counter/mysql/schema", + "//platform/extension/messagequeue/mysql/schema", + "//service/submitqueue:docker-compose.fake.yml", + "//service/submitqueue:docker-compose.web.yml", + "//service/submitqueue:docker-compose.yml", + "//service/submitqueue/demo:fake_provider_config", + "//submitqueue/gateway/extension/storage/mysql/schema", + "//submitqueue/orchestrator/extension/storage/mysql/schema", + "//web:node_modules/@axe-core/playwright", + "//web:node_modules/@bufbuild/protobuf", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@connectrpc/connect-node", + "//web:node_modules/@playwright/test", + ":node_modules/@submitqueue/api", + "package.json", + "playwright.config.ts", + "//web/api:lib", + "//web/api:lib_types", + "//web/service/submitqueue:image_load", + ":docker-compose.e2e.yml", + ":gateway_image_load", + "//web/test/e2e:mysql_image_load", + ":orchestrator_image_load", + ":runway_image_load", + "@playwright//:chromium", + ], + tags = [ + "e2e", + "integration", + "requires-network", + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = ["//web:tsconfig_base"], +) + +ts_project( + name = "typecheck", + srcs = [ + "playwright.config.ts", + "submitqueue.spec.ts", + ], + declaration = True, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules", + "//web:node_modules/@types/node", + ], +) diff --git a/web/test/e2e/docker-compose.e2e.yml b/web/test/e2e/submitqueue/docker-compose.e2e.yml similarity index 100% rename from web/test/e2e/docker-compose.e2e.yml rename to web/test/e2e/submitqueue/docker-compose.e2e.yml diff --git a/web/test/e2e/submitqueue/package.json b/web/test/e2e/submitqueue/package.json new file mode 100644 index 000000000..aaa0a7c80 --- /dev/null +++ b/web/test/e2e/submitqueue/package.json @@ -0,0 +1,15 @@ +{ + "name": "@submitqueue/submitqueue-web-e2e", + "private": true, + "type": "module", + "dependencies": { + "@submitqueue/api": "workspace:*", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-node": "2.1.1", + "@playwright/test": "1.55.1", + "@axe-core/playwright": "4.10.2" + }, + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/web/playwright.config.ts b/web/test/e2e/submitqueue/playwright.config.ts similarity index 95% rename from web/playwright.config.ts rename to web/test/e2e/submitqueue/playwright.config.ts index 5444033df..92442a64a 100644 --- a/web/playwright.config.ts +++ b/web/test/e2e/submitqueue/playwright.config.ts @@ -14,7 +14,8 @@ if (!baseURL || !gatewayURL || !token || !chromiumExecutablePath) { export default defineConfig({ outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR ?? "test-results", - testDir: "./test/e2e", + testDir: ".", + testMatch: "*.spec.ts", // No per-test limit: the Bazel test timeout bounds the run. timeout: 0, fullyParallel: false, diff --git a/web/test/e2e/run_e2e.py b/web/test/e2e/submitqueue/run_e2e.py similarity index 76% rename from web/test/e2e/run_e2e.py rename to web/test/e2e/submitqueue/run_e2e.py index a94f59c59..92f62f667 100644 --- a/web/test/e2e/run_e2e.py +++ b/web/test/e2e/submitqueue/run_e2e.py @@ -1,4 +1,3 @@ -import fcntl import os import shutil import signal @@ -7,6 +6,13 @@ import tempfile from pathlib import Path +from web.test.testutil.compose import ( + compose_command as _compose_command, + load_project_images, + apply_schemas, + published_port as _published_port, +) + _PROJECT = f"e2e-submitqueue-web-{os.getpid()}" _TOKEN = "test" @@ -48,11 +54,6 @@ 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", @@ -64,9 +65,9 @@ def _stage_provider_configuration(workspace: Path, context: Path) -> None: 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") + _copy(workspace / "web/test/e2e/submitqueue/package.json", playwright_root / "package.json") + _copy(workspace / "web/test/e2e/submitqueue/playwright.config.ts", playwright_root / "playwright.config.ts") + _copy(workspace / "web/test/e2e/submitqueue/submitqueue.spec.ts", playwright_root / "submitqueue.spec.ts") links = { "web/node_modules/@axe-core/playwright": "node_modules/@axe-core/playwright", @@ -74,7 +75,7 @@ def _stage_playwright(workspace: Path, context: Path) -> Path: "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", + "web/test/e2e/submitqueue/node_modules/@submitqueue/api": "node_modules/@submitqueue/api", } for source, destination in links.items(): _link(workspace / source, playwright_root / destination) @@ -96,65 +97,25 @@ def _stage_docker_config(destination: Path) -> None: (destination / "cli-plugins").symlink_to(plugins, target_is_directory=True) -def _compose_command(*, env: dict[str, str]) -> list[str]: - # Mirrors test/testutil/compose.go: prefer the Compose plugin, which CI runners ship, - # and fall back to the standalone binary. - if shutil.which("docker", path=env.get("PATH")) is None: - return ["docker-compose"] - probe = subprocess.run( - ["docker", "compose", "version"], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - return ["docker", "compose"] if probe.returncode == 0 else ["docker-compose"] - - 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, + tags = {"submitqueue-web-e2e-mysql:8.0": f"{_PROJECT}-mysql:8.0"} + for service in ("gateway", "orchestrator", "runway", "web"): + source = ( + f"submitqueue-web-e2e-{service}-service:latest" + if service != "web" + else "submitqueue-web-service:latest" ) - 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, - ) + tags[source] = f"{_PROJECT}-{service}-service:latest" + load_project_images(image_loaders, tags, env=env) def _apply_schemas(workspace: Path, *, env: dict[str, str]) -> None: - schema_groups = ( + apply_schemas([ (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, - ) + ], env=env) def _chromium_executable(chromium: Path) -> Path: @@ -199,7 +160,7 @@ def main() -> int: 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] + workspace = _runfile("web/test/e2e/submitqueue/package.json").parents[4] context = Path(tempfile.mkdtemp(prefix="submitqueue-web-e2e.")) gate_dir = context / "consumer-gate" gate_dir.mkdir() @@ -210,7 +171,7 @@ def main() -> int: env["DOCKER_CONFIG"] = str(docker_config) compose = [ - *_compose_command(env=env), + *_compose_command(), "-f", str(workspace / "service/submitqueue/docker-compose.yml"), "-f", @@ -218,7 +179,7 @@ def main() -> int: "-f", str(workspace / "service/submitqueue/docker-compose.web.yml"), "-f", - str(workspace / "web/test/e2e/docker-compose.e2e.yml"), + str(workspace / "web/test/e2e/submitqueue/docker-compose.e2e.yml"), "-p", _PROJECT, ] diff --git a/web/test/e2e/run_e2e_main.py b/web/test/e2e/submitqueue/run_e2e_main.py similarity index 53% rename from web/test/e2e/run_e2e_main.py rename to web/test/e2e/submitqueue/run_e2e_main.py index db95a6eed..751286b30 100644 --- a/web/test/e2e/run_e2e_main.py +++ b/web/test/e2e/submitqueue/run_e2e_main.py @@ -1,4 +1,4 @@ -from web.test.e2e.run_e2e import main +from web.test.e2e.submitqueue.run_e2e import main if __name__ == "__main__": diff --git a/web/test/e2e/run_e2e_test.py b/web/test/e2e/submitqueue/run_e2e_test.py similarity index 51% rename from web/test/e2e/run_e2e_test.py rename to web/test/e2e/submitqueue/run_e2e_test.py index efa47e49d..9c2733758 100644 --- a/web/test/e2e/run_e2e_test.py +++ b/web/test/e2e/submitqueue/run_e2e_test.py @@ -5,7 +5,7 @@ from pathlib import Path from unittest.mock import patch -from web.test.e2e.run_e2e import _capture_compose_logs, _compose_command +from web.test.e2e.submitqueue.run_e2e import _capture_compose_logs class CaptureComposeLogsTest(unittest.TestCase): @@ -18,8 +18,8 @@ def test_writes_failure_output_and_bazel_artifact(self) -> None: 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 + with patch("web.test.e2e.submitqueue.run_e2e.subprocess.run", return_value=result), patch( + "web.test.e2e.submitqueue.run_e2e.sys.stderr", stderr ): _capture_compose_logs( ["docker-compose", "-p", "e2e-submitqueue-web-test"], @@ -31,19 +31,5 @@ def test_writes_failure_output_and_bazel_artifact(self) -> None: self.assertEqual((Path(output_dir) / "compose.log").read_text(), expected) -class ComposeCommandTest(unittest.TestCase): - def test_prefers_the_compose_plugin(self) -> None: - with patch("web.test.e2e.run_e2e.shutil.which", return_value="/usr/bin/docker"), patch( - "web.test.e2e.run_e2e.subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0) - ): - self.assertEqual(_compose_command(env={}), ["docker", "compose"]) - - def test_falls_back_to_the_standalone_binary(self) -> None: - with patch("web.test.e2e.run_e2e.shutil.which", return_value="/usr/bin/docker"), patch( - "web.test.e2e.run_e2e.subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=1) - ): - self.assertEqual(_compose_command(env={}), ["docker-compose"]) - - if __name__ == "__main__": unittest.main() diff --git a/web/test/e2e/submitqueue.spec.ts b/web/test/e2e/submitqueue/submitqueue.spec.ts similarity index 100% rename from web/test/e2e/submitqueue.spec.ts rename to web/test/e2e/submitqueue/submitqueue.spec.ts diff --git a/web/test/e2e/submitqueue/tsconfig.json b/web/test/e2e/submitqueue/tsconfig.json new file mode 100644 index 000000000..be57a454f --- /dev/null +++ b/web/test/e2e/submitqueue/tsconfig.json @@ -0,0 +1 @@ +{"extends":"../../../tsconfig.base.json","compilerOptions":{"types":["node"]},"include":["*.ts"]} diff --git a/web/test/testutil/BUILD.bazel b/web/test/testutil/BUILD.bazel new file mode 100644 index 000000000..9b8f67478 --- /dev/null +++ b/web/test/testutil/BUILD.bazel @@ -0,0 +1,20 @@ +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +package( + default_testonly = True, + default_visibility = ["//web/test:__subpackages__"], +) + +py_library( + name = "compose", + srcs = ["compose.py"], + data = ["@rules_docker_compose//docker_compose"], + deps = ["@rules_python//python/runfiles"], +) + +py_test( + name = "compose_test", + srcs = ["compose_test.py"], + deps = [":compose"], + legacy_create_init = 0, +) diff --git a/web/test/testutil/compose.py b/web/test/testutil/compose.py new file mode 100644 index 000000000..9cefd42f5 --- /dev/null +++ b/web/test/testutil/compose.py @@ -0,0 +1,58 @@ +import fcntl +import subprocess +import tempfile +from pathlib import Path + +from python.runfiles import runfiles + + +def compose_command() -> list[str]: + executable = runfiles.Create().Rlocation( + "rules_docker_compose/docker_compose/current_docker_compose_toolchain/docker-compose" + ) + if executable is None: + raise RuntimeError("The pinned Compose executable is missing from Bazel runfiles") + return [executable] + + +def load_project_images( + loaders: list[Path], tags: dict[str, str], *, env: dict[str, str] +) -> None: + # Shared loader tags are locked until each stack has its own image tags. + with (Path(tempfile.gettempdir()) / "submitqueue-web-e2e-image.lock").open( + "w", encoding="utf-8" + ) as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + for loader in loaders: + subprocess.run([str(loader)], env=env, check=True) + for source, destination in tags.items(): + subprocess.run(["docker", "tag", source, destination], env=env, check=True) + + +def apply_schemas(groups: list[tuple[Path, str]], *, env: dict[str, str]) -> None: + for folder, container in groups: + for schema in sorted(folder.glob("*.sql")): + with schema.open(encoding="utf-8") as statements: + subprocess.run( + [ + "docker", + "exec", + "-i", + container, + "mysql", + "-uroot", + "-proot", + "submitqueue", + ], + env=env, + stdin=statements, + check=True, + text=True, + ) + + +def published_port(container: str, port: str, *, env: dict[str, str]) -> str: + address = subprocess.check_output( + ["docker", "port", container, port], env=env, text=True + ) + return address.splitlines()[0].rsplit(":", 1)[-1] diff --git a/web/test/testutil/compose_test.py b/web/test/testutil/compose_test.py new file mode 100644 index 000000000..41fa12b78 --- /dev/null +++ b/web/test/testutil/compose_test.py @@ -0,0 +1,19 @@ +import os +import subprocess +import unittest + +from web.test.testutil.compose import compose_command + + +class ComposeCommandTest(unittest.TestCase): + def test_reports_version_without_a_local_docker_installation(self) -> None: + version = subprocess.check_output( + [*compose_command(), "version", "--short"], + env={**os.environ, "PATH": ""}, + text=True, + ) + self.assertRegex(version.strip(), r"^v?\d+\.\d+\.\d+") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/tsconfig.e2e.json b/web/tsconfig.e2e.json deleted file mode 100644 index f27ebabbe..000000000 --- a/web/tsconfig.e2e.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "types": ["node"] - }, - "include": ["test/e2e/**/*.ts", "playwright.config.ts"] -}