diff --git a/.github/workflows/ci-host.yaml b/.github/workflows/ci-host.yaml index 6c6fadf8c38..727995b2efd 100644 --- a/.github/workflows/ci-host.yaml +++ b/.github/workflows/ci-host.yaml @@ -84,6 +84,12 @@ jobs: with: caller: ci-host skip_catalog: true + # Both jobs in this workflow (live-test and host-test) run their + # chrome against the env-mode service stack at + # `https://*.ci.localhost`, so environment.js needs to bake those + # URLs into the host bundle. The standard-mode build still exists + # in ci.yaml for `matrix-client-test` / `vscode-boxel-tools-package`. + boxel_environment: ci concurrency: group: ci-host-test-web-assets-${{ github.head_ref || github.run_id }} cancel-in-progress: true @@ -96,6 +102,16 @@ jobs: concurrency: group: boxel-live-test-${{ github.head_ref || github.run_id }} cancel-in-progress: true + env: + BOXEL_ENVIRONMENT: ci + SKIP_CATALOG: "true" + # env-vars.sh sets these only when the cert files exist at sourcing + # time, but mise-action sources it before `actions/init` runs + # `mise run infra:ensure-dev-cert`, so the conditional silently + # skips. Set explicitly to the path infra:ensure-dev-cert writes + # to on ubuntu-latest. Required by `createListener` in env mode. + REALM_SERVER_TLS_CERT_FILE: /home/runner/.local/share/boxel/dev-certs/localhost.pem + REALM_SERVER_TLS_KEY_FILE: /home/runner/.local/share/boxel/dev-certs/localhost-key.pem steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/init @@ -125,6 +141,44 @@ jobs: sudo apt-get install -y dbus-x11 upower sudo service dbus restart sudo service upower restart + + # Trust mkcert's root in the system store too, so plain `curl` (which + # ignores NODE_EXTRA_CA_CERTS) can validate Traefik's leaf. + - name: Install mkcert root into system trust store + run: mkcert -install + + - name: Start Traefik + run: mise run infra:ensure-traefik + + # The env-mode service stack uses `*.localhost` hostnames behind + # Traefik (host.ci.localhost, realm-server.ci.localhost, etc.). + # Fail the job here with a focused error if loopback resolution + # for those names breaks, rather than deep in a service boot. + - name: Verify *.localhost resolves to loopback + run: | + set -x + getent hosts realm-server.ci.localhost || true + python3 -c "import socket; print(socket.getaddrinfo('realm-server.ci.localhost', 443))" || true + curl -sS -o /dev/null -w '%{http_code}\n' --max-time 10 https://realm-server.ci.localhost/ || true + + # Explicit boxel-skills clone before any pnpm workspace op can + # materialize an empty packages/skills-realm/contents directory and + # trip services/realm-server's `[ -d contents ]` skip in + # `pnpm skills:setup`. The repo is public and CI has no SSH key, so + # go straight to HTTPS; skip if a real working copy is already + # present (idempotent on reruns). + - name: Populate skills-realm content + run: | + set -eux + cd packages/skills-realm + if [ -d contents/Skill ]; then + echo "skills content already present, skipping clone" + else + rm -rf contents + git clone --depth=1 https://github.com/cardstack/boxel-skills.git contents + fi + ls contents/Skill/ | head -5 + # Warm the test images from the GHCR mirror so the services # below start from a local image instead of an unauthenticated Docker Hub # pull (the cause of the recurring "Failed to reach Synapse" failures). @@ -134,15 +188,98 @@ jobs: uses: ./.github/actions/warm-test-images - name: Start test services (icons + host dist + realm servers) run: mise run test-services:host | tee -a /tmp/server.log & - - name: Create realm users + + # Register the matrix users (realm_server, base_realm, …) the + # realm-server logs in as. Without this the worker's `_mtimes` + # request to the realm-server is unauthenticated and gets 404, so + # the from-scratch index finishes with zero files and every later + # card fetch 404s. Script waits for Synapse to be reachable before + # registering, so the ordering with `Start test services` is safe. + - name: create realm users run: pnpm register-realm-users working-directory: packages/matrix + # Block until base + skills realms are fully indexed and publicly + # readable. `_readiness-check` returns 200 only after the realm's + # from-scratch index finishes (which itself waits for the prerender + # standby pool the index depends on). The subsequent settle pause + # lets the prerender's standby pool finish populating before the + # test runner launches its own chrome instance — concurrent chrome + # lifecycle events otherwise trip NetworkChangeNotifier and abort + # the still-loading standby pages. + - name: Wait for realms and assert public-read parity + run: | + set -u + accept='application/vnd.api+json' + ready='_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' + info='_info' + base_ready="https://realm-server.ci.localhost/base/${ready}" + skills_ready="https://realm-server.ci.localhost/skills/${ready}" + skills_info="https://realm-server.ci.localhost/skills/${info}" + base_info="https://realm-server.ci.localhost/base/${info}" + ok=0 + for i in $(seq 1 120); do + code=$(curl -sS -o /tmp/base_ready.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$base_ready" || echo 000) + if [ "$code" = "200" ]; then ok=1; break; fi + echo "attempt $i: base/_readiness-check -> $code (waiting)" + sleep 10 + done + if [ "$ok" != "1" ]; then + echo "::error::base/_readiness-check did not return 200 within timeout (last $code)" + exit 1 + fi + ok=0 + for i in $(seq 1 120); do + code=$(curl -sS -o /tmp/skills_ready.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$skills_ready" || echo 000) + if [ "$code" = "200" ]; then ok=1; break; fi + echo "attempt $i: skills/_readiness-check -> $code (waiting)" + sleep 10 + done + if [ "$ok" != "1" ]; then + echo "::error::skills/_readiness-check did not return 200 within timeout (last $code)" + exit 1 + fi + skills_code=$(curl -sS -o /tmp/skills_info.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$skills_info" || echo 000) + base_code=$(curl -sS -o /tmp/base_info.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$base_info" || echo 000) + if [ "$skills_code" != "200" ] || [ "$base_code" != "200" ]; then + echo "::error::Public-read parity broken (skills=$skills_code base=$base_code)" + exit 1 + fi + + # icons..localhost serves a static dist via http-server + # behind Traefik; there's no _readiness-check, so probe a stable + # file. A failure here means either the Traefik route isn't + # registered yet or http-server hasn't bound its port. + icons_probe="https://icons.ci.localhost/@cardstack/boxel-icons/v1/icons/folder-pen.js" + ok=0 + for i in $(seq 1 30); do + code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$icons_probe" || echo 000) + if [ "$code" = "200" ]; then ok=1; break; fi + echo "attempt $i: icons probe -> $code (waiting)" + sleep 2 + done + if [ "$ok" != "1" ]; then + echo "::error::icons probe did not return 200 within timeout (last $code)" + exit 1 + fi + + echo "::notice::Environment-mode public-read parity confirmed" + - name: Live test suite run: dbus-run-session -- pnpm test:live working-directory: packages/host env: DBUS_SYSTEM_BUS_ADDRESS: unix:path=/run/dbus/system_bus_socket + # testem-live.js defaults to https://localhost:4201/skills/ as the + # realm under test; in env mode the skills realm sits at + # https://realm-server..localhost/skills/. live-test-wait-for- + # servers.sh also reads this to know which realm readiness probe to + # wait on alongside base. + REALM_URL: https://realm-server.ci.localhost/skills/ - name: Upload junit report uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -189,21 +326,25 @@ jobs: concurrency: group: boxel-host-test${{ github.head_ref || github.run_id }}-shard${{ matrix.shardIndex }} cancel-in-progress: true + env: + BOXEL_ENVIRONMENT: ci + SKIP_CATALOG: "true" + # env-vars.sh sets these only if the cert files exist at sourcing + # time, but mise-action sources it before `actions/init` runs + # `mise run infra:ensure-dev-cert` (which provisions the cert), so + # the conditional silently skips and the env vars never propagate + # to the test step. Setting them explicitly at the job level matches + # the hardcoded path `infra:ensure-dev-cert` writes to on + # ubuntu-latest. Required by `createListener` in env mode — HTTP/2 + # is a system invariant and there's no plain-HTTP fallback. + REALM_SERVER_TLS_CERT_FILE: /home/runner/.local/share/boxel/dev-certs/localhost.pem + REALM_SERVER_TLS_KEY_FILE: /home/runner/.local/share/boxel/dev-certs/localhost-key.pem steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # init provisions mise, deps, mkcert + the dev TLS cert (and points + # NODE_EXTRA_CA_CERTS at mkcert's root), which Traefik terminates with. - uses: ./.github/actions/init - - name: Download test web assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ needs.test-web-assets.outputs.artifact_name }} - path: .test-web-assets-artifact - - name: Restore test web assets into workspace - shell: bash - run: | - shopt -s dotglob - cp -a .test-web-assets-artifact/. ./ - # this is to hopefully address the CI network flakiness that we # occasionally see in host tests. # https://github.com/actions/runner-images/issues/1187#issuecomment-686735760 @@ -221,6 +362,59 @@ jobs: sudo apt-get install -y dbus-x11 upower sudo service dbus restart sudo service upower restart + + # Trust mkcert's root in the system store too, so plain `curl` (which + # ignores NODE_EXTRA_CA_CERTS) can validate Traefik's leaf. + - name: Install mkcert root into system trust store + run: mkcert -install + + - name: Start Traefik + run: mise run infra:ensure-traefik + + # The env-mode service stack uses `*.localhost` hostnames behind + # Traefik (host.ci.localhost, realm-server.ci.localhost, etc.). + # Fail the job here with a focused error if loopback resolution + # for those names breaks, rather than deep in a service boot. + - name: Verify *.localhost resolves to loopback + run: | + set -x + getent hosts realm-server.ci.localhost || true + python3 -c "import socket; print(socket.getaddrinfo('realm-server.ci.localhost', 443))" || true + curl -sS -o /dev/null -w '%{http_code}\n' --max-time 10 https://realm-server.ci.localhost/ || true + + # The env-mode host dist (with realm-server.ci.localhost URLs + # baked in by environment.js) is built once in test-web-assets and + # downloaded here, avoiding a per-shard rebuild. The artifact + # bundles boxel-icons/dist, boxel-ui/addon/dist, packages/host/dist, + # and the build manifest. + - name: Download test web assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.test-web-assets.outputs.artifact_name }} + path: .test-web-assets-artifact + - name: Restore test web assets into workspace + shell: bash + run: | + shopt -s dotglob + cp -a .test-web-assets-artifact/. ./ + + # Explicit boxel-skills clone before any pnpm workspace op can + # materialize an empty packages/skills-realm/contents directory and trip + # services/realm-server's `[ -d contents ]` skip in `pnpm skills:setup`. + # The repo is public and CI has no SSH key, so go straight to HTTPS; + # skip if a real working copy is already present (idempotent on reruns). + - name: Populate skills-realm content + run: | + set -eux + cd packages/skills-realm + if [ -d contents/Skill ]; then + echo "skills content already present, skipping clone" + else + rm -rf contents + git clone --depth=1 https://github.com/cardstack/boxel-skills.git contents + fi + ls contents/Skill/ | head -5 + # Warm the test images from the GHCR mirror so the services # below start from a local image instead of an unauthenticated Docker Hub # pull (the cause of the recurring "Failed to reach Synapse" failures). @@ -231,22 +425,137 @@ jobs: - name: Start test services (icons + host dist + realm servers) run: mise run test-services:host | tee -a /tmp/server.log & env: - SKIP_CATALOG: true # Dump HTTP/2 session/stream state for any realm-server stream that # stalls, to localize the Chrome ↔ Node http2 hang behind the flaky # 60s host-test timeouts. Diagnostics-only; see server.ts. REALM_SERVER_HTTP2_DIAGNOSTICS: "1" + + # Register the matrix users (realm_server, base_realm, …) the + # realm-server logs in as. Without this the worker's `_mtimes` + # request to the realm-server is unauthenticated and gets 404, so + # the from-scratch index finishes with zero files and every later + # card fetch 404s. Script waits for Synapse to be reachable before + # registering, so the ordering with `Start test services` is safe. - name: create realm users run: pnpm register-realm-users working-directory: packages/matrix + # Block until base + skills realms are fully indexed and publicly + # readable. `_readiness-check` returns 200 only after the realm's + # from-scratch index finishes (which itself waits for the + # prerender standby pool the index depends on). The subsequent + # settle pause lets the prerender's standby pool finish + # populating before the test runner launches its own chrome + # instance — concurrent chrome lifecycle events otherwise trip + # NetworkChangeNotifier and abort the still-loading standby pages. + - name: Wait for realms and assert public-read parity + run: | + set -u + accept='application/vnd.api+json' + ready='_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson' + info='_info' + base_ready="https://realm-server.ci.localhost/base/${ready}" + skills_ready="https://realm-server.ci.localhost/skills/${ready}" + skills_info="https://realm-server.ci.localhost/skills/${info}" + base_info="https://realm-server.ci.localhost/base/${info}" + + # base/_readiness-check returns 200 only after the from-scratch + # index of the base realm finishes. The per-slug Postgres is fresh + # every run so this always runs, ~60s. Tests that load base cards + # block on this being indexed. + ok=0 + for i in $(seq 1 120); do + code=$(curl -sS -o /tmp/base_ready.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$base_ready" || echo 000) + if [ "$code" = "200" ]; then ok=1; break; fi + echo "attempt $i: base/_readiness-check -> $code (waiting)" + sleep 10 + done + if [ "$ok" != "1" ]; then + echo "::error::base/_readiness-check did not return 200 within timeout (last $code)" + exit 1 + fi + + # skills/_readiness-check is the parallel gate for the skills + # realm. The realm-server boots base and skills in sequence, so + # skills indexing only starts after base finishes; the AI-assistant + # skills tests fetch Skill/boxel-environment, which requires skills + # to be indexed before testem launches its chrome. Without this + # wait the test would 404 on skills cards even though base is ready. + ok=0 + for i in $(seq 1 120); do + code=$(curl -sS -o /tmp/skills_ready.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$skills_ready" || echo 000) + if [ "$code" = "200" ]; then ok=1; break; fi + echo "attempt $i: skills/_readiness-check -> $code (waiting)" + sleep 10 + done + if [ "$ok" != "1" ]; then + echo "::error::skills/_readiness-check did not return 200 within timeout (last $code)" + exit 1 + fi + + # Public-read parity: must be 200 unauthenticated. + skills_code=$(curl -sS -o /tmp/skills_info.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$skills_info" || echo 000) + base_code=$(curl -sS -o /tmp/base_info.json -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$base_info" || echo 000) + echo "skills/_info -> $skills_code" + echo "base/_info -> $base_code" + if [ "$skills_code" != "200" ] || [ "$base_code" != "200" ]; then + echo "::error::Public-read parity broken (skills=$skills_code base=$base_code)" + exit 1 + fi + + # icons..localhost serves a static dist via http-server + # behind Traefik; there's no _readiness-check, so probe a stable + # file. A failure here means either the Traefik route isn't + # registered yet or http-server hasn't bound its port. + icons_probe="https://icons.ci.localhost/@cardstack/boxel-icons/v1/icons/folder-pen.js" + ok=0 + for i in $(seq 1 30); do + code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$icons_probe" || echo 000) + if [ "$code" = "200" ]; then ok=1; break; fi + echo "attempt $i: icons probe -> $code (waiting)" + sleep 2 + done + if [ "$ok" != "1" ]; then + echo "::error::icons probe did not return 200 within timeout (last $code)" + exit 1 + fi + + # The live test realm-server's `/test/` realm contains the + # source for `testModuleRealm` URLs the host tests load + # (hassan, mango, etc.). `start-server-and-test` releases when + # `/node-test/_readiness-check` 200s, but node-test and test + # index sequentially in the same realm-server process — so + # `/node-test/` can be ready while `/test/` is still + # indexing. Without this wait, fast tests race the indexer + # and see the realm-server's notFound response (rendered as + # "Card Error: Not Found" in Percy snapshots). + test_realm_ready="https://realm-test.ci.localhost/test/${ready}" + ok=0 + for i in $(seq 1 60); do + code=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Accept: ${accept}" --max-time 15 "$test_realm_ready" || echo 000) + if [ "$code" = "200" ]; then ok=1; break; fi + echo "attempt $i: realm-test/test/_readiness-check -> $code (waiting)" + sleep 5 + done + if [ "$ok" != "1" ]; then + echo "::error::realm-test/test/_readiness-check did not return 200 within timeout (last $code)" + exit 1 + fi + + echo "::notice::Environment-mode public-read parity confirmed (skills/_info and base/_info -> 200 unauthenticated)" + - name: host test suite (shard ${{ matrix.shardIndex }}) run: | if [ "$PERCY_ENABLED" = "true" ]; then TEST_CMD="pnpm test-with-percy" else echo "::notice::Skipping Percy snapshots — no UI-relevant changes detected" - TEST_CMD="pnpm test:wait-for-servers" + TEST_CMD="pnpm ember-test-pre-built" fi set +e @@ -256,8 +565,25 @@ jobs: # Transient chunk-fetch failures present as `ChunkLoadError` under # webpack and as `Failed to fetch dynamically imported module` / # `NetworkError when attempting to fetch resource` under Vite. - # Retry the whole shard once when we detect any of these. - RETRY_PATTERN='ChunkLoadError|Failed to fetch dynamically imported module|NetworkError when attempting to fetch resource' + # Env-mode CI also sees momentary `Failed to fetch` against the + # per-slug Traefik hostnames mid-run (icons..localhost + # most often, occasionally realm-server..localhost) — same + # category: a service was briefly unreachable. The realm-server + # path has its own `withRetries` and recovers silently; icons + # doesn't, so the failure surfaces in any test that imports an + # icon. Retry the whole shard once when we detect any of these. + # The realm-test 404 lines are a transient env-mode boot race: + # when the dev realm-server's from-scratch index takes the long + # path on a given runner, the test realm-server's from-scratch + # index occasionally finishes with zero files indexed, so any + # test that reads a card from `${testModuleRealm}` sees a 404 + # from the live test realm. Both `cross-realm fetch failed` (the + # loader's wrapped error during cross-realm linksTo resolution) + # and `Could not find https://realm-test.` (the realm-server's + # raw notFound when the host store does a direct GET) point at + # the same race; match both. A second shard pass typically lands + # after the race resolves. + RETRY_PATTERN='ChunkLoadError|Failed to fetch dynamically imported module|NetworkError when attempting to fetch resource|unable to fetch https://icons\.[^:]+: fetch failed|cross-realm fetch failed for https://realm-test\.[^/]+|Could not find https://realm-test\.[^/"]+' if [ $exit_code -ne 0 ] && grep -Eq "$RETRY_PATTERN" /tmp/test-output.log; then echo "" echo "::warning::Transient chunk-fetch failure detected — retrying shard ${{ matrix.shardIndex }}" @@ -289,7 +615,6 @@ jobs: exit $exit_code env: PERCY_ENABLED: ${{ needs.check-percy.outputs.percy_needed }} - SKIP_CATALOG: true PERCY_GZIP: true PERCY_TOKEN: ${{ needs.check-percy.outputs.percy_needed == 'true' && secrets.PERCY_TOKEN_HOST || '' }} PERCY_PARALLEL_NONCE: ${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/test-web-assets.yaml b/.github/workflows/test-web-assets.yaml index 133e22cf8b8..80dc0158eaf 100644 --- a/.github/workflows/test-web-assets.yaml +++ b/.github/workflows/test-web-assets.yaml @@ -12,6 +12,18 @@ on: required: false type: boolean default: false + boxel_environment: + description: | + BOXEL_ENVIRONMENT slug to bake into the host dist. Empty for + a standard-mode build (URLs like https://localhost:4201); + a value like "ci" produces an env-mode build whose realm URLs + target the per-environment Traefik hostnames + (https://realm-server.ci.localhost, etc.). The slug is folded + into the cache key and artifact name so standard-mode and + env-mode builds don't collide. + required: false + type: string + default: "" outputs: artifact_name: description: Artifact name containing built test web assets @@ -46,8 +58,13 @@ jobs: run: | ref_slug="$(echo "${GITHUB_REF}" | tr '/' '_')" skip_catalog="${{ inputs.skip_catalog }}" - cache_key="test-web-assets-v2-${RUNNER_OS}-${ref_slug}-${GITHUB_SHA}-skip-catalog-${skip_catalog}" - artifact_name="test-web-assets-${GITHUB_SHA}-skip-catalog-${skip_catalog}" + boxel_env="${{ inputs.boxel_environment }}" + # An empty boxel_environment encodes the standard-mode build; + # any non-empty slug produces a distinct artifact + cache entry + # so env-mode and standard-mode builds for the same SHA coexist. + env_suffix="${boxel_env:-std}" + cache_key="test-web-assets-v2-${RUNNER_OS}-${ref_slug}-${GITHUB_SHA}-skip-catalog-${skip_catalog}-env-${env_suffix}" + artifact_name="test-web-assets-${GITHUB_SHA}-skip-catalog-${skip_catalog}-env-${env_suffix}" echo "cache_key=$cache_key" >> "$GITHUB_OUTPUT" echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT" @@ -78,6 +95,10 @@ jobs: working-directory: packages/host env: SKIP_CATALOG: ${{ inputs.skip_catalog }} + # When set, environment.js bakes the env-mode realm/matrix/icons + # URLs (https://realm-server..localhost, etc.) into the + # vite chunks. Empty preserves the standard-mode defaults. + BOXEL_ENVIRONMENT: ${{ inputs.boxel_environment }} - name: Write assets manifest shell: bash @@ -94,6 +115,7 @@ jobs: cacheKey: process.env.CACHE_KEY, cacheHit: process.env.CACHE_HIT === "true", skipCatalog: process.env.SKIP_CATALOG === "true", + boxelEnvironment: process.env.BOXEL_ENVIRONMENT || null, builtAt: new Date().toISOString(), }; fs.writeFileSync(".ci/test-web-assets/manifest.json", JSON.stringify(manifest, null, 2)); @@ -102,6 +124,7 @@ jobs: CACHE_KEY: ${{ steps.keys.outputs.cache_key }} CACHE_HIT: ${{ steps.restore.outputs.cache-hit }} SKIP_CATALOG: ${{ inputs.skip_catalog }} + BOXEL_ENVIRONMENT: ${{ inputs.boxel_environment }} - name: Save cached assets if: steps.restore.outputs.cache-hit != 'true' diff --git a/mise-tasks/services/test-realms b/mise-tasks/services/test-realms index 2518319536f..830edb6c621 100755 --- a/mise-tasks/services/test-realms +++ b/mise-tasks/services/test-realms @@ -9,6 +9,17 @@ SCRIPTS_DIR="./scripts" # server never writes into the repo's source tree. TEST_REALM_DIR=$(mktemp -d) cp -R ../test-realm-cards/contents/. "$TEST_REALM_DIR"/ +# Diagnostics: env-mode CI has been seeing the test realm-server's +# from-scratch index finish with `files_completed=0` for both +# `/test/` (this temp dir) and `/node-test/` (./tests/fixtures/realistic), +# which means the realm-server's `_mtimes` endpoint is walking what +# looks like an empty directory. Log file counts at this point so the +# CI log makes it obvious whether the cp succeeded, whether the +# checked-in fixtures directory is populated, and whether anything +# wipes them between here and the realm-server boot. +echo "[test-realms diag] TEST_REALM_DIR=$TEST_REALM_DIR file_count=$(ls -1A "$TEST_REALM_DIR" 2>/dev/null | wc -l | tr -d ' ')" +echo "[test-realms diag] ../test-realm-cards/contents file_count=$(ls -1A ../test-realm-cards/contents 2>/dev/null | wc -l | tr -d ' ')" +echo "[test-realms diag] ./tests/fixtures/realistic file_count=$(ls -1A ./tests/fixtures/realistic 2>/dev/null | wc -l | tr -d ' ')" # In environment mode, share the dev icons server; otherwise start our own if [ -z "$BOXEL_ENVIRONMENT" ]; then diff --git a/packages/host/app/config/environment.ts b/packages/host/app/config/environment.ts index 41a1ea6b56a..9c9150b3242 100644 --- a/packages/host/app/config/environment.ts +++ b/packages/host/app/config/environment.ts @@ -39,6 +39,7 @@ export default config as { resolvedCatalogRealmURL: string | undefined; resolvedSkillsRealmURL: string; resolvedOpenRouterRealmURL: string | undefined; + resolvedTestRealmURL: string; hostsOwnAssets: boolean; realmsServed?: string[]; logLevels: string; diff --git a/packages/host/app/services/network.ts b/packages/host/app/services/network.ts index 72cf07be7e7..b67128941cb 100644 --- a/packages/host/app/services/network.ts +++ b/packages/host/app/services/network.ts @@ -1,6 +1,8 @@ import type Owner from '@ember/owner'; import Service, { service } from '@ember/service'; +import { isTesting } from '@embroider/macros'; + import { VirtualNetwork, authorizationMiddleware, @@ -83,6 +85,25 @@ export default class NetworkService extends Service { config.resolvedOpenRouterRealmURL, ); } + // Some test fixture content (JSON card files under tests/cards/, embedded + // card ids in test data) refers to the live test realm by its standard- + // mode URL `https://localhost:4202/test/`. In environment mode the live + // test realm is served at a per-environment Traefik hostname. Mapping + // the standard-mode URL onto whatever the running test realm-server + // serves lets the same fixture content resolve under either mode. + // Gated on isTesting() so the mapping never reaches prod fetches. + if (isTesting()) { + let hardcodedTestRealmURL = new URL('https://localhost:4202/test/'); + let resolvedTestRealmURL = new URL( + withTrailingSlash(config.resolvedTestRealmURL), + ); + if (resolvedTestRealmURL.href !== hardcodedTestRealmURL.href) { + virtualNetwork.addURLMapping( + hardcodedTestRealmURL, + resolvedTestRealmURL, + ); + } + } return virtualNetwork; } diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index d598de7b909..f116695c89a 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -67,6 +67,7 @@ function environmentDefaults() { catalogRealmURL: 'https://localhost:4201/catalog/', skillsRealmURL: 'https://localhost:4201/skills/', openRouterRealmURL: 'https://localhost:4201/openrouter/', + testRealmURL: 'https://localhost:4202/test/', }; } let slug = getEnvSlug(); @@ -87,6 +88,10 @@ function environmentDefaults() { catalogRealmURL: `https://${realmHost}/catalog/`, skillsRealmURL: `https://${realmHost}/skills/`, openRouterRealmURL: `https://${realmHost}/openrouter/`, + // mise-tasks/services/test-realms registers the live test realm at + // `https://realm-test.${slug}.localhost/test/` in env mode (the + // counterpart to standard mode's `https://localhost:4202/test/`). + testRealmURL: `https://realm-test.${slug}.localhost/test/`, }; } @@ -165,6 +170,24 @@ module.exports = function (environment) { process.env.RESOLVED_SKILLS_REALM_URL || defaults.skillsRealmURL, resolvedOpenRouterRealmURL: process.env.RESOLVED_OPENROUTER_REALM_URL || defaults.openRouterRealmURL, + // The live test realm-server's /test/ realm — used by host tests + // that load source modules from it via + // `tests/helpers#testModuleRealm`. Derived from BOXEL_ENVIRONMENT via + // `defaults.testRealmURL` above (localhost:4202 in standard mode, + // realm-test..localhost in env mode). Explicit + // `REALM_TEST_URL` overrides take precedence for non-CI consumers + // that want a custom test realm endpoint. The override accepts + // either a base host URL (which gets `/test/` appended) or a value + // that already names the `/test` realm — without the latter case + // a path like `https://my-host/test/` would become + // `https://my-host/test/test/`. + resolvedTestRealmURL: (() => { + if (!process.env.REALM_TEST_URL) return defaults.testRealmURL; + let normalized = process.env.REALM_TEST_URL.replace(/\/$/, ''); + return normalized.endsWith('/test') + ? `${normalized}/` + : `${normalized}/test/`; + })(), featureFlags: {}, }; diff --git a/packages/host/package.json b/packages/host/package.json index 847869afdc3..da9a5d294f5 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -24,7 +24,7 @@ "start": "node scripts/vite-serve.js", "serve:dist": "node scripts/serve-dist.js", "test": "concurrently \"pnpm:lint\" \"pnpm:test:*\" --names \"lint,test:\"", - "test-with-percy": "percy exec --parallel -- pnpm test:wait-for-servers", + "test-with-percy": "percy exec --parallel -- pnpm ember-test-pre-built", "test:wait-for-servers": "./scripts/test-wait-for-servers.sh", "test:live": "./scripts/live-test-wait-for-servers.sh", "ember-test-pre-built": "sleep 15 && ember exam --path ./dist --split $HOST_TEST_PARTITION_COUNT --partition $HOST_TEST_PARTITION --preserve-test-name", diff --git a/packages/host/scripts/live-test-wait-for-servers.sh b/packages/host/scripts/live-test-wait-for-servers.sh index 2ef321bb8f5..374c7454b36 100755 --- a/packages/host/scripts/live-test-wait-for-servers.sh +++ b/packages/host/scripts/live-test-wait-for-servers.sh @@ -1,8 +1,22 @@ #! /bin/sh READY_PATH="_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson" -BASE_REALM_READY="https-get://localhost:4201/base/${READY_PATH}" -SYNAPSE_URL="http://localhost:8008" + +# Default to the standard-mode ports; env mode (BOXEL_ENVIRONMENT set) +# exports REALM_BASE_URL=https://realm-server..localhost and +# MATRIX_URL_VAL=https://matrix..localhost via env-vars.sh, so +# fall back to those when present. +REALM_BASE_URL="${REALM_BASE_URL:-https://localhost:4201}" +MATRIX_URL_VAL="${MATRIX_URL_VAL:-http://localhost:8008}" +realm_host="${REALM_BASE_URL#http://}" +realm_host="${realm_host#https://}" +realm_host="${realm_host%/}" +case "$REALM_BASE_URL" in + https://*) realm_scheme="https-get" ;; + *) realm_scheme="http-get" ;; +esac +BASE_REALM_READY="${realm_scheme}://${realm_host}/base/${READY_PATH}" +SYNAPSE_URL="$MATRIX_URL_VAL" SMTP_4_DEV_URL="http://localhost:5001" # Pick wait-on's protocol prefix from whichever scheme the caller used. @@ -32,7 +46,7 @@ else # passing assert). Skills is small and is hosted by every host CI job # already, so waiting on its readiness adds little beyond what base # already incurs. - SKILLS_REALM_READY="https-get://localhost:4201/skills/${READY_PATH}" + SKILLS_REALM_READY="${realm_scheme}://${realm_host}/skills/${READY_PATH}" READY_URLS="$BASE_REALM_READY|$SKILLS_REALM_READY|$SYNAPSE_URL|$SMTP_4_DEV_URL" fi diff --git a/packages/host/tests/acceptance/code-submode-test.ts b/packages/host/tests/acceptance/code-submode-test.ts index 230d2e30a49..1e4e3d8e7a0 100644 --- a/packages/host/tests/acceptance/code-submode-test.ts +++ b/packages/host/tests/acceptance/code-submode-test.ts @@ -44,6 +44,7 @@ import { SYSTEM_CARD_FIXTURE_CONTENTS, setMonacoContent, setupLocalIndexing, + testModuleRealm, testRealmURL, visitOperatorMode, setupAuthEndpoints, @@ -1273,12 +1274,12 @@ module('Acceptance | code submode tests', function (_hooks) { }); module('with connection to test realm', function (hooks) { hooks.beforeEach(function () { - setActiveRealms([testRealmURL, 'https://localhost:4202/test/']); + setActiveRealms([testRealmURL, `${testModuleRealm}`]); }); test('code submode handles binary files', async function (assert) { await visitOperatorMode({ submode: 'code', - codePath: `https://localhost:4202/test/mango.png`, + codePath: `${testModuleRealm}mango.png`, }); await waitFor('[data-test-binary-info]'); diff --git a/packages/host/tests/acceptance/code-submode/file-tree-test.ts b/packages/host/tests/acceptance/code-submode/file-tree-test.ts index 918202cd89a..01548ef5436 100644 --- a/packages/host/tests/acceptance/code-submode/file-tree-test.ts +++ b/packages/host/tests/acceptance/code-submode/file-tree-test.ts @@ -22,6 +22,7 @@ import { elementIsVisible, setupLocalIndexing, setupRealmCacheTeardown, + testModuleRealm, testRealmURL, setupAcceptanceTestRealm, SYSTEM_CARD_FIXTURE_CONTENTS, @@ -478,7 +479,7 @@ module('Acceptance | code submode | file-tree tests', function (hooks) { // go to a file with different realm await fillIn( '[data-test-card-url-bar-input]', - `https://localhost:4202/test/mango.png`, + `${testModuleRealm}mango.png`, ); await triggerKeyEvent( '[data-test-card-url-bar-input]', @@ -522,7 +523,7 @@ module('Acceptance | code submode | file-tree tests', function (hooks) { await fillIn( '[data-test-card-url-bar-input]', - `https://localhost:4202/test/mango.png`, + `${testModuleRealm}mango.png`, ); await triggerKeyEvent( '[data-test-card-url-bar-input]', @@ -824,7 +825,7 @@ module('Acceptance | code submode | file-tree tests', function (hooks) { await fillIn( '[data-test-card-url-bar-input]', - `https://localhost:4202/test/mango.png`, + `${testModuleRealm}mango.png`, ); await triggerKeyEvent( '[data-test-card-url-bar-input]', diff --git a/packages/host/tests/acceptance/code-submode/recent-files-test.ts b/packages/host/tests/acceptance/code-submode/recent-files-test.ts index bfba8741e4b..253fb020ea6 100644 --- a/packages/host/tests/acceptance/code-submode/recent-files-test.ts +++ b/packages/host/tests/acceptance/code-submode/recent-files-test.ts @@ -21,6 +21,7 @@ import { percySnapshot, setupLocalIndexing, setupRealmCacheTeardown, + testModuleRealm, testRealmURL, setupAcceptanceTestRealm, SYSTEM_CARD_FIXTURE_CONTENTS, @@ -318,7 +319,7 @@ module('Acceptance | code submode | recent files tests', function (hooks) { test('recent file links are shown', async function (assert) { setRecentFiles([ [testRealmURL, 'index.json'], - ['https://localhost:4202/test/', 'français.json'], + [`${testModuleRealm}`, 'français.json'], // @ts-ignore error on purpose 'a-non-url-to-ignore', ]); @@ -404,7 +405,7 @@ module('Acceptance | code submode | recent files tests', function (hooks) { [testRealmURL, 'index.json', null], [testRealmURL, 'français.json', null], [testRealmURL, 'Person/1.json', null], - ['https://localhost:4202/test/', 'français.json', null], + [`${testModuleRealm}`, 'français.json', null], ]); }); diff --git a/packages/host/tests/acceptance/commands-test.gts b/packages/host/tests/acceptance/commands-test.gts index 296910e21d6..868b8fda09a 100644 --- a/packages/host/tests/acceptance/commands-test.gts +++ b/packages/host/tests/acceptance/commands-test.gts @@ -53,6 +53,7 @@ import { addSkillToAiAssistant, setupLocalIndexing, setupOnSave, + testModuleRealm, testRealmURL, testRRI, setupAcceptanceTestRealm, @@ -399,7 +400,7 @@ module('Acceptance | Commands tests', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/ai-command-example', + module: `${testModuleRealm}ai-command-example`, name: 'AiCommandExample', }, }, diff --git a/packages/host/tests/acceptance/host-submode-test.gts b/packages/host/tests/acceptance/host-submode-test.gts index dbd18cd5ba5..5f2576942a1 100644 --- a/packages/host/tests/acceptance/host-submode-test.gts +++ b/packages/host/tests/acceptance/host-submode-test.gts @@ -13,6 +13,8 @@ import { TrackedObject } from 'tracked-built-ins'; import { Deferred, baseRealm, param, query } from '@cardstack/runtime-common'; +import ENV from '@cardstack/host/config/environment'; + import { getDbAdapter, setupLocalIndexing, @@ -25,6 +27,16 @@ import { realmConfigCardJSON, } from '../helpers'; +// Per-user published-realm URL host. Standard mode: `localhost:4201`; +// env mode: `realm-server..localhost`. The publishing UI builds +// URLs of the form `https://.//` (or +// `./` for boxel-site claims), so the +// assertions need to derive the host the same way the UI does. +// publishedRealmBoxelSpaceDomain and publishedRealmBoxelSiteDomain are +// distinct in the host config, but in this test environment they +// resolve to the same value, so one const covers both. +const publishedSpaceHost = ENV.publishedRealmBoxelSpaceDomain; + import { CardsGrid, setupBaseRealm } from '../helpers/base-realm'; import { viewCardDemoCardSource } from '../helpers/cards/view-card-demo'; @@ -696,7 +708,9 @@ module('Acceptance | host submode', function (hooks) { assert.dom('.publishing-realm-popover').exists(); assert .dom('.publishing-realm-popover') - .containsText(`Publishing to: https://testuser.localhost:4201/test/`); + .containsText( + `Publishing to: https://testuser.${publishedSpaceHost}/test/`, + ); assert.dom('.publishing-realm-popover').exists(); assert.dom('.loading-icon').exists(); @@ -729,7 +743,7 @@ module('Acceptance | host submode', function (hooks) { .dom( '[data-test-publish-realm-modal] [data-test-open-boxel-space-button]', ) - .hasAttribute('href', 'https://testuser.localhost:4201/test/') + .hasAttribute('href', `https://testuser.${publishedSpaceHost}/test/`) .hasAttribute('target', '_blank'); }); @@ -740,15 +754,15 @@ module('Acceptance | host submode', function (hooks) { realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'custom-site-name.localhost:4201', + hostname: `custom-site-name.${publishedSpaceHost}`, subdomain: 'custom-site-name', sourceRealmURL: testRealmURL, }); let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - 'https://testuser.localhost:4201/test/': String(now), - 'https://custom-site-name.localhost:4201/': String(now), + [`https://testuser.${publishedSpaceHost}/test/`]: String(now), + [`https://custom-site-name.${publishedSpaceHost}/`]: String(now), }, }); @@ -793,7 +807,7 @@ module('Acceptance | host submode', function (hooks) { test('can unpublish realm', async function (assert) { let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - ['https://testuser.localhost:4201/test/']: ( + [`https://testuser.${publishedSpaceHost}/test/`]: ( new Date().getTime() - 3 * 24 * 60 * 60 * 1000 ).toString(), @@ -913,7 +927,7 @@ module('Acceptance | host submode', function (hooks) { assert .dom('[data-test-custom-subdomain-details]') .includesText( - 'https://my-boxel-site.localhost:4201/ Not published yet', + `https://my-boxel-site.${publishedSpaceHost}/ Not published yet`, ); assert.dom('[data-test-unclaim-custom-subdomain-button]').exists(); assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); @@ -988,7 +1002,7 @@ module('Acceptance | host submode', function (hooks) { let now = Date.now(); let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - 'https://testuser.localhost:4201/test/': String(now), + [`https://testuser.${publishedSpaceHost}/test/`]: String(now), 'https://another-domain.com/realm/': String(now - 1000), }, }); @@ -1004,7 +1018,7 @@ module('Acceptance | host submode', function (hooks) { .dom('[data-test-open-site-button]') .hasAttribute( 'href', - 'https://testuser.localhost:4201/test/Person/1', + `https://testuser.${publishedSpaceHost}/test/Person/1`, ) .hasAttribute('target', '_blank'); @@ -1032,7 +1046,7 @@ module('Acceptance | host submode', function (hooks) { assert .dom( - '[data-test-published-realm-item="https://testuser.localhost:4201/test/Person/1"]', + `[data-test-published-realm-item="https://testuser.${publishedSpaceHost}/test/Person/1"]`, ) .exists(); assert @@ -1044,11 +1058,11 @@ module('Acceptance | host submode', function (hooks) { // Check that popover buttons have correct href attributes assert .dom( - '[data-test-published-realm-item="https://testuser.localhost:4201/test/Person/1"] [data-test-open-site-button]', + `[data-test-published-realm-item="https://testuser.${publishedSpaceHost}/test/Person/1"] [data-test-open-site-button]`, ) .hasAttribute( 'href', - 'https://testuser.localhost:4201/test/Person/1', + `https://testuser.${publishedSpaceHost}/test/Person/1`, ) .hasAttribute('target', '_blank'); @@ -1072,7 +1086,7 @@ module('Acceptance | host submode', function (hooks) { realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'custom-site-name.localhost:4201', + hostname: `custom-site-name.${publishedSpaceHost}`, subdomain: 'custom-site-name', sourceRealmURL: testRealmURL, }); @@ -1101,7 +1115,7 @@ module('Acceptance | host submode', function (hooks) { assert .dom(`${customDomainOption} .domain-url`) .hasText( - 'https://custom-site-name.localhost:4201/', + `https://custom-site-name.${publishedSpaceHost}/`, 'shows claimed custom site URL', ); assert @@ -1132,7 +1146,7 @@ module('Acceptance | host submode', function (hooks) { assert .dom(`${customDomainOption} .domain-url`) .hasText( - 'https://custom-site-name.localhost:4201/', + `https://custom-site-name.${publishedSpaceHost}/`, 'displays placeholder custom site URL after unclaim', ); assert @@ -1149,7 +1163,7 @@ module('Acceptance | host submode', function (hooks) { let originalFetchClaimed = realmServer.fetchBoxelClaimedDomain; realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'custom-site-name.localhost:4201', + hostname: `custom-site-name.${publishedSpaceHost}`, subdomain: 'custom-site-name', sourceRealmURL: testRealmURL, }); @@ -1177,7 +1191,7 @@ module('Acceptance | host submode', function (hooks) { realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'custom-site-name.localhost:4201', + hostname: `custom-site-name.${publishedSpaceHost}`, subdomain: 'custom-site-name', sourceRealmURL: testRealmURL, }); @@ -1224,7 +1238,7 @@ module('Acceptance | host submode', function (hooks) { await click('[data-test-publish-realm-button]'); assert.dom('[data-test-publish-realm-modal]').exists(); - let defaultUrl = 'https://testuser.localhost:4201/test/'; + let defaultUrl = `https://testuser.${publishedSpaceHost}/test/`; assert .dom(`[data-test-domain-publish-error="${defaultUrl}"]`) .doesNotExist(); @@ -1255,13 +1269,13 @@ module('Acceptance | host submode', function (hooks) { realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'my-custom-site.localhost:4201', + hostname: `my-custom-site.${publishedSpaceHost}`, subdomain: 'my-custom-site', sourceRealmURL: testRealmURL, }); - let defaultUrl = 'https://testuser.localhost:4201/test/'; - let customUrl = 'https://my-custom-site.localhost:4201/'; + let defaultUrl = `https://testuser.${publishedSpaceHost}/test/`; + let customUrl = `https://my-custom-site.${publishedSpaceHost}/`; // Mock publish to succeed for default, fail for custom realmServer.publishRealm = async ( @@ -1338,7 +1352,7 @@ module('Acceptance | host submode', function (hooks) { realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'my-custom-site.localhost:4201', + hostname: `my-custom-site.${publishedSpaceHost}`, subdomain: 'my-custom-site', sourceRealmURL: testRealmURL, }); @@ -1393,7 +1407,10 @@ module('Acceptance | host submode', function (hooks) { assert .dom('[data-test-open-custom-subdomain-button]') - .hasAttribute('href', 'https://my-custom-site.localhost:4201/') + .hasAttribute( + 'href', + `https://my-custom-site.${publishedSpaceHost}/`, + ) .hasAttribute('target', '_blank'); } finally { realmServer.fetchBoxelClaimedDomain = originalFetchClaimed; @@ -1406,7 +1423,7 @@ module('Acceptance | host submode', function (hooks) { realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'my-custom-site.localhost:4201', + hostname: `my-custom-site.${publishedSpaceHost}`, subdomain: 'my-custom-site', sourceRealmURL: testRealmURL, }); @@ -1441,14 +1458,14 @@ module('Acceptance | host submode', function (hooks) { realmServer.fetchBoxelClaimedDomain = async () => ({ id: 'claimed-domain-1', - hostname: 'my-custom-site.localhost:4201', + hostname: `my-custom-site.${publishedSpaceHost}`, subdomain: 'my-custom-site', sourceRealmURL: testRealmURL, }); let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { - ['https://my-custom-site.localhost:4201/']: ( + [`https://my-custom-site.${publishedSpaceHost}/`]: ( new Date().getTime() - 2 * 24 * 60 * 60 * 1000 ).toString(), diff --git a/packages/host/tests/acceptance/interact-submode-test.gts b/packages/host/tests/acceptance/interact-submode-test.gts index 5884e686845..afa67de78f0 100644 --- a/packages/host/tests/acceptance/interact-submode-test.gts +++ b/packages/host/tests/acceptance/interact-submode-test.gts @@ -29,6 +29,7 @@ import type { import { percySnapshot, + testModuleRealm, testRealmURL, visitOperatorMode, type TestContextWithSave, @@ -962,7 +963,7 @@ module('Acceptance | interact submode tests', function (hooks) { }); test('visiting 2 stacks from differing realms', async function (assert) { - setActiveRealms([testRealmURL, 'https://localhost:4202/test/']); + setActiveRealms([testRealmURL, `${testModuleRealm}`]); await visitOperatorMode({ stacks: [ [ @@ -973,7 +974,7 @@ module('Acceptance | interact submode tests', function (hooks) { ], [ { - id: 'https://localhost:4202/test/hassan', + id: `${testModuleRealm}hassan`, format: 'isolated', }, ], diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index a64637a0a24..ddace132dfd 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -102,7 +102,14 @@ export { setupOperatorModeStateCleanup } from './operator-mode-state'; export * from '@cardstack/runtime-common/helpers'; export * from './indexer'; -export const testModuleRealm = ri('https://localhost:4202/test/'); +// The live test realm-server's /test/ realm. Standard mode serves it at +// `https://localhost:4202/test/`; env mode at the per-environment +// `https://realm-test..localhost/test/`. Sourced from the resolved +// URL the host config exports (set from `REALM_TEST_URL` by env-vars.sh) +// so a single test bundle works in both modes. +export const testModuleRealm = ri( + ensureTrailingSlash(ENV.resolvedTestRealmURL), +); /** * Build a `RealmResourceIdentifier` for a module in `testModuleRealm`. @@ -129,8 +136,15 @@ type ModuleHooks = { after: (callback: () => void | Promise) => void; }; +// Matrix homeserver URL. Standard mode: `http://localhost:8008`; env +// mode: `https://matrix..localhost`. ENV.matrixURL is the +// already-resolved value (set by `environment.js` from BOXEL_ENVIRONMENT +// or MATRIX_URL). The mock-matrix harness still routes requests to its +// in-process mock — this URL is what the host code reads as +// `ENV.matrixURL` and what the matrix-js-sdk gets as its default +// homeserver, so it has to match whichever stack is actually running. const baseTestMatrix = { - url: new URL(`http://localhost:8008`), + url: new URL(ENV.matrixURL), username: 'test_realm', password: 'password', }; diff --git a/packages/host/tests/integration/components/card-delete-test.gts b/packages/host/tests/integration/components/card-delete-test.gts index c13ae45c3ad..791d3dfa7f2 100644 --- a/packages/host/tests/integration/components/card-delete-test.gts +++ b/packages/host/tests/integration/components/card-delete-test.gts @@ -568,7 +568,7 @@ module('Integration | card-delete', function (hooks) { test('can delete a card that is a selected item', async function (assert) { setCardInOperatorModeState( [`${testRealmURL}index`], - [`https://localhost:4202/test/`], + [`${testModuleRealm}index`], ); await renderComponent( class TestDriver extends GlimmerComponent { diff --git a/packages/host/tests/integration/components/operator-mode-card-chooser-test.gts b/packages/host/tests/integration/components/operator-mode-card-chooser-test.gts index 17aa8c42b6a..076e35272a1 100644 --- a/packages/host/tests/integration/components/operator-mode-card-chooser-test.gts +++ b/packages/host/tests/integration/components/operator-mode-card-chooser-test.gts @@ -21,7 +21,7 @@ import { import OperatorMode from '@cardstack/host/components/operator-mode/container'; -import { percySnapshot, testRealmURL } from '../../helpers'; +import { percySnapshot, testModuleRealm, testRealmURL } from '../../helpers'; import { renderComponent } from '../../helpers/render-component'; import { setupOperatorModeTests } from './operator-mode/setup'; @@ -1032,10 +1032,7 @@ module('Integration | operator-mode | card chooser', function (hooks) { ); await waitFor(`[data-test-stack-card="${testRealmURL}grid"]`); await click(`[data-test-open-search-field]`); - await fillIn( - '[data-test-search-field]', - 'https://localhost:4202/test/nonexistent', - ); + await fillIn('[data-test-search-field]', `${testModuleRealm}nonexistent`); await waitFor(`[data-test-search-label]`); assert.dom('[data-test-search-sheet-empty]').exists(); assert diff --git a/packages/host/tests/integration/components/serialization-test.gts b/packages/host/tests/integration/components/serialization-test.gts index 1dbe21c7915..4e25c027fe6 100644 --- a/packages/host/tests/integration/components/serialization-test.gts +++ b/packages/host/tests/integration/components/serialization-test.gts @@ -39,6 +39,7 @@ import { provideConsumeContext, setupIntegrationTestRealm, setupLocalIndexing, + testModuleRealm, testRealmURL, testRRI, cardInfo, @@ -960,7 +961,7 @@ module('Integration | serialization', function (hooks) { }, }); - let ref = { module: `https://localhost:4202/test/person`, name: 'Person' }; + let ref = { module: `${testModuleRealm}person`, name: 'Person' }; let resource = { attributes: { ref, @@ -1006,7 +1007,7 @@ module('Integration | serialization', function (hooks) { }, }); - let ref = { module: `https://localhost:4202/test/person`, name: 'Person' }; + let ref = { module: `${testModuleRealm}person`, name: 'Person' }; let driver = new DriverCard({ ref }); let serializedRef = serializeCard(driver, { includeUnrenderedFields: true }) .data.attributes?.ref; @@ -5864,7 +5865,7 @@ module('Integration | serialization', function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/captain'), + module: rri(`${testModuleRealm}captain`), name: 'Captain', }, }, @@ -5889,7 +5890,7 @@ module('Integration | serialization', function (hooks) { }, meta: { adoptsFrom: { - module: rri(`https://localhost:4202/test/captain`), + module: rri(`${testModuleRealm}captain`), name: 'Boat', }, }, diff --git a/packages/host/tests/integration/enum-field-test.gts b/packages/host/tests/integration/enum-field-test.gts index 88671c1a949..19c27c19169 100644 --- a/packages/host/tests/integration/enum-field-test.gts +++ b/packages/host/tests/integration/enum-field-test.gts @@ -20,6 +20,7 @@ import { provideConsumeContext, setupCardLogs, setupIntegrationTestRealm, + testModuleRealm, } from '../helpers'; import { setupBaseRealm, @@ -243,7 +244,7 @@ module('Integration | enumField', function (hooks) { let t1b = (await createFromSerialized( doc1.data, doc1, - new URL('https://localhost:4202/test/'), + new URL(`${testModuleRealm}`), )) as Task; assert.strictEqual(t1b.priority, 'Medium', 'single enum value round-trips'); @@ -253,7 +254,7 @@ module('Integration | enumField', function (hooks) { let t2b = (await createFromSerialized( doc2.data, doc2, - new URL('https://localhost:4202/test/'), + new URL(`${testModuleRealm}`), )) as Task; assert.ok( Array.isArray(t2b.priorities), @@ -724,7 +725,7 @@ module('Integration | enumField', function (hooks) { let t2 = (await createFromSerialized( doc.data, doc, - new URL('https://localhost:4202/test/'), + new URL(`${testModuleRealm}`), )) as Task; assert.strictEqual( t2.priority, @@ -840,7 +841,7 @@ module('Integration | enumField', function (hooks) { let t2 = (await createFromSerialized( doc.data, doc, - new URL('https://localhost:4202/test/'), + new URL(`${testModuleRealm}`), )) as Task; assert.deepEqual( t2.priorities, diff --git a/packages/host/tests/integration/realm-indexing-test.gts b/packages/host/tests/integration/realm-indexing-test.gts index 20bcdfbde01..7a5f7642df2 100644 --- a/packages/host/tests/integration/realm-indexing-test.gts +++ b/packages/host/tests/integration/realm-indexing-test.gts @@ -8,6 +8,7 @@ import { md5 } from 'super-fast-md5'; import { baseRealm, baseCardRef, + ensureTrailingSlash, internalKeyFor, ri, rri, @@ -20,8 +21,14 @@ import { import stripScopedCSSAttributes from '@cardstack/runtime-common/helpers/strip-scoped-css-attributes'; import type { Loader } from '@cardstack/runtime-common/loader'; +import ENV from '@cardstack/host/config/environment'; import { REALM_INDEX_BOILERPLATE_HTML } from '@cardstack/host/utils/realm-index-boilerplate'; +// Standard mode: `http://localhost:4206/`; env mode: +// `https://icons..localhost/`. ENV.iconsURL is the resolved +// value populated by environment.js from ICONS_URL or BOXEL_ENVIRONMENT. +const iconsBase = ensureTrailingSlash(ENV.iconsURL); + import { testRealmURL, testRRI, @@ -328,7 +335,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -376,7 +383,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/person'), + module: rri(`${testModuleRealm}person`), name: 'Person', }, }, @@ -411,7 +418,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, lastModified: adapter.lastModifiedMap.get( @@ -453,7 +460,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -473,7 +480,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, }, @@ -509,7 +516,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, realmURL: ri('http://test-realm/test/'), @@ -565,7 +572,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, realmURL: ri('http://test-realm/test/'), @@ -603,7 +610,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -623,7 +630,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, }, @@ -673,7 +680,7 @@ module(`Integration | realm indexing`, function (hooks) { attributes: { firstName: 'Mango' }, meta: { adoptsFrom: { - module: 'http://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -744,7 +751,7 @@ module(`Integration | realm indexing`, function (hooks) { attributes: { firstName: 'Mango' }, meta: { adoptsFrom: { - module: 'http://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -775,7 +782,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -794,7 +801,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, }, @@ -821,7 +828,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -842,7 +849,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -878,7 +885,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, lastModified: adapter.lastModifiedMap.get( @@ -919,7 +926,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -940,7 +947,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -974,7 +981,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/pet'), + module: rri(`${testModuleRealm}pet`), name: 'Pet', }, lastModified: adapter.lastModifiedMap.get( @@ -2376,7 +2383,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/vendor`, + module: `${testModuleRealm}vendor`, name: 'Vendor', }, }, @@ -2390,7 +2397,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/chain`, + module: `${testModuleRealm}chain`, name: 'Chain', }, }, @@ -2404,7 +2411,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/chain`, + module: `${testModuleRealm}chain`, name: 'Chain', }, }, @@ -2471,7 +2478,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri(`https://localhost:4202/test/vendor`), + module: rri(`${testModuleRealm}vendor`), name: 'Vendor', }, lastModified: adapter.lastModifiedMap.get( @@ -2515,7 +2522,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri(`https://localhost:4202/test/chain`), + module: rri(`${testModuleRealm}chain`), name: 'Chain', }, lastModified: adapter.lastModifiedMap.get( @@ -2555,7 +2562,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri(`https://localhost:4202/test/chain`), + module: rri(`${testModuleRealm}chain`), name: 'Chain', }, lastModified: adapter.lastModifiedMap.get( @@ -2595,7 +2602,7 @@ module(`Integration | realm indexing`, function (hooks) { id: `${testRealmURL}Boom/boom`, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/card-with-error', + module: `${testModuleRealm}card-with-error`, name: 'Boom', }, }, @@ -2609,7 +2616,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -2678,7 +2685,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -2742,7 +2749,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/post', + module: `${testModuleRealm}post`, name: 'Post', }, }, @@ -2762,7 +2769,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/post', + module: `${testModuleRealm}post`, name: 'Post', }, }, @@ -2893,7 +2900,7 @@ module(`Integration | realm indexing`, function (hooks) { cardDescription: 'Spec for Booking', specType: 'card', ref: { - module: 'https://localhost:4202/test/booking', + module: `${testModuleRealm}booking`, name: 'Booking', }, }, @@ -2916,10 +2923,10 @@ module(`Integration | realm indexing`, function (hooks) { id: `${testRealmURL}Spec/booking`, cardDescription: 'Spec for Booking', specType: 'card', - moduleHref: 'https://localhost:4202/test/booking', + moduleHref: `${testModuleRealm}booking`, containedExamples: null, linkedExamples: null, - ref: 'https://localhost:4202/test/booking/Booking', + ref: `${testModuleRealm}booking/Booking`, cardTitle: 'Booking', isCard: true, isComponent: false, @@ -3439,7 +3446,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/friend', + module: `${testModuleRealm}friend`, name: 'Friend', }, }, @@ -3461,7 +3468,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/friend', + module: `${testModuleRealm}friend`, name: 'Friend', }, }, @@ -3484,7 +3491,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/friend', + module: `${testModuleRealm}friend`, name: 'Friend', }, }, @@ -3520,7 +3527,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/friend'), + module: rri(`${testModuleRealm}friend`), name: 'Friend', }, lastModified: adapter.lastModifiedMap.get( @@ -3602,7 +3609,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/friend', + module: `${testModuleRealm}friend`, name: 'Friend', }, }, @@ -3628,7 +3635,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/friend', + module: `${testModuleRealm}friend`, name: 'Friend', }, }, @@ -3670,7 +3677,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/friend'), + module: rri(`${testModuleRealm}friend`), name: 'Friend', }, lastModified: adapter.lastModifiedMap.get( @@ -3720,7 +3727,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/friend'), + module: rri(`${testModuleRealm}friend`), name: 'Friend', }, lastModified: adapter.lastModifiedMap.get( @@ -3816,7 +3823,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/friend'), + module: rri(`${testModuleRealm}friend`), name: 'Friend', }, lastModified: adapter.lastModifiedMap.get( @@ -3866,7 +3873,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/friend'), + module: rri(`${testModuleRealm}friend`), name: 'Friend', }, lastModified: adapter.lastModifiedMap.get( @@ -3950,7 +3957,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/friend', + module: `${testModuleRealm}friend`, name: 'Friend', }, }, @@ -3992,7 +3999,7 @@ module(`Integration | realm indexing`, function (hooks) { }, meta: { adoptsFrom: { - module: rri('https://localhost:4202/test/friend'), + module: rri(`${testModuleRealm}friend`), name: 'Friend', }, lastModified: adapter.lastModifiedMap.get( @@ -4684,29 +4691,29 @@ module(`Integration | realm indexing`, function (hooks) { // Exclude synthetic imports that encapsulate scoped CSS .filter((ref) => !ref.includes('glimmer-scoped.css')), [ - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/align-box-left-middle', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/align-left', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/arrow-left', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/bell', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/captions', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/clipboard-copy', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/code', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/eye', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/file', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/file-pencil', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/folder-pen', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/hash', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/image', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/import', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/letter-case', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link-off', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/notepad-text', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/palette', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/rectangle-ellipsis', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/trash-2', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/wand', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/x', + `${iconsBase}@cardstack/boxel-icons/v1/icons/align-box-left-middle`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/align-left`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/arrow-left`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/bell`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/captions`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/clipboard-copy`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/code`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/eye`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/file`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/file-pencil`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/folder-pen`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/hash`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/image`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/import`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/letter-case`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/link`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/link-off`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/notepad-text`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/palette`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/rectangle-ellipsis`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/trash-2`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/wand`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/x`, 'https://cardstack.com/base/-private', 'https://cardstack.com/base/card-api', 'https://cardstack.com/base/card-serialization', @@ -4751,7 +4758,7 @@ module(`Integration | realm indexing`, function (hooks) { 'https://cardstack.com/base/string', 'https://cardstack.com/base/text-input-validator', 'https://cardstack.com/base/watched-array', - 'https://localhost:4202/test/person', + `${testModuleRealm}person`, 'https://packages/@cardstack/boxel-host/commands/copy-and-edit', 'https://packages/@cardstack/boxel-host/commands/copy-card', 'https://packages/@cardstack/boxel-host/commands/copy-card-as-markdown', @@ -4791,7 +4798,12 @@ module(`Integration | realm indexing`, function (hooks) { 'https://packages/lodash', 'https://packages/super-fast-md5', 'https://packages/tracked-built-ins', - ], + // Sort the expected list so the assertion is robust against + // the iconsBase URL scheme/host: standard mode puts icons at + // `http://localhost:4206/`, env mode at `https://icons..localhost/`, + // and the lexical position of those entries among the other + // URLs differs accordingly. + ].sort(), 'the card references for the instance are correct', ); }); @@ -4838,37 +4850,37 @@ module(`Integration | realm indexing`, function (hooks) { // Exclude synthetic imports that encapsulate scoped CSS .filter((ref) => !ref.includes('glimmer-scoped.css')), [ - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/align-box-left-middle', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/align-left', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/apps', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/arrow-left', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/bell', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/book-open-text', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/box-model', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/captions', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/clipboard-copy', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/code', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/eye', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/file', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/file-pencil', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/folder-pen', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/git-branch', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/hash', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/image', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/import', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/layers-subtract', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/layout-list', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/letter-case', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/link-off', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/notepad-text', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/palette', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/rectangle-ellipsis', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/stack', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/toggle-left', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/trash-2', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/wand', - 'http://localhost:4206/@cardstack/boxel-icons/v1/icons/x', + `${iconsBase}@cardstack/boxel-icons/v1/icons/align-box-left-middle`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/align-left`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/apps`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/arrow-left`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/bell`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/book-open-text`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/box-model`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/captions`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/clipboard-copy`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/code`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/eye`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/file`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/file-pencil`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/folder-pen`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/git-branch`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/hash`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/image`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/import`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/layers-subtract`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/layout-list`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/letter-case`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/link`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/link-off`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/notepad-text`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/palette`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/rectangle-ellipsis`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/stack`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/toggle-left`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/trash-2`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/wand`, + `${iconsBase}@cardstack/boxel-icons/v1/icons/x`, 'https://cardstack.com/base/-private', 'https://cardstack.com/base/boolean', 'https://cardstack.com/base/card-api', @@ -4917,7 +4929,7 @@ module(`Integration | realm indexing`, function (hooks) { 'https://cardstack.com/base/string', 'https://cardstack.com/base/text-input-validator', 'https://cardstack.com/base/watched-array', - 'https://localhost:4202/test/person', + `${testModuleRealm}person`, 'https://packages/@cardstack/boxel-host/commands/copy-and-edit', 'https://packages/@cardstack/boxel-host/commands/copy-card', 'https://packages/@cardstack/boxel-host/commands/copy-card-as-markdown', @@ -4959,7 +4971,8 @@ module(`Integration | realm indexing`, function (hooks) { 'https://packages/lodash', 'https://packages/super-fast-md5', 'https://packages/tracked-built-ins', - ], + // See note on iconsBase ordering above. + ].sort(), 'the card references for the instance are correct', ); }); diff --git a/packages/host/tests/integration/realm-test.gts b/packages/host/tests/integration/realm-test.gts index 0b288362be8..c091b8bc6c0 100644 --- a/packages/host/tests/integration/realm-test.gts +++ b/packages/host/tests/integration/realm-test.gts @@ -154,7 +154,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -179,7 +179,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -225,7 +225,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, lastModified: adapter.lastModifiedMap.get( @@ -259,7 +259,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, lastModified: adapter.lastModifiedMap.get( @@ -292,13 +292,13 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { - self: `https://localhost:4202/test/hassan`, + self: `${testModuleRealm}hassan`, }, }, }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -329,12 +329,12 @@ module('Integration | realm', function (hooks) { ); assert.strictEqual( json.data.relationships.owner.links.self, - 'https://localhost:4202/test/hassan', + `${testModuleRealm}hassan`, 'owner self link points to other realm', ); assert.strictEqual( json.data.relationships.owner.data.id, - 'https://localhost:4202/test/hassan', + `${testModuleRealm}hassan`, 'owner data id points to other realm', ); assert.strictEqual( @@ -357,7 +357,7 @@ module('Integration | realm', function (hooks) { let hassan = included[0]; assert.strictEqual( hassan.id, - 'https://localhost:4202/test/hassan', + `${testModuleRealm}hassan`, 'included hassan id', ); assert.strictEqual( @@ -628,7 +628,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -658,7 +658,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -700,7 +700,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, lastModified: adapter.lastModifiedMap.get( @@ -734,7 +734,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, lastModified: adapter.lastModifiedMap.get( @@ -771,7 +771,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -861,7 +861,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -886,7 +886,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -943,7 +943,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -981,7 +981,7 @@ module('Integration | realm', function (hooks) { let { data: cards } = await queryEngine.searchCards({ filter: { on: { - module: rri(`https://localhost:4202/test/person`), + module: rri(`${testModuleRealm}person`), name: 'Person', }, eq: { firstName: 'Van Gogh' }, @@ -1008,7 +1008,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/booking', + module: `${testModuleRealm}booking`, name: 'Booking', }, }, @@ -1039,7 +1039,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/booking', + module: `${testModuleRealm}booking`, name: 'Booking', }, }, @@ -1086,7 +1086,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/booking', + module: `${testModuleRealm}booking`, name: 'Booking', }, lastModified: adapter.lastModifiedMap.get( @@ -1148,7 +1148,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1161,7 +1161,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1173,7 +1173,7 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Hassan', lastName: 'Abdel-Rahman' }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -1191,7 +1191,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1220,7 +1220,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1354,7 +1354,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1375,7 +1375,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1388,7 +1388,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1400,7 +1400,7 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Hassan', lastName: 'Abdel-Rahman' }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -1418,7 +1418,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1445,7 +1445,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1496,7 +1496,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, lastModified: adapter.lastModifiedMap.get(`${testRealmURL}jackie.json`), @@ -1518,7 +1518,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1531,7 +1531,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1546,7 +1546,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1573,7 +1573,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1612,7 +1612,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, lastModified: adapter.lastModifiedMap.get(`${testRealmURL}jackie.json`), @@ -1749,7 +1749,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1762,7 +1762,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1780,7 +1780,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1801,7 +1801,7 @@ module('Integration | realm', function (hooks) { relationships: { pets: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1835,7 +1835,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, lastModified: adapter.lastModifiedMap.get(`${testRealmURL}jackie.json`), @@ -1857,7 +1857,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -1869,7 +1869,7 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Hassan', lastName: 'Abdel-Rahman' }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -1881,7 +1881,7 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Burcu' }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -1899,7 +1899,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1926,7 +1926,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -1967,7 +1967,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, lastModified: adapter.lastModifiedMap.get(`${testRealmURL}jackie.json`), @@ -1989,7 +1989,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -2002,7 +2002,7 @@ module('Integration | realm', function (hooks) { relationships: { owner: { links: { self: null } } }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet`, + module: `${testModuleRealm}pet`, name: 'Pet', }, }, @@ -2014,7 +2014,7 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Hassan', lastName: 'Abdel-Rahman' }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -2026,7 +2026,7 @@ module('Integration | realm', function (hooks) { attributes: { firstName: 'Burcu' }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -2044,7 +2044,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -2073,7 +2073,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, }, @@ -2113,7 +2113,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: `https://localhost:4202/test/pet-person`, + module: `${testModuleRealm}pet-person`, name: 'PetPerson', }, lastModified: adapter.lastModifiedMap.get(`${testRealmURL}jackie.json`), @@ -2137,7 +2137,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -2152,7 +2152,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/person', + module: `${testModuleRealm}person`, name: 'Person', }, }, @@ -2175,7 +2175,7 @@ module('Integration | realm', function (hooks) { }, meta: { adoptsFrom: { - module: 'https://localhost:4202/test/pet', + module: `${testModuleRealm}pet`, name: 'Pet', }, }, diff --git a/packages/matrix/helpers/index.ts b/packages/matrix/helpers/index.ts index 4da2c651ee3..1d86cdc7dc3 100644 --- a/packages/matrix/helpers/index.ts +++ b/packages/matrix/helpers/index.ts @@ -510,7 +510,9 @@ export async function selectCardFromCatalog(page: Page, cardId: string) { .locator('[data-test-card-chooser-modal] [data-test-search-field]') .fill(cardId); await page - .locator(`[data-test-card-chooser-modal] [data-test-item-button="${cardId}"]`) + .locator( + `[data-test-card-chooser-modal] [data-test-item-button="${cardId}"]`, + ) .first() .click(); await page.locator('[data-test-card-chooser-go-button]').click(); diff --git a/packages/matrix/support/docker.ts b/packages/matrix/support/docker.ts index a2d1fae1e20..c3b832a9c13 100644 --- a/packages/matrix/support/docker.ts +++ b/packages/matrix/support/docker.ts @@ -34,7 +34,7 @@ async function dockerPull( ['pull', image], { encoding: 'utf8' }, (err, _stdout, stderr) => - resolve(err ? (stderr.trim() || err.message) : null), + resolve(err ? stderr.trim() || err.message : null), ); }); if (stderr === null) { diff --git a/packages/realm-server/lib/realm-registry-backfill.ts b/packages/realm-server/lib/realm-registry-backfill.ts index 6dcc389aaa4..e553b5eeaa8 100644 --- a/packages/realm-server/lib/realm-registry-backfill.ts +++ b/packages/realm-server/lib/realm-registry-backfill.ts @@ -3,12 +3,15 @@ import { access } from 'fs/promises'; import { join, resolve } from 'path'; import { PUBLISHED_DIRECTORY_NAME, + ensureTrailingSlash, + insertPermissions, logger, param, query, type DBAdapter, } from '@cardstack/runtime-common'; import type { PgAdapter } from '@cardstack/postgres'; +import { isEnvironmentMode } from './dev-service-registry'; const log = logger('realm-server:registry-backfill'); @@ -80,6 +83,21 @@ export async function runRegistryBackfill( await safeStep('stale-bootstrap-check', () => warnOnStaleBootstrapRows(opts, bootstrapUrls ?? new Set()), ); + // Environment mode mounts the dev realms at per-environment Traefik URLs, + // which the static-URL permission migrations never seed. Without parity, + // public realms 401 unauthenticated readers AND any bootstrap realm whose + // CLI fromUrl is scoped (e.g. `@cardstack/skills/`) has its realm.url set + // to the env-served URL and so finds no realm-owner row at boot — + // `Realm#startup` → `getRealmOwnerUserId()` throws "Cannot determine + // realm owner", which silently aborts the from-scratch-index and leaves + // the realm mounted with zero rows in boxel_index. Mirror every + // standard-mode permission row (public-read, realm-owner, named-user + // read/write) onto the env-mode URL by matching pathname. + if (isEnvironmentMode()) { + await safeStep('env-permission-parity', () => + seedEnvironmentPermissionParity(opts), + ); + } // Note: `sourceDiscovered` is the number of realm directories seen on // disk, not the number of INSERTs actually executed. Under ON CONFLICT DO @@ -138,6 +156,120 @@ async function upsertBootstrapRealms( return seen; } +// Mirror every standard-mode realm_user_permissions row (public read, +// realm-owner, named-user read/write) onto each bootstrap realm at the +// env-mode URL by path-matching. Static-URL permission migrations +// (`http://localhost:4201//`, canonical base URL) don't match +// env-mode `realm-server..localhost//` URLs, so without +// this every scoped-fromUrl bootstrap realm (skills, openrouter, …) boots +// with no realm-owner row and its from-scratch-index aborts immediately +// in `getRealmOwnerUserId()`, leaving the realm mounted but unindexed. +// +// Per-(username, env-url) granularity: only inserts if no row exists at +// the env URL for that user — so a custom permission added later via the +// admin UI isn't clobbered by a subsequent boot. The path-keyed match +// keeps this in lockstep with whatever the migrations declare (no second +// hardcoded realm list); only realms whose path is already declared in +// `realm_user_permissions` are touched, so it cannot grant access to a +// realm policy never authorized. +// +// Matrix server_name is always "localhost" for `*.localhost` subdomains +// (see `userIdFromUsername` in matrix-client.ts), so usernames like +// `@skills_realm:localhost` work in both standard and env mode unchanged. +async function seedEnvironmentPermissionParity( + opts: RegistryBackfillOpts, +): Promise { + type PermRow = { + realm_url: string; + username: string; + read: boolean; + write: boolean; + realm_owner: boolean; + }; + let allRows = (await query(opts.dbAdapter, [ + `SELECT realm_url, username, read, write, realm_owner FROM realm_user_permissions`, + ])) as PermRow[]; + + // Bucket existing permissions by URL pathname so we can match a + // bootstrap realm's env-mode pathname against the standard-mode source + // rows that share it. Also bucket by env-mode URL so we can skip rows + // a previous boot already mirrored (idempotency without an upsert). + let pathToRows = new Map(); + let envUrlSeenUsernames = new Map>(); + for (let row of allRows) { + let pathname: string; + try { + pathname = new URL(row.realm_url).pathname; + } catch { + continue; + } + if (!pathToRows.has(pathname)) { + pathToRows.set(pathname, []); + } + pathToRows.get(pathname)!.push(row); + if (!envUrlSeenUsernames.has(row.realm_url)) { + envUrlSeenUsernames.set(row.realm_url, new Set()); + } + envUrlSeenUsernames.get(row.realm_url)!.add(row.username); + } + + let seeded = 0; + for (let { url } of opts.bootstrapRealms) { + let realmURL = new URL(ensureTrailingSlash(url)); + let sourceRows = pathToRows.get(realmURL.pathname); + if (!sourceRows?.length) { + continue; + } + let existingAtEnv = + envUrlSeenUsernames.get(realmURL.href) ?? new Set(); + // Coalesce sibling source rows (e.g. localhost:4201 + localhost:4205 + // both grant the same user) by OR-ing their permission bits, so a + // single env-mode row carries the union of standard-mode grants. + let coalesced = new Map< + string, + { read: boolean; write: boolean; realm_owner: boolean } + >(); + for (let row of sourceRows) { + if (row.realm_url === realmURL.href) { + continue; // skip rows already at the env URL + } + let existing = coalesced.get(row.username) ?? { + read: false, + write: false, + realm_owner: false, + }; + coalesced.set(row.username, { + read: existing.read || row.read, + write: existing.write || row.write, + realm_owner: existing.realm_owner || row.realm_owner, + }); + } + let toInsert: Record> = {}; + for (let [username, perms] of coalesced) { + if (existingAtEnv.has(username)) { + continue; + } + let actions: Array<'read' | 'write' | 'realm-owner'> = []; + if (perms.read) actions.push('read'); + if (perms.write) actions.push('write'); + if (perms.realm_owner) actions.push('realm-owner'); + if (actions.length === 0) { + continue; + } + toInsert[username] = actions; + } + if (Object.keys(toInsert).length === 0) { + continue; + } + await insertPermissions(opts.dbAdapter, realmURL, toInsert); + seeded += Object.keys(toInsert).length; + log.info( + `seeded env-mode permission parity for ${realmURL.href}: ${Object.keys(toInsert).join(', ')}`, + ); + } + return seeded; +} + async function upsertSourceRealms(opts: RegistryBackfillOpts): Promise { if (!existsSync(opts.realmsRootPath)) { return 0; diff --git a/packages/realm-server/tests/realm-registry-backfill-test.ts b/packages/realm-server/tests/realm-registry-backfill-test.ts index a62322c24f1..3f5b1f468b5 100644 --- a/packages/realm-server/tests/realm-registry-backfill-test.ts +++ b/packages/realm-server/tests/realm-registry-backfill-test.ts @@ -6,12 +6,50 @@ import type { PgAdapter } from '@cardstack/postgres'; import { asExpressions, insert, + insertPermissions, query, + param, PUBLISHED_DIRECTORY_NAME, } from '@cardstack/runtime-common'; import { setupDB } from './helpers/index.ts'; import { runRegistryBackfill } from '../lib/realm-registry-backfill.ts'; +async function publicReadGranted( + dbAdapter: PgAdapter, + realmURL: string, +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT read FROM realm_user_permissions WHERE realm_url =`, + param(realmURL), + `AND username = '*'`, + ])) as Array<{ read: boolean }>; + return rows.length > 0 && rows[0].read === true; +} + +interface UserPermissionRow { + username: string; + read: boolean; + write: boolean; + realm_owner: boolean; +} + +async function userPermissionsAt( + dbAdapter: PgAdapter, + realmURL: string, +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT username, read, write, realm_owner FROM realm_user_permissions WHERE realm_url =`, + param(realmURL), + `ORDER BY username`, + ])) as Array>; + return rows.map((r) => ({ + username: r.username as string, + read: r.read as boolean, + write: r.write as boolean, + realm_owner: r.realm_owner as boolean, + })); +} + interface RegistryRow { url: string; kind: string; @@ -244,6 +282,203 @@ module(basename(__filename), function () { ); }); + module('env-mode permission parity', function (envHooks) { + let priorBoxelEnvironment: string | undefined; + // Use a synthetic `/probe-realm/` pathname for tests that deepEqual + // the full row set at the env-mode URL. The skills + base + catalog + // migrations already seed standard-mode rows the template DB carries + // into every test, so a test that uses `/skills/` would silently + // pick those rows up alongside its own and yield an "actual" that + // looks reasonable but isn't what the test set up. The publicReadGranted + // tests below stay on `/skills/` because they only assert one specific + // property and aren't affected by extra rows. + const envSkillsURL = 'https://realm-server.test-env.localhost/skills/'; + const envPrivateURL = 'https://realm-server.test-env.localhost/private/'; + const stdSkillsURL = 'http://localhost:4201/skills/'; + const envProbeURL = + 'https://realm-server.test-env.localhost/probe-realm/'; + const stdProbeURL = 'http://localhost:4201/probe-realm/'; + const altStdProbeURL = 'http://localhost:4205/probe-realm/'; + + envHooks.beforeEach(function () { + priorBoxelEnvironment = process.env.BOXEL_ENVIRONMENT; + process.env.BOXEL_ENVIRONMENT = 'test-env'; + }); + envHooks.afterEach(function () { + if (priorBoxelEnvironment === undefined) { + delete process.env.BOXEL_ENVIRONMENT; + } else { + process.env.BOXEL_ENVIRONMENT = priorBoxelEnvironment; + } + }); + + test('grants public read at the env-mode URL for an already-public path', async function (assert) { + // Stand in for the migration seed that makes the standard-mode skills + // realm public. + await insertPermissions(dbAdapter, new URL(stdSkillsURL), { + '*': ['read'], + }); + const bootstrapPath = join(dir.name, 'skills'); + seedRealmJson(bootstrapPath, { name: 'skills' }); + + await runRegistryBackfill({ + dbAdapter, + realmsRootPath, + serverURL, + bootstrapRealms: [{ diskPath: bootstrapPath, url: envSkillsURL }], + }); + + assert.true( + await publicReadGranted(dbAdapter, envSkillsURL), + 'env-mode skills realm is now public-readable', + ); + }); + + test('does not promote a realm whose path is not already public', async function (assert) { + await insertPermissions(dbAdapter, new URL(stdSkillsURL), { + '*': ['read'], + }); + const bootstrapPath = join(dir.name, 'private'); + seedRealmJson(bootstrapPath, { name: 'private' }); + + await runRegistryBackfill({ + dbAdapter, + realmsRootPath, + serverURL, + bootstrapRealms: [{ diskPath: bootstrapPath, url: envPrivateURL }], + }); + + assert.false( + await publicReadGranted(dbAdapter, envPrivateURL), + 'a path with no existing public grant stays private', + ); + }); + + test('is a no-op when BOXEL_ENVIRONMENT is unset', async function (assert) { + delete process.env.BOXEL_ENVIRONMENT; + await insertPermissions(dbAdapter, new URL(stdSkillsURL), { + '*': ['read'], + }); + const bootstrapPath = join(dir.name, 'skills'); + seedRealmJson(bootstrapPath, { name: 'skills' }); + + await runRegistryBackfill({ + dbAdapter, + realmsRootPath, + serverURL, + bootstrapRealms: [{ diskPath: bootstrapPath, url: envSkillsURL }], + }); + + assert.false( + await publicReadGranted(dbAdapter, envSkillsURL), + 'standard mode does not seed env-mode parity rows', + ); + }); + + test('mirrors realm-owner, write, and named-user rows from the matching standard-mode URL', async function (assert) { + // Stand in for the migration seed: realm-owner + read + write for the + // realm bot, read+write for a writer, and public read. + await insertPermissions(dbAdapter, new URL(stdProbeURL), { + '@probe_realm:localhost': ['read', 'write', 'realm-owner'], + '@probe_writer:localhost': ['read', 'write'], + '*': ['read'], + }); + const bootstrapPath = join(dir.name, 'probe-realm'); + seedRealmJson(bootstrapPath, { name: 'probe-realm' }); + + await runRegistryBackfill({ + dbAdapter, + realmsRootPath, + serverURL, + bootstrapRealms: [{ diskPath: bootstrapPath, url: envProbeURL }], + }); + + assert.deepEqual( + await userPermissionsAt(dbAdapter, envProbeURL), + [ + { + username: '*', + read: true, + write: false, + realm_owner: false, + }, + { + username: '@probe_realm:localhost', + read: true, + write: true, + realm_owner: true, + }, + { + username: '@probe_writer:localhost', + read: true, + write: true, + realm_owner: false, + }, + ], + 'env-mode URL gets the full standard-mode permission set', + ); + }); + + test('coalesces multiple standard-mode source URLs sharing the bootstrap path', async function (assert) { + // Some migrations seed BOTH localhost:4201 and localhost:4205 for + // the same realm with the same grants. Either alone (or both) must + // produce a single env-mode row carrying the union. + await insertPermissions(dbAdapter, new URL(stdProbeURL), { + '@probe_realm:localhost': ['read', 'write', 'realm-owner'], + }); + await insertPermissions(dbAdapter, new URL(altStdProbeURL), { + '@probe_realm:localhost': ['read', 'write', 'realm-owner'], + }); + const bootstrapPath = join(dir.name, 'probe-realm'); + seedRealmJson(bootstrapPath, { name: 'probe-realm' }); + + await runRegistryBackfill({ + dbAdapter, + realmsRootPath, + serverURL, + bootstrapRealms: [{ diskPath: bootstrapPath, url: envProbeURL }], + }); + + assert.deepEqual(await userPermissionsAt(dbAdapter, envProbeURL), [ + { + username: '@probe_realm:localhost', + read: true, + write: true, + realm_owner: true, + }, + ]); + }); + + test('preserves a custom env-mode permission row across reruns', async function (assert) { + await insertPermissions(dbAdapter, new URL(stdProbeURL), { + '@probe_realm:localhost': ['read', 'write', 'realm-owner'], + }); + // Operator pre-seeded a downgraded permission for the same user at + // the env URL — backfill must not clobber it. + await insertPermissions(dbAdapter, new URL(envProbeURL), { + '@probe_realm:localhost': ['read'], + }); + const bootstrapPath = join(dir.name, 'probe-realm'); + seedRealmJson(bootstrapPath, { name: 'probe-realm' }); + + await runRegistryBackfill({ + dbAdapter, + realmsRootPath, + serverURL, + bootstrapRealms: [{ diskPath: bootstrapPath, url: envProbeURL }], + }); + + assert.deepEqual(await userPermissionsAt(dbAdapter, envProbeURL), [ + { + username: '@probe_realm:localhost', + read: true, + write: false, + realm_owner: false, + }, + ]); + }); + }); + test('bootstrap upsert does not clobber a non-bootstrap row with a colliding URL', async function (assert) { const { valueExpressions, nameExpressions } = asExpressions({ url: 'http://localhost:4201/luke/app/', diff --git a/packages/runtime-common/virtual-network.ts b/packages/runtime-common/virtual-network.ts index 2011a9a74fc..94ba84ee3e2 100644 --- a/packages/runtime-common/virtual-network.ts +++ b/packages/runtime-common/virtual-network.ts @@ -498,6 +498,26 @@ const backOffMs = 100; const retryableLocalHosts = new Set(['localhost', '127.0.0.1']); function shouldRetryFetch(url: URL) { + // Env-mode services live at `..localhost` and are + // reached through a local Traefik. The realm-server worker fetches + // its own realm's `_mtimes` via this hostname on boot, and if Traefik + // hasn't picked up the dynamic route file yet the first attempt fails + // with ECONNRESET. Without a retry, that single failure rejects the + // from-scratch-index job and leaves the realm mounted but unindexed. + // Gate on `BOXEL_ENVIRONMENT` rather than the `__environment === 'test'` + // global below: worker processes don't set that global (only `main.ts` + // does), and the standard-mode realm-server tests do set it — those + // tests POST to `testuser.localhost:4445` and rely on no-retry + // behavior for their publish/unpublish flows, so we must scope this + // retry to env-mode runs only. + if ( + typeof process !== 'undefined' && + process.env?.BOXEL_ENVIRONMENT && + url.hostname.endsWith('.localhost') + ) { + return true; + } + if ((globalThis as any).__environment !== 'test') { return false; } diff --git a/packages/runtime-common/worker.ts b/packages/runtime-common/worker.ts index 913309be407..36067465e11 100644 --- a/packages/runtime-common/worker.ts +++ b/packages/runtime-common/worker.ts @@ -389,21 +389,56 @@ export function getReader( }, mtimes: async () => { - let response = await _fetch(`${realmURL}_mtimes`, { - headers: { - Accept: SupportedMimeType.Mtimes, - }, - }); - if (!response.ok) { + // Env-mode boot race: the realm-server writes its Traefik dynamic + // route file in `registerService`, but Traefik picks the file up + // via inotify a short moment later. A worker that begins indexing + // immediately after the realm-server's `listening` callback fires + // can hit Traefik's default "404 page not found" before its own + // route is live. With the current handler logging and returning + // {} on that response, the from-scratch index finishes with zero + // files and the realm stays mounted but unindexed for the rest + // of the process's life. Distinguish the intermediary 404 from a + // genuine realm-server 404 by checking for the `X-Boxel-Realm-Url` + // header (every realm-server response carries it; Traefik's + // default response doesn't). Retry with backoff while the header + // is absent, so the eventual route-live state resolves naturally + // and the index actually walks the realm. + const MAX_ATTEMPTS = 10; + const BACKOFF_MS = 200; + let response: Response | undefined; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + response = await _fetch(`${realmURL}_mtimes`, { + headers: { + Accept: SupportedMimeType.Mtimes, + }, + }); + if (response.ok) break; + let fromRealmServer = response.headers.has('X-Boxel-Realm-Url'); + if (fromRealmServer || attempt === MAX_ATTEMPTS) break; + console.warn( + `mtimes for ${realmURL}_mtimes got ${response.status} from intermediary (no X-Boxel-Realm-Url header), retrying (attempt ${attempt}/${MAX_ATTEMPTS}) after ${attempt * BACKOFF_MS}ms`, + ); + // Cancel the body before backing off — undici holds the + // underlying connection in a reserved state until the body is + // consumed or cancelled, and a 10-attempt loop on each indexed + // realm at boot would otherwise pin sockets across the backoff + // window for no benefit (the body is Traefik's "404 page not + // found" which we never use). + await response.body?.cancel().catch(() => {}); + await new Promise((resolve) => + setTimeout(resolve, attempt * BACKOFF_MS), + ); + } + if (!response!.ok) { let responseText = ''; try { - responseText = await response.text(); + responseText = await response!.text(); } catch { responseText = ''; } let details = responseText ? `: ${responseText}` : ''; console.warn( - `mtimes request failed for ${realmURL}_mtimes (${response.status} ${response.statusText})${details}`, + `mtimes request failed for ${realmURL}_mtimes (${response!.status} ${response!.statusText})${details}`, ); return {}; } @@ -411,7 +446,7 @@ export function getReader( data: { attributes: { mtimes }, }, - } = (await response.json()) as { + } = (await response!.json()) as { data: { attributes: { mtimes: { [url: string]: number } } }; }; return mtimes;