From bfb8ad0accbbd64a5f5f72170e3d7ca4f2aaf81e Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 10:27:43 -0800 Subject: [PATCH 01/18] Load tauth.js via CDN and untrack env files --- .env.tauth.example | 8 -- .gitignore | 1 - ARCHITECTURE.md | 12 +-- CHANGELOG.md | 4 + ISSUES.md | 4 + README.md | 21 +++-- docker-compose.yml | 14 ++-- env.ghttp.example | 8 ++ .env.gravity.example => env.gravity.example | 0 env.tauth.example | 22 +++++ frontend/data/runtime.config.development.json | 5 +- frontend/data/runtime.config.production.json | 1 + frontend/js/app.js | 1 + frontend/js/core/config.js | 12 +++ frontend/js/core/environmentConfig.js | 7 +- frontend/js/core/runtimeConfig.js | 84 ++++++++++++++++++- frontend/js/core/tauthClient.js | 16 +++- frontend/tests/auth.tauth.puppeteer.test.js | 2 + frontend/tests/config.runtime.test.js | 15 ++++ frontend/tests/helpers/browserHarness.js | 12 ++- frontend/tests/helpers/syncScenarioHarness.js | 2 + frontend/tests/helpers/syncTestUtils.js | 8 +- .../persistence.backend.puppeteer.test.js | 2 + .../tests/runtimeConfig.initialize.test.js | 34 +++++++- .../tests/sync.endtoend.puppeteer.test.js | 2 + .../tests/sync.realtime.puppeteer.test.js | 2 + 26 files changed, 255 insertions(+), 44 deletions(-) delete mode 100644 .env.tauth.example create mode 100644 env.ghttp.example rename .env.gravity.example => env.gravity.example (100%) create mode 100644 env.tauth.example diff --git a/.env.tauth.example b/.env.tauth.example deleted file mode 100644 index f265eb8..0000000 --- a/.env.tauth.example +++ /dev/null @@ -1,8 +0,0 @@ -APP_LISTEN_ADDR=:8080 -APP_GOOGLE_WEB_CLIENT_ID=qqq.apps.googleusercontent.com -APP_JWT_SIGNING_KEY=qqq -APP_DATABASE_URL=sqlite:///data/tauth.db -APP_ENABLE_CORS=true -APP_CORS_ALLOWED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000 -APP_DEV_INSECURE_HTTP=true -APP_COOKIE_DOMAIN= diff --git a/.gitignore b/.gitignore index af2d5dc..183554a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ tools/ # Managed by gix gitignore workflow .env .env.* -!.env.*.example .DS_Store qodana.yaml bin/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b32fbb2..53b734b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -76,8 +76,8 @@ EasyMDE produces markdown, marked renders it to HTML, and DOMPurify sanitises th - `GravityStore` persists notes in IndexedDB for offline-first behaviour; reconciliation applies backend snapshots. - `createNoteRecord` validates note identifiers/markdown before writes so malformed payloads never hit storage. - `GravityStore.setUserScope(userId)` switches the storage namespace so each Google account receives an isolated notebook. -- Runtime configuration loads from environment-specific JSON files under `data/`, selected according to the active hostname. Each profile now surfaces `authBaseUrl` so the frontend knows which TAuth origin to contact when requesting `/auth/nonce`, `/auth/google`, and `/auth/logout`. -- Authentication flows through Google Identity Services + TAuth: the browser loads `authBaseUrl/tauth.js`, fetches a nonce from `/auth/nonce`, exchanges Google credentials at `/auth/google`, and refreshes the session via `/auth/refresh`. The frontend never sends Google tokens to the Gravity backend; every API request simply carries the `app_session` cookie minted by TAuth and validated locally via HS256. +- Runtime configuration loads from environment-specific JSON files under `data/`, selected according to the active hostname. Each profile now surfaces `authBaseUrl` (API origin) plus `tauthScriptUrl` (CDN host) so the frontend knows where to call `/auth/*` and where to load `tauth.js`. +- Authentication flows through Google Identity Services + TAuth: the browser loads `tauth.js` from `tauthScriptUrl`, fetches a nonce from `/auth/nonce`, exchanges Google credentials at `/auth/google`, and refreshes the session via `/auth/refresh`. The frontend never sends Google tokens to the Gravity backend; every API request simply carries the `app_session` cookie minted by TAuth and validated locally via HS256. - The backend records a canonical user table (`user_identities`) so each `(provider, subject)` pair (for example `google:1234567890`) maps to a stable Gravity `user_id`. That allows multiple login providers to point at the same notebook without rewriting note rows. #### Frontend Dependencies @@ -139,7 +139,8 @@ Profiles live under `frontend/data/runtime.config..json` and are se { "environment": "development", "backendBaseUrl": "http://localhost:8080", - "llmProxyUrl": "http://localhost:8081/v1/gravity/classify" + "llmProxyUrl": "http://localhost:8081/v1/gravity/classify", + "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js" } ``` @@ -148,7 +149,8 @@ Profiles live under `frontend/data/runtime.config..json` and are se { "environment": "production", "backendBaseUrl": "https://gravity-api.mprlab.com", - "llmProxyUrl": "https://llm-proxy.mprlab.com/v1/gravity/classify" + "llmProxyUrl": "https://llm-proxy.mprlab.com/v1/gravity/classify", + "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js" } ``` @@ -156,7 +158,7 @@ When serving from an alternate hostname, add a new profile or override the URLs #### Authentication Contract -- **Browser responsibilities:** Gravity’s frontend loads `authBaseUrl/tauth.js`, asks `/auth/nonce` for a nonce, exchanges Google credentials at `/auth/google`, and retries requests via `/auth/refresh` when the backend returns `401`. All network calls simply include the `app_session` cookie; no Google tokens touch the Gravity API. +- **Browser responsibilities:** Gravity’s frontend loads `tauth.js` from `tauthScriptUrl`, asks `/auth/nonce` for a nonce, exchanges Google credentials at `/auth/google`, and retries requests via `/auth/refresh` when the backend returns `401`. All network calls simply include the `app_session` cookie; no Google tokens touch the Gravity API. - **Backend responsibilities:** the API validates `app_session` with the shared HS256 signing secret and fixed `tauth` issuer, stores no refresh tokens, and trusts the canonical `user_id` resolved by the `user_identities` table. A one-time migration strips the legacy `google:` prefix from existing note rows and backfills the identity mapping automatically. - **Logout propagation:** triggering **Sign out** in the UI invokes `/auth/logout`, revokes refresh tokens inside TAuth, and dispatches `gravity:auth-sign-out` so the browser returns to the anonymous notebook. - **Future providers:** because every `(provider, subject)` pair maps to the same Gravity user, we can add Apple/email sign-in later without rewriting stored notes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 250ea5b..72efd3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,15 @@ and are grouped by the date the work landed on `master`. - TAuth session now delegates nonce issuance and credential exchange to auth-client helpers instead of local fetches (GN-423). - Centralized environment config defaults and reused them across runtime config and test harnesses (GN-427). - Runtime config now returns a frozen app config and callers pass it explicitly instead of shared globals (GN-427). +- Environment example files now live at `env.*.example`, keeping `.env*` files untracked while preserving copy-ready templates (GN-443). - Signed-out visitors now see a landing page with a Google sign-in button; the Gravity interface requires authentication (GN-126). - mpr-ui now loads from a static script tag and auth components mount after runtime config so attributes are applied before initialization (GN-436). - Frontend now pulls mpr-ui assets from the `@latest` CDN tag so releases stay aligned (GN-437). ### Fixed +- TAuth helper now loads from a dedicated CDN URL via `tauthScriptUrl`, and gHTTP no longer proxies `/tauth.js` in the dev stack (GN-442). +- Dev docker compose now serves Gravity over HTTPS at computercat.tyemirov.net:4443 via gHTTP proxies for backend/TAuth endpoints, with updated dev runtime config and env templates (GN-441). +- Normalized development runtime config endpoints to swap loopback hosts for the active dev hostname and refreshed the TAuth env example for localhost defaults (GN-440). - Sync queue now coalesces per note and resolves payloads from the latest stored note to avoid duplicate ops and offline failures (GN-439). - Landing sign-in now sets mpr-ui auth base/login/logout/nonce attributes so nonce requests hit TAuth instead of the frontend origin (GN-433). - Runtime config now accepts a Google client ID override so local GSI origins can match the correct project (GN-438). diff --git a/ISSUES.md b/ISSUES.md index 0eb34ef..43412dc 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -169,6 +169,10 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [x] [GN-438] (P1) Allow runtime config to override the Google client ID so local dev origins can match the correct GSI project. (Resolved by requiring googleClientId in runtime config payloads/JSON, plumbing through the config builder, and updating tests.) - [x] [GN-439] (P1) `sync.manager.test.js` fails during `make ci` with `offline` errors and duplicate sync operations (expected 1, observed 2) in the "coalesces repeated upserts" case; investigate and stabilize. (Resolved by coalescing per-note operations, deferring payload hydration to flush time, and adding regression coverage.) - [x] [GN-439] (P1) `QuotaExceededError` in sync queue persistence when localStorage fills; redesign persistence to avoid localStorage quotas and coalesce pending sync operations. (Resolved by migrating notes/sync queue/sync metadata to IndexedDB with localStorage-only test mode and storage-full notifications.) +- [x] [GN-440] (P1) Local auth fails when TAuth tenant origin/cookie domain drift from the runtime-configured localhost URLs, causing tauth.js/nonce requests to miss or fail CORS. (Resolved by rewriting loopback runtime endpoints for non-loopback dev hosts and refreshing TAuth env defaults for localhost.) +- [x] [GN-441] (P1) Dev auth fails on computercat.tyemirov.net because gHTTP still serves only the frontend and runtime config points to localhost; proxy backend/TAuth through gHTTP HTTPS and update dev config/env defaults. (Resolved by adding gHTTP HTTPS env config + proxy routes, updating compose and runtime config defaults for computercat, and documenting the new dev stack.) +- [ ] [GN-442] (P1) Load tauth.js from the CDN (not via gHTTP), remove the /tauth.js proxy route, and wire the runtime config/test harness to use a dedicated tauthScriptUrl for the helper. +- [x] [GN-443] (P1) Keep `.env*` files untracked and rename example env files to `env.*.example` with updated setup docs. ## Maintenance (428–499) diff --git a/README.md b/README.md index 484916c..12969e0 100644 --- a/README.md +++ b/README.md @@ -57,29 +57,32 @@ Developers and curious tinkerers can find project structure, dependencies, and r ## Local Stack (Gravity + TAuth) -Run the full application locally (frontend, backend, and the new TAuth service) via Docker: +Run the full application locally (frontend, backend, TAuth, and the gHTTP reverse proxy) via Docker: 1. Copy the environment files and customize secrets as needed: - - `cp .env.gravity.example .env.gravity` - - `cp .env.tauth.example .env.tauth` + - `cp env.gravity.example .env.gravity` + - `cp env.tauth.example .env.tauth` + - `cp env.ghttp.example .env.ghttp` Keep `GRAVITY_TAUTH_SIGNING_SECRET` in sync with `APP_JWT_SIGNING_KEY` so Gravity and TAuth agree on JWT signatures. -2. Start the stack with the development profile (local backend build): `docker compose --profile dev up --build` +2. Add TLS certificates for `computercat.tyemirov.net` and update `.env.ghttp` with the paths (the compose file mounts `./certs` at `/certs` inside the container). + +3. Start the stack with the development profile (local backend build): `docker compose --profile dev up --build` Switch to the docker profile (`docker compose --profile docker up`) to run everything from prebuilt GHCR images. The compose file exposes: -- Frontend static assets at `http://localhost:8000` -- Gravity backend API at `http://localhost:8080` -- TAuth (nonce + Google exchange + `tauth.js`) at `http://localhost:8082` +- Frontend + proxied API at `https://computercat.tyemirov.net:4443` (gHTTP terminates TLS and proxies `/notes`, `/auth`, and `/api` to the backend/TAuth containers) +- Gravity backend API at `http://localhost:8080` (container port published for direct access) +- TAuth (nonce + Google exchange) at `http://localhost:8082` (container port published for direct access) -Runtime configuration files under `frontend/data/` now include `authBaseUrl`, so the browser can discover which TAuth origin to contact for `/auth/nonce`, `/auth/google`, and `/auth/logout` once the frontend wiring lands. Update `frontend/data/runtime.config.production.json` if your deployment uses a different TAuth hostname. +Runtime configuration files under `frontend/data/` include `authBaseUrl` and `tauthScriptUrl`, so the browser can discover which TAuth origin to contact for `/auth/nonce`, `/auth/google`, and `/auth/logout` and which CDN host serves `tauth.js`. Update `frontend/data/runtime.config.production.json` if your deployment uses a different TAuth hostname or script CDN, and update `frontend/data/runtime.config.development.json` if you run dev on a different HTTPS origin. ### Authentication Contract -- Gravity no longer exchanges Google credentials itself. The browser loads `https:///tauth.js`, fetches a nonce from `/auth/nonce`, and lets TAuth exchange the Google credential at `/auth/google`. +- Gravity no longer exchanges Google credentials itself. The browser loads `tauth.js` from the configured CDN (`tauthScriptUrl`), fetches a nonce from `/auth/nonce`, and lets TAuth exchange the Google credential at `/auth/google`. - TAuth mints two cookies: `app_session` (short-lived HS256 JWT) and `app_refresh` (long-lived refresh token). Every request from the UI includes `app_session` automatically, so the Gravity backend validates the JWT using `GRAVITY_TAUTH_SIGNING_SECRET` and the fixed `tauth` issuer. No bearer tokens or local storage is used. - To keep the multi-tenant TAuth flow working, the backend’s CORS preflight now whitelists the `X-TAuth-Tenant` header (in addition to `Authorization`, `Content-Type`, etc.), so browsers can send the tenant hint while relying on cookie authentication. - When a request returns `401`, the browser calls `/auth/refresh` on the TAuth origin; a fresh `app_session` cookie is minted and the original request is retried. diff --git a/docker-compose.yml b/docker-compose.yml index bb08595..ddfe31c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,12 +9,13 @@ services: condition: service_started gravity-tauth: condition: service_started - working_dir: /srv/gravity/frontend - command: ["--directory", "/srv/gravity/frontend", "8000"] + env_file: + - .env.ghttp volumes: - .:/srv/gravity:ro + - /media/share/Drive/exchange/certs/computercat:/certs:ro ports: - - "8000:8000" + - "4443:8443" restart: unless-stopped gravity-frontend-docker: @@ -27,12 +28,13 @@ services: condition: service_started gravity-tauth: condition: service_started - working_dir: /srv/gravity/frontend - command: ["--directory", "/srv/gravity/frontend", "8000"] + env_file: + - .env.ghttp volumes: - .:/srv/gravity:ro + - /media/share/Drive/exchange/certs/computercat:/certs:ro ports: - - "8000:8000" + - "4443:8443" restart: unless-stopped gravity-backend-dev: diff --git a/env.ghttp.example b/env.ghttp.example new file mode 100644 index 0000000..83247f6 --- /dev/null +++ b/env.ghttp.example @@ -0,0 +1,8 @@ +# gHTTP HTTPS + reverse proxy configuration for the Gravity dev stack. +# Copy to .env.ghttp and update certificate paths for your machine. +GHTTP_SERVE_DIRECTORY=/srv/gravity/frontend +GHTTP_SERVE_PORT=8443 +GHTTP_SERVE_LOGGING_TYPE=JSON +GHTTP_SERVE_TLS_CERTIFICATE=/certs/computercat.tyemirov.net.pem +GHTTP_SERVE_TLS_PRIVATE_KEY=/certs/computercat.tyemirov.net-key.pem +GHTTP_SERVE_PROXIES=/notes=http://gravity-backend:8080,/auth=http://gravity-tauth:8082,/api=http://gravity-tauth:8082 diff --git a/.env.gravity.example b/env.gravity.example similarity index 100% rename from .env.gravity.example rename to env.gravity.example diff --git a/env.tauth.example b/env.tauth.example new file mode 100644 index 0000000..a59ab90 --- /dev/null +++ b/env.tauth.example @@ -0,0 +1,22 @@ +# Gravity +TAUTH_TENANT_ID_GRAVITY=gravity +TAUTH_TENANT_DISPLAY_NAME_GRAVITY="Gravity Notes" +TAUTH_TENANT_ORIGIN_GRAVITY=https://computercat.tyemirov.net:4443 +TAUTH_TENANT_GOOGLE_WEB_CLIENT_ID_GRAVITY=qqq.apps.googleusercontent.com +TAUTH_TENANT_JWT_SIGNING_KEY_GRAVITY=qqq +TAUTH_TENANT_SESSION_COOKIE_NAME_GRAVITY=app_session_gravity +TAUTH_TENANT_REFRESH_COOKIE_NAME_GRAVITY=app_refresh_gravity + +TAUTH_COOKIE_DOMAIN=computercat.tyemirov.net + +# TAuth Server +TAUTH_CONFIG_FILE=/config/config.yml +TAUTH_LISTEN_ADDR=:8082 +TAUTH_DATABASE_URL=sqlite:///data/tauth.db +TAUTH_ENABLE_CORS=true +TAUTH_CORS_EXCEPTION_1=https://accounts.google.com +TAUTH_CORS_ORIGIN_1=${TAUTH_TENANT_ORIGIN_GRAVITY} +TAUTH_ENABLE_TENANT_HEADER_OVERRIDE=true + +# Shared +TAUTH_ALLOW_INSECURE_HTTP=false diff --git a/frontend/data/runtime.config.development.json b/frontend/data/runtime.config.development.json index a82fab9..d04eee1 100644 --- a/frontend/data/runtime.config.development.json +++ b/frontend/data/runtime.config.development.json @@ -1,8 +1,9 @@ { "environment": "development", - "backendBaseUrl": "http://localhost:8080", + "backendBaseUrl": "https://computercat.tyemirov.net:4443", "llmProxyUrl": "http://computercat:8081/v1/gravity/classify", - "authBaseUrl": "http://localhost:8082", + "authBaseUrl": "https://computercat.tyemirov.net:4443", + "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", "authTenantId": "gravity", "googleClientId": "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" } diff --git a/frontend/data/runtime.config.production.json b/frontend/data/runtime.config.production.json index 00beac9..8e201b2 100644 --- a/frontend/data/runtime.config.production.json +++ b/frontend/data/runtime.config.production.json @@ -3,6 +3,7 @@ "backendBaseUrl": "https://gravity-api.mprlab.com", "llmProxyUrl": "https://llm-proxy.mprlab.com/v1/gravity/classify", "authBaseUrl": "https://tauth-api.mprlab.com", + "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", "authTenantId": "gravity", "googleClientId": "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" } diff --git a/frontend/js/app.js b/frontend/js/app.js index 9a14944..2354d99 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -143,6 +143,7 @@ async function bootstrapApplication() { await GravityStore.initialize(); await ensureTAuthClientLoaded({ baseUrl: appConfig.authBaseUrl, + scriptUrl: appConfig.tauthScriptUrl, tenantId: appConfig.authTenantId }).catch((error) => { logging.error("TAuth client failed to load", error); diff --git a/frontend/js/core/config.js b/frontend/js/core/config.js index c451786..18fa028 100644 --- a/frontend/js/core/config.js +++ b/frontend/js/core/config.js @@ -11,6 +11,7 @@ const ERROR_MESSAGES = Object.freeze({ INVALID_BACKEND_BASE_URL: "app_config.invalid_backend_base_url", INVALID_LLM_PROXY_URL: "app_config.invalid_llm_proxy_url", INVALID_AUTH_BASE_URL: "app_config.invalid_auth_base_url", + INVALID_TAUTH_SCRIPT_URL: "app_config.invalid_tauth_script_url", INVALID_AUTH_TENANT_ID: "app_config.invalid_auth_tenant_id", INVALID_GOOGLE_CLIENT_ID: "app_config.invalid_google_client_id" }); @@ -35,6 +36,7 @@ export const STATIC_APP_CONFIG = Object.freeze({ * backendBaseUrl: string, * llmProxyUrl: string, * authBaseUrl: string, + * tauthScriptUrl: string, * authTenantId: string, * timezone: string, * classificationTimeoutMs: number, @@ -51,6 +53,7 @@ export const STATIC_APP_CONFIG = Object.freeze({ * backendBaseUrl?: string, * llmProxyUrl?: string, * authBaseUrl?: string, + * tauthScriptUrl?: string, * authTenantId?: string, * googleClientId: string * }} RuntimeConfigInput @@ -96,6 +99,14 @@ export function createAppConfig(config) { ERROR_MESSAGES.INVALID_AUTH_BASE_URL, hasAuthBaseUrl ); + const hasTauthScriptUrl = Object.prototype.hasOwnProperty.call(config, "tauthScriptUrl"); + const tauthScriptUrl = resolveConfigValue( + config.tauthScriptUrl, + environmentDefaults.tauthScriptUrl, + false, + ERROR_MESSAGES.INVALID_TAUTH_SCRIPT_URL, + hasTauthScriptUrl + ); const hasAuthTenantId = Object.prototype.hasOwnProperty.call(config, "authTenantId"); const authTenantId = resolveConfigValue( config.authTenantId, @@ -119,6 +130,7 @@ export function createAppConfig(config) { backendBaseUrl, llmProxyUrl, authBaseUrl, + tauthScriptUrl, authTenantId, googleClientId }); diff --git a/frontend/js/core/environmentConfig.js b/frontend/js/core/environmentConfig.js index 07e3dfe..35399c6 100644 --- a/frontend/js/core/environmentConfig.js +++ b/frontend/js/core/environmentConfig.js @@ -3,9 +3,10 @@ export const ENVIRONMENT_PRODUCTION = "production"; export const ENVIRONMENT_DEVELOPMENT = "development"; -export const DEVELOPMENT_BACKEND_BASE_URL = "http://localhost:8080"; +export const DEVELOPMENT_BACKEND_BASE_URL = "https://computercat.tyemirov.net:4443"; export const DEVELOPMENT_LLM_PROXY_URL = "http://computercat:8081/v1/gravity/classify"; -export const DEVELOPMENT_AUTH_BASE_URL = "http://localhost:8082"; +export const DEVELOPMENT_AUTH_BASE_URL = "https://computercat.tyemirov.net:4443"; +export const DEVELOPMENT_TAUTH_SCRIPT_URL = "https://tauth.mprlab.com/tauth.js"; export const DEVELOPMENT_AUTH_TENANT_ID = ""; // Production URLs are loaded from runtime.config.production.json - no hardcoded fallbacks @@ -13,6 +14,7 @@ export const PRODUCTION_ENVIRONMENT_CONFIG = Object.freeze({ backendBaseUrl: "", llmProxyUrl: "", authBaseUrl: "", + tauthScriptUrl: "", authTenantId: "" }); @@ -20,6 +22,7 @@ export const DEVELOPMENT_ENVIRONMENT_CONFIG = Object.freeze({ backendBaseUrl: DEVELOPMENT_BACKEND_BASE_URL, llmProxyUrl: DEVELOPMENT_LLM_PROXY_URL, authBaseUrl: DEVELOPMENT_AUTH_BASE_URL, + tauthScriptUrl: DEVELOPMENT_TAUTH_SCRIPT_URL, authTenantId: DEVELOPMENT_AUTH_TENANT_ID }); diff --git a/frontend/js/core/runtimeConfig.js b/frontend/js/core/runtimeConfig.js index c47b669..63b18c0 100644 --- a/frontend/js/core/runtimeConfig.js +++ b/frontend/js/core/runtimeConfig.js @@ -13,6 +13,7 @@ const RUNTIME_CONFIG_KEYS = Object.freeze({ BACKEND_BASE_URL: "backendBaseUrl", LLM_PROXY_URL: "llmProxyUrl", AUTH_BASE_URL: "authBaseUrl", + TAUTH_SCRIPT_URL: "tauthScriptUrl", AUTH_TENANT_ID: "authTenantId", GOOGLE_CLIENT_ID: "googleClientId" }); @@ -22,7 +23,7 @@ const TYPE_OBJECT = "object"; const TYPE_STRING = "string"; const TYPE_UNDEFINED = "undefined"; -const LOOPBACK_HOSTNAMES = Object.freeze(["localhost", "127.0.0.1", "[::1]"]); +const LOOPBACK_HOSTNAMES = Object.freeze(["localhost", "127.0.0.1", "[::1]", "::1"]); const DEVELOPMENT_TLDS = Object.freeze([".local", ".test"]); @@ -139,7 +140,7 @@ async function fetchRuntimeConfig(fetchImplementation, resource) { * Validate and project the runtime config payload into known keys. * @param {unknown} payload * @param {"production" | "development"} environment - * @returns {{ backendBaseUrl?: string, llmProxyUrl?: string, authBaseUrl?: string, authTenantId?: string, googleClientId: string }} + * @returns {{ backendBaseUrl?: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, authTenantId?: string, googleClientId: string }} */ function parseRuntimeConfigPayload(payload, environment) { if (!payload || typeof payload !== TYPE_OBJECT || Array.isArray(payload)) { @@ -185,6 +186,13 @@ function parseRuntimeConfigPayload(payload, environment) { } overrides.authBaseUrl = authBaseUrl; } + if (Object.prototype.hasOwnProperty.call(payload, RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL)) { + const tauthScriptUrl = /** @type {Record} */ (payload)[RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL]; + if (typeof tauthScriptUrl !== TYPE_STRING) { + throw new Error(ERROR_MESSAGES.INVALID_PAYLOAD); + } + overrides.tauthScriptUrl = tauthScriptUrl; + } if (Object.prototype.hasOwnProperty.call(payload, RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID)) { const authTenantId = /** @type {Record} */ (payload)[RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]; if (typeof authTenantId !== TYPE_STRING) { @@ -203,6 +211,75 @@ function parseRuntimeConfigPayload(payload, environment) { return overrides; } +/** + * @param {string} hostname + * @returns {boolean} + */ +function isLoopbackHostname(hostname) { + if (typeof hostname !== TYPE_STRING) { + return false; + } + const normalized = hostname.trim().toLowerCase(); + if (normalized.length === 0) { + return false; + } + return LOOPBACK_HOSTNAMES.includes(normalized); +} + +/** + * @param {string} urlValue + * @param {string} targetHostname + * @returns {string} + */ +function rewriteLoopbackUrl(urlValue, targetHostname) { + const parsedUrl = new URL(urlValue); + if (!isLoopbackHostname(parsedUrl.hostname)) { + return urlValue; + } + const keepTrailingSlash = urlValue.endsWith("/") || parsedUrl.pathname !== "/"; + parsedUrl.hostname = targetHostname; + const rewrittenUrl = parsedUrl.toString(); + if (!keepTrailingSlash && rewrittenUrl.endsWith("/")) { + return rewrittenUrl.slice(0, -1); + } + return rewrittenUrl; +} + +/** + * @param {import("./config.js").AppConfig} appConfig + * @param {Location|undefined} runtimeLocation + * @returns {import("./config.js").AppConfig} + */ +function applyRuntimeHostOverrides(appConfig, runtimeLocation) { + if (appConfig.environment !== ENVIRONMENT_LABELS.DEVELOPMENT) { + return appConfig; + } + if (!runtimeLocation || typeof runtimeLocation.hostname !== TYPE_STRING) { + return appConfig; + } + const runtimeHostname = runtimeLocation.hostname.trim().toLowerCase(); + if (!runtimeHostname || isLoopbackHostname(runtimeHostname)) { + return appConfig; + } + const backendBaseUrl = rewriteLoopbackUrl(appConfig.backendBaseUrl, runtimeHostname); + const authBaseUrl = rewriteLoopbackUrl(appConfig.authBaseUrl, runtimeHostname); + const llmProxyUrl = rewriteLoopbackUrl(appConfig.llmProxyUrl, runtimeHostname); + const tauthScriptUrl = rewriteLoopbackUrl(appConfig.tauthScriptUrl, runtimeHostname); + if (backendBaseUrl === appConfig.backendBaseUrl + && authBaseUrl === appConfig.authBaseUrl + && llmProxyUrl === appConfig.llmProxyUrl + && tauthScriptUrl === appConfig.tauthScriptUrl) { + return appConfig; + } + return Object.freeze({ + ...appConfig, + backendBaseUrl, + authBaseUrl, + llmProxyUrl, + tauthScriptUrl + }); +} + /** * Determine the environment from the current location. * @param {Location|undefined} runtimeLocation @@ -247,10 +324,11 @@ export async function initializeRuntimeConfig(options = {}) { } const payload = await response.json(); const overrides = parseRuntimeConfigPayload(payload, environment); - return createAppConfig({ + const appConfig = createAppConfig({ environment, ...overrides }); + return applyRuntimeHostOverrides(appConfig, location); } catch (error) { if (typeof options.onError === TYPE_FUNCTION) { options.onError(error); diff --git a/frontend/js/core/tauthClient.js b/frontend/js/core/tauthClient.js index aaffc8f..eb6182a 100644 --- a/frontend/js/core/tauthClient.js +++ b/frontend/js/core/tauthClient.js @@ -1,12 +1,12 @@ // @ts-check +import { APP_BUILD_ID } from "../constants.js?build=2026-01-01T22:43:21Z"; import { logging } from "../utils/logging.js?build=2026-01-01T22:43:21Z"; const SCRIPT_ELEMENT_ID = "gravity-tauth-client-script"; const SCRIPT_TAG_NAME = "script"; const SCRIPT_EVENT_LOAD = "load"; const SCRIPT_EVENT_ERROR = "error"; -const SCRIPT_TAUTH_PATH = "/tauth.js"; const DATA_TENANT_ATTRIBUTE = "data-tenant-id"; const TYPE_OBJECT = "object"; @@ -17,6 +17,7 @@ const ERROR_MESSAGES = Object.freeze({ MISSING_WINDOW: "tauth_client.missing_window", MISSING_DOCUMENT: "tauth_client.missing_document", MISSING_BASE_URL: "tauth_client.missing_base_url", + MISSING_SCRIPT_URL: "tauth_client.missing_script_url", INVALID_TENANT_ID: "tauth_client.invalid_tenant_id", LOAD_FAILED: "tauth-client-load-failed" }); @@ -28,7 +29,7 @@ const LOG_MESSAGES = Object.freeze({ /** * Ensure the TAuth tauth.js helper is loaded. Returns when the script * has been appended (or already present). - * @param {{ documentRef?: Document|null, baseUrl: string, tenantId?: string|null }} options + * @param {{ documentRef?: Document|null, baseUrl: string, scriptUrl: string, tenantId?: string|null }} options * @returns {Promise} */ export async function ensureTAuthClientLoaded(options) { @@ -49,15 +50,24 @@ export async function ensureTAuthClientLoaded(options) { if (typeof authBaseUrl !== TYPE_STRING || authBaseUrl.length === 0) { throw new Error(ERROR_MESSAGES.MISSING_BASE_URL); } + const scriptSource = options.scriptUrl; + if (typeof scriptSource !== TYPE_STRING || scriptSource.length === 0) { + throw new Error(ERROR_MESSAGES.MISSING_SCRIPT_URL); + } const tenantId = options.tenantId ?? null; if (tenantId !== null && tenantId !== undefined && typeof tenantId !== TYPE_STRING) { throw new Error(ERROR_MESSAGES.INVALID_TENANT_ID); } + const scriptUrl = new URL(scriptSource, authBaseUrl); + if (typeof APP_BUILD_ID === TYPE_STRING && APP_BUILD_ID.length > 0) { + scriptUrl.searchParams.set("build", APP_BUILD_ID); + } + const script = doc.createElement(SCRIPT_TAG_NAME); script.id = SCRIPT_ELEMENT_ID; script.defer = true; - script.src = authBaseUrl + SCRIPT_TAUTH_PATH; + script.src = scriptUrl.toString(); if (tenantId !== null && tenantId !== undefined) { script.setAttribute(DATA_TENANT_ATTRIBUTE, tenantId); } diff --git a/frontend/tests/auth.tauth.puppeteer.test.js b/frontend/tests/auth.tauth.puppeteer.test.js index 4f5f4ef..47ed50f 100644 --- a/frontend/tests/auth.tauth.puppeteer.test.js +++ b/frontend/tests/auth.tauth.puppeteer.test.js @@ -134,9 +134,11 @@ async function bootstrapTAuthEnvironment() { const browser = await connectSharedBrowser(); const context = await browser.createBrowserContext(); let tauthHarnessHandle = null; + const tauthScriptUrl = new URL("/tauth.js", backend.baseUrl).toString(); const page = await prepareFrontendPage(context, PAGE_URL, { backendBaseUrl: backend.baseUrl, authBaseUrl: backend.baseUrl, + tauthScriptUrl, beforeNavigate: async (targetPage) => { tauthHarnessHandle = await installTAuthHarness(targetPage, { baseUrl: backend.baseUrl, diff --git a/frontend/tests/config.runtime.test.js b/frontend/tests/config.runtime.test.js index 23cb9e7..31cc9e0 100644 --- a/frontend/tests/config.runtime.test.js +++ b/frontend/tests/config.runtime.test.js @@ -14,6 +14,7 @@ import { const BACKEND_URL_OVERRIDE = "https://api.example.com/v1/"; const LLM_PROXY_OVERRIDE = "http://localhost:5001/api/classify"; const AUTH_BASE_URL_OVERRIDE = "https://auth.example.com/service/"; +const TAUTH_SCRIPT_URL_OVERRIDE = "https://cdn.example.com/tauth.js"; const AUTH_TENANT_OVERRIDE = " gravity "; const DEFAULT_GOOGLE_CLIENT_ID = "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com"; const GOOGLE_CLIENT_ID_OVERRIDE = "custom-client.apps.googleusercontent.com"; @@ -24,6 +25,7 @@ const TEST_LABELS = Object.freeze({ BACKEND_OVERRIDE: "createAppConfig respects injected backendBaseUrl", LLM_OVERRIDE: "createAppConfig respects injected llmProxyUrl", AUTH_BASE_OVERRIDE: "createAppConfig respects injected authBaseUrl", + TAUTH_SCRIPT_OVERRIDE: "createAppConfig respects injected tauthScriptUrl", AUTH_TENANT_OVERRIDE: "createAppConfig preserves injected authTenantId", GOOGLE_CLIENT_ID_OVERRIDE: "createAppConfig preserves injected googleClientId" }); @@ -38,6 +40,7 @@ test(TEST_LABELS.DEVELOPMENT_DEFAULTS, () => { assert.equal(appConfig.backendBaseUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.backendBaseUrl); assert.equal(appConfig.llmProxyUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.llmProxyUrl); assert.equal(appConfig.authBaseUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.authBaseUrl); + assert.equal(appConfig.tauthScriptUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.tauthScriptUrl); assert.equal(appConfig.authTenantId, DEVELOPMENT_ENVIRONMENT_CONFIG.authTenantId); assert.equal(appConfig.googleClientId, DEFAULT_GOOGLE_CLIENT_ID); }); @@ -80,18 +83,30 @@ test(TEST_LABELS.AUTH_BASE_OVERRIDE, () => { environment: ENVIRONMENT_PRODUCTION, backendBaseUrl: BACKEND_URL_OVERRIDE, authBaseUrl: AUTH_BASE_URL_OVERRIDE, + tauthScriptUrl: TAUTH_SCRIPT_URL_OVERRIDE, googleClientId: DEFAULT_GOOGLE_CLIENT_ID }); assert.equal(appConfig.authBaseUrl, AUTH_BASE_URL_OVERRIDE); }); +test(TEST_LABELS.TAUTH_SCRIPT_OVERRIDE, () => { + const appConfig = createAppConfig({ + environment: ENVIRONMENT_DEVELOPMENT, + tauthScriptUrl: TAUTH_SCRIPT_URL_OVERRIDE, + googleClientId: DEFAULT_GOOGLE_CLIENT_ID + }); + + assert.equal(appConfig.tauthScriptUrl, TAUTH_SCRIPT_URL_OVERRIDE); +}); + test(TEST_LABELS.AUTH_TENANT_OVERRIDE, () => { // Production requires backendBaseUrl and authBaseUrl overrides as well const appConfig = createAppConfig({ environment: ENVIRONMENT_PRODUCTION, backendBaseUrl: BACKEND_URL_OVERRIDE, authBaseUrl: AUTH_BASE_URL_OVERRIDE, + tauthScriptUrl: TAUTH_SCRIPT_URL_OVERRIDE, authTenantId: AUTH_TENANT_OVERRIDE, googleClientId: DEFAULT_GOOGLE_CLIENT_ID }); diff --git a/frontend/tests/helpers/browserHarness.js b/frontend/tests/helpers/browserHarness.js index 5bef1f7..2c0c7de 100644 --- a/frontend/tests/helpers/browserHarness.js +++ b/frontend/tests/helpers/browserHarness.js @@ -89,6 +89,7 @@ const RUNTIME_CONFIG_KEYS = Object.freeze({ BACKEND_BASE_URL: "backendBaseUrl", LLM_PROXY_URL: "llmProxyUrl", AUTH_BASE_URL: "authBaseUrl", + TAUTH_SCRIPT_URL: "tauthScriptUrl", AUTH_TENANT_ID: "authTenantId", GOOGLE_CLIENT_ID: "googleClientId" }); @@ -96,6 +97,7 @@ const TEST_RUNTIME_CONFIG = Object.freeze({ backendBaseUrl: DEVELOPMENT_ENVIRONMENT_CONFIG.backendBaseUrl, llmProxyUrl: EMPTY_STRING, authBaseUrl: DEVELOPMENT_ENVIRONMENT_CONFIG.authBaseUrl, + tauthScriptUrl: DEVELOPMENT_ENVIRONMENT_CONFIG.tauthScriptUrl, authTenantId: DEFAULT_TEST_TENANT_ID, googleClientId: DEFAULT_GOOGLE_CLIENT_ID }); @@ -106,7 +108,7 @@ const REQUEST_HANDLERS_SYMBOL = Symbol("gravityRequestHandlers"); const REQUEST_INTERCEPTION_READY_SYMBOL = Symbol("gravityRequestInterceptionReady"); const REQUEST_HANDLER_REGISTRY_SYMBOL = Symbol("gravityRequestHandlerRegistry"); const TAUTH_STUB_NONCE = "tauth-stub-nonce"; -const TAUTH_SCRIPT_PATTERN = /\/tauth\.js$/u; +const TAUTH_SCRIPT_PATTERN = /\/tauth\.js(?:\?.*)?$/u; const TAUTH_STUB_KEYS = Object.freeze({ OPTIONS: "__tauthStubOptions", PROFILE: "__tauthStubProfile", @@ -415,6 +417,7 @@ export async function injectRuntimeConfig(page, overrides = {}) { [RUNTIME_CONFIG_KEYS.BACKEND_BASE_URL]: overridesByEnvironment.development.backendBaseUrl, [RUNTIME_CONFIG_KEYS.LLM_PROXY_URL]: overridesByEnvironment.development.llmProxyUrl, [RUNTIME_CONFIG_KEYS.AUTH_BASE_URL]: overridesByEnvironment.development.authBaseUrl, + [RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL]: overridesByEnvironment.development.tauthScriptUrl, [RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]: overridesByEnvironment.development.authTenantId, [RUNTIME_CONFIG_KEYS.GOOGLE_CLIENT_ID]: overridesByEnvironment.development.googleClientId }, @@ -423,6 +426,7 @@ export async function injectRuntimeConfig(page, overrides = {}) { [RUNTIME_CONFIG_KEYS.BACKEND_BASE_URL]: overridesByEnvironment.production.backendBaseUrl, [RUNTIME_CONFIG_KEYS.LLM_PROXY_URL]: overridesByEnvironment.production.llmProxyUrl, [RUNTIME_CONFIG_KEYS.AUTH_BASE_URL]: overridesByEnvironment.production.authBaseUrl, + [RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL]: overridesByEnvironment.production.tauthScriptUrl, [RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]: overridesByEnvironment.production.authTenantId, [RUNTIME_CONFIG_KEYS.GOOGLE_CLIENT_ID]: overridesByEnvironment.production.googleClientId } @@ -448,6 +452,7 @@ export async function injectRuntimeConfig(page, overrides = {}) { [RUNTIME_CONFIG_KEYS.BACKEND_BASE_URL]: resolvedOverrides.backendBaseUrl, [RUNTIME_CONFIG_KEYS.LLM_PROXY_URL]: resolvedOverrides.llmProxyUrl, [RUNTIME_CONFIG_KEYS.AUTH_BASE_URL]: resolvedOverrides.authBaseUrl, + [RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL]: resolvedOverrides.tauthScriptUrl, [RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]: resolvedOverrides.authTenantId, [RUNTIME_CONFIG_KEYS.GOOGLE_CLIENT_ID]: resolvedOverrides.googleClientId }); @@ -585,7 +590,7 @@ function isThenable(value) { /** * @param {Record} overrides * @param {"development" | "production"} environment - * @returns {{ backendBaseUrl: string, llmProxyUrl: string, authBaseUrl: string, authTenantId: string, googleClientId: string }} + * @returns {{ backendBaseUrl: string, llmProxyUrl: string, authBaseUrl: string, tauthScriptUrl: string, authTenantId: string, googleClientId: string }} */ function resolveRuntimeConfigOverrides(overrides, environment) { if (!overrides || typeof overrides !== "object") { @@ -597,6 +602,7 @@ function resolveRuntimeConfigOverrides(overrides, environment) { const backendBaseUrl = normalizeTestUrl(scoped?.backendBaseUrl ?? overrides.backendBaseUrl ?? TEST_RUNTIME_CONFIG.backendBaseUrl); const llmProxyUrl = normalizeTestUrl(scoped?.llmProxyUrl ?? overrides.llmProxyUrl ?? TEST_RUNTIME_CONFIG.llmProxyUrl, true); const authBaseUrl = normalizeTestUrl(scoped?.authBaseUrl ?? overrides.authBaseUrl ?? TEST_RUNTIME_CONFIG.authBaseUrl, true); + const tauthScriptUrl = normalizeTestUrl(scoped?.tauthScriptUrl ?? overrides.tauthScriptUrl ?? TEST_RUNTIME_CONFIG.tauthScriptUrl); const authTenantIdCandidate = scoped?.authTenantId ?? overrides.authTenantId ?? TEST_RUNTIME_CONFIG.authTenantId; const authTenantId = typeof authTenantIdCandidate === "string" ? authTenantIdCandidate @@ -605,7 +611,7 @@ function resolveRuntimeConfigOverrides(overrides, environment) { const googleClientId = typeof googleClientIdCandidate === "string" ? googleClientIdCandidate : TEST_RUNTIME_CONFIG.googleClientId; - return { backendBaseUrl, llmProxyUrl, authBaseUrl, authTenantId, googleClientId }; + return { backendBaseUrl, llmProxyUrl, authBaseUrl, tauthScriptUrl, authTenantId, googleClientId }; } /** diff --git a/frontend/tests/helpers/syncScenarioHarness.js b/frontend/tests/helpers/syncScenarioHarness.js index 3e3a294..5b963f5 100644 --- a/frontend/tests/helpers/syncScenarioHarness.js +++ b/frontend/tests/helpers/syncScenarioHarness.js @@ -68,10 +68,12 @@ export async function createSyncScenarioHarness(options = {}) { const context = sessionOptions.context ?? await browser.createBrowserContext(); const ownsContext = !sessionOptions.context; let harnessHandle = null; + const tauthScriptUrl = new URL("/tauth.js", backend.baseUrl).toString(); const page = await prepareFrontendPage(context, pageUrl, { backendBaseUrl: backend.baseUrl, llmProxyUrl: "", authBaseUrl: backend.baseUrl, + tauthScriptUrl, preserveLocalStorage: sessionOptions.preserveLocalStorage === true, beforeNavigate: async (targetPage) => { harnessHandle = await installTAuthHarness(targetPage, { diff --git a/frontend/tests/helpers/syncTestUtils.js b/frontend/tests/helpers/syncTestUtils.js index b14110a..0af3f96 100644 --- a/frontend/tests/helpers/syncTestUtils.js +++ b/frontend/tests/helpers/syncTestUtils.js @@ -40,6 +40,7 @@ const appConfig = createAppConfig({ const DEFAULT_JWT_AUDIENCE = appConfig.googleClientId; const EMPTY_STRING = ""; const DEVELOPMENT_AUTH_BASE_URL = DEVELOPMENT_ENVIRONMENT_CONFIG.authBaseUrl; +const DEVELOPMENT_TAUTH_SCRIPT_URL = DEVELOPMENT_ENVIRONMENT_CONFIG.tauthScriptUrl; const DEFAULT_AUTH_TENANT_ID = DEVELOPMENT_ENVIRONMENT_CONFIG.authTenantId || "gravity"; const TAUTH_PROFILE_STORAGE_KEY = "__gravityTestAuthProfile"; const STATIC_SERVER_HOST = "127.0.0.1"; @@ -82,7 +83,7 @@ export function buildUserStorageKey(userId) { * Prepare a new browser page configured for backend synchronization tests. * @param {import('puppeteer').Browser | import('puppeteer').BrowserContext} browser * @param {string} pageUrl - * @param {{ backendBaseUrl: string, llmProxyUrl?: string, authBaseUrl?: string, authTenantId?: string, googleClientId?: string, preserveLocalStorage?: boolean }} options + * @param {{ backendBaseUrl: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, authTenantId?: string, googleClientId?: string, preserveLocalStorage?: boolean }} options * @returns {Promise} */ export async function prepareFrontendPage(browser, pageUrl, options) { @@ -90,6 +91,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { backendBaseUrl, llmProxyUrl = EMPTY_STRING, authBaseUrl = DEVELOPMENT_AUTH_BASE_URL, + tauthScriptUrl = DEVELOPMENT_TAUTH_SCRIPT_URL, authTenantId = DEFAULT_AUTH_TENANT_ID, googleClientId = appConfig.googleClientId, beforeNavigate, @@ -136,6 +138,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { backendBaseUrl, llmProxyUrl, authBaseUrl, + tauthScriptUrl, authTenantId, googleClientId } @@ -155,6 +158,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { backendBaseUrl: config.backendBaseUrl, llmProxyUrl: config.llmProxyUrl, authBaseUrl: config.authBaseUrl, + tauthScriptUrl: config.tauthScriptUrl, authTenantId: config.authTenantId, googleClientId: config.googleClientId }; @@ -170,6 +174,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { backendBaseUrl, llmProxyUrl, authBaseUrl, + tauthScriptUrl, authTenantId, googleClientId }); @@ -255,6 +260,7 @@ export async function initializePuppeteerTest(pageUrl = DEFAULT_PAGE_URL, setupO backendBaseUrl: backend.baseUrl, llmProxyUrl: EMPTY_STRING, authBaseUrl: DEVELOPMENT_AUTH_BASE_URL, + tauthScriptUrl: DEVELOPMENT_TAUTH_SCRIPT_URL, authTenantId: DEFAULT_AUTH_TENANT_ID, googleClientId: appConfig.googleClientId } diff --git a/frontend/tests/persistence.backend.puppeteer.test.js b/frontend/tests/persistence.backend.puppeteer.test.js index 7d8dac0..753e9df 100644 --- a/frontend/tests/persistence.backend.puppeteer.test.js +++ b/frontend/tests/persistence.backend.puppeteer.test.js @@ -64,6 +64,7 @@ test.describe("Backend sync integration", () => { deadlineSignal.addEventListener("abort", abortHandler, { once: true }); const backendUrl = backendContext.baseUrl; + const tauthScriptUrl = new URL("/tauth.js", backendUrl).toString(); browserConnection = await connectSharedBrowser(); context = await browserConnection.createBrowserContext(); let harnessHandle = null; @@ -71,6 +72,7 @@ test.describe("Backend sync integration", () => { backendBaseUrl: backendUrl, llmProxyUrl: backendUrl, authBaseUrl: backendUrl, + tauthScriptUrl, beforeNavigate: async (targetPage) => { harnessHandle = await installTAuthHarness(targetPage, { baseUrl: backendUrl, diff --git a/frontend/tests/runtimeConfig.initialize.test.js b/frontend/tests/runtimeConfig.initialize.test.js index 979abae..43015c2 100644 --- a/frontend/tests/runtimeConfig.initialize.test.js +++ b/frontend/tests/runtimeConfig.initialize.test.js @@ -12,6 +12,7 @@ import { const TEST_LABELS = Object.freeze({ APPLIES_REMOTE_CONFIG: "initializeRuntimeConfig applies remote payload when fetch succeeds", + REWRITES_LOOPBACK_HOSTS: "initializeRuntimeConfig rewrites loopback endpoints for non-loopback dev hosts", RETRIES_TRANSIENT_FAILURES: "initializeRuntimeConfig retries transient failures before succeeding", HANDLES_HTTP_FAILURE: "initializeRuntimeConfig rejects HTTP failures", HANDLES_ABORT_FAILURE: "initializeRuntimeConfig normalizes abort failures into timeout errors" @@ -19,7 +20,8 @@ const TEST_LABELS = Object.freeze({ const HOSTNAMES = Object.freeze({ PRODUCTION: "gravity-notes.example.com", - DEVELOPMENT: "localhost" + DEVELOPMENT: "localhost", + REMOTE: "computercat.tyemirov.net" }); const RUNTIME_PATHS = Object.freeze({ @@ -36,6 +38,7 @@ const REMOTE_ENDPOINTS = Object.freeze({ BACKEND: "https://api.example.com/v1", LLM_PROXY: "https://llm.example.com/v1/classify", AUTH: "https://auth.example.com", + TAUTH_SCRIPT: "https://cdn.example.com/tauth.js", GOOGLE_CLIENT_ID: "test-client-id.apps.googleusercontent.com" }); @@ -74,6 +77,7 @@ test.describe(SUITE_LABELS.INITIALIZE_RUNTIME_CONFIG, () => { backendBaseUrl: REMOTE_ENDPOINTS.BACKEND, llmProxyUrl: REMOTE_ENDPOINTS.LLM_PROXY, authBaseUrl: REMOTE_ENDPOINTS.AUTH, + tauthScriptUrl: REMOTE_ENDPOINTS.TAUTH_SCRIPT, authTenantId: REMOTE_AUTH_TENANT_ID, googleClientId: REMOTE_ENDPOINTS.GOOGLE_CLIENT_ID }; @@ -99,11 +103,39 @@ test.describe(SUITE_LABELS.INITIALIZE_RUNTIME_CONFIG, () => { assert.equal(appConfig.backendBaseUrl, REMOTE_ENDPOINTS.BACKEND); assert.equal(appConfig.llmProxyUrl, REMOTE_ENDPOINTS.LLM_PROXY); assert.equal(appConfig.authBaseUrl, REMOTE_ENDPOINTS.AUTH); + assert.equal(appConfig.tauthScriptUrl, REMOTE_ENDPOINTS.TAUTH_SCRIPT); assert.equal(appConfig.authTenantId, REMOTE_AUTH_TENANT_ID); assert.equal(appConfig.googleClientId, REMOTE_ENDPOINTS.GOOGLE_CLIENT_ID); assert.equal(errorNotifications.length, 0); }); + test(TEST_LABELS.REWRITES_LOOPBACK_HOSTS, async () => { + const fetchStub = async () => ({ + ok: true, + status: 200, + async json() { + return { + environment: ENVIRONMENT_DEVELOPMENT, + backendBaseUrl: "http://localhost:8080", + llmProxyUrl: "http://localhost:8081/v1/classify", + authBaseUrl: "http://localhost:8082", + tauthScriptUrl: "http://localhost:8082/tauth.js", + googleClientId: REMOTE_ENDPOINTS.GOOGLE_CLIENT_ID + }; + } + }); + + const appConfig = await initializeRuntimeConfig({ + fetchImplementation: fetchStub, + location: { hostname: HOSTNAMES.REMOTE } + }); + + assert.equal(appConfig.backendBaseUrl, `http://${HOSTNAMES.REMOTE}:8080`); + assert.equal(appConfig.llmProxyUrl, `http://${HOSTNAMES.REMOTE}:8081/v1/classify`); + assert.equal(appConfig.authBaseUrl, `http://${HOSTNAMES.REMOTE}:8082`); + assert.equal(appConfig.tauthScriptUrl, `http://${HOSTNAMES.REMOTE}:8082/tauth.js`); + }); + test(TEST_LABELS.RETRIES_TRANSIENT_FAILURES, async () => { let attemptCount = 0; const fetchStub = async () => { diff --git a/frontend/tests/sync.endtoend.puppeteer.test.js b/frontend/tests/sync.endtoend.puppeteer.test.js index c207f5a..eddade6 100644 --- a/frontend/tests/sync.endtoend.puppeteer.test.js +++ b/frontend/tests/sync.endtoend.puppeteer.test.js @@ -66,10 +66,12 @@ test.describe("UI sync integration", () => { iterationSuffix = 1; } const userId = `ui-sync-user-${iterationSuffix}`; + const tauthScriptUrl = new URL("/tauth.js", backendContext.baseUrl).toString(); const page = await prepareFrontendPage(context, PAGE_URL, { backendBaseUrl: backendContext.baseUrl, llmProxyUrl: "", authBaseUrl: backendContext.baseUrl, + tauthScriptUrl, beforeNavigate: async (targetPage) => { await installTAuthHarness(targetPage, { baseUrl: backendContext.baseUrl, diff --git a/frontend/tests/sync.realtime.puppeteer.test.js b/frontend/tests/sync.realtime.puppeteer.test.js index 56377b4..57325be 100644 --- a/frontend/tests/sync.realtime.puppeteer.test.js +++ b/frontend/tests/sync.realtime.puppeteer.test.js @@ -208,10 +208,12 @@ test.describe("Realtime synchronization", () => { async function bootstrapRealtimeSession(context, backend, userId, options = {}) { const beforeAuth = typeof options?.beforeAuth === "function" ? options.beforeAuth : null; let harnessHandle = null; + const tauthScriptUrl = new URL("/tauth.js", backend.baseUrl).toString(); const page = await prepareFrontendPage(context, PAGE_URL, { backendBaseUrl: backend.baseUrl, llmProxyUrl: "", authBaseUrl: backend.baseUrl, + tauthScriptUrl, beforeNavigate: async (targetPage) => { harnessHandle = await installTAuthHarness(targetPage, { baseUrl: backend.baseUrl, From d02b41b0dfd9bad94767526d6e874c8fc9b64964 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 11:03:49 -0800 Subject: [PATCH 02/18] Load mpr-ui via runtime config --- ARCHITECTURE.md | 8 +++++--- CHANGELOG.md | 1 + ISSUES.md | 1 + README.md | 2 +- frontend/data/runtime.config.development.json | 1 + frontend/data/runtime.config.production.json | 1 + frontend/index.html | 5 ----- frontend/js/app.js | 6 ++++++ frontend/js/core/config.js | 12 ++++++++++++ frontend/js/core/environmentConfig.js | 3 +++ frontend/js/core/runtimeConfig.js | 17 ++++++++++++++--- frontend/tests/config.runtime.test.js | 16 ++++++++++++++++ frontend/tests/helpers/browserHarness.js | 10 ++++++++-- frontend/tests/helpers/syncTestUtils.js | 8 +++++++- frontend/tests/runtimeConfig.initialize.test.js | 5 +++++ 15 files changed, 81 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 53b734b..8f74011 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -76,7 +76,7 @@ EasyMDE produces markdown, marked renders it to HTML, and DOMPurify sanitises th - `GravityStore` persists notes in IndexedDB for offline-first behaviour; reconciliation applies backend snapshots. - `createNoteRecord` validates note identifiers/markdown before writes so malformed payloads never hit storage. - `GravityStore.setUserScope(userId)` switches the storage namespace so each Google account receives an isolated notebook. -- Runtime configuration loads from environment-specific JSON files under `data/`, selected according to the active hostname. Each profile now surfaces `authBaseUrl` (API origin) plus `tauthScriptUrl` (CDN host) so the frontend knows where to call `/auth/*` and where to load `tauth.js`. +- Runtime configuration loads from environment-specific JSON files under `data/`, selected according to the active hostname. Each profile now surfaces `authBaseUrl` (API origin), `tauthScriptUrl` (TAuth CDN host), and `mprUiScriptUrl` (mpr-ui CDN host) so the frontend knows where to call `/auth/*` and where to load `tauth.js` + the auth UI bundle. - Authentication flows through Google Identity Services + TAuth: the browser loads `tauth.js` from `tauthScriptUrl`, fetches a nonce from `/auth/nonce`, exchanges Google credentials at `/auth/google`, and refreshes the session via `/auth/refresh`. The frontend never sends Google tokens to the Gravity backend; every API request simply carries the `app_session` cookie minted by TAuth and validated locally via HS256. - The backend records a canonical user table (`user_identities`) so each `(provider, subject)` pair (for example `google:1234567890`) maps to a stable Gravity `user_id`. That allows multiple login providers to point at the same notebook without rewriting note rows. @@ -140,7 +140,8 @@ Profiles live under `frontend/data/runtime.config..json` and are se "environment": "development", "backendBaseUrl": "http://localhost:8080", "llmProxyUrl": "http://localhost:8081/v1/gravity/classify", - "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js" + "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", + "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js" } ``` @@ -150,7 +151,8 @@ Profiles live under `frontend/data/runtime.config..json` and are se "environment": "production", "backendBaseUrl": "https://gravity-api.mprlab.com", "llmProxyUrl": "https://llm-proxy.mprlab.com/v1/gravity/classify", - "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js" + "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", + "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js" } ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 72efd3e..9a554c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and are grouped by the date the work landed on `master`. - Centralized environment config defaults and reused them across runtime config and test harnesses (GN-427). - Runtime config now returns a frozen app config and callers pass it explicitly instead of shared globals (GN-427). - Environment example files now live at `env.*.example`, keeping `.env*` files untracked while preserving copy-ready templates (GN-443). +- mpr-ui now loads from a runtime-configured script URL (`mprUiScriptUrl`) after tauth.js so login components always register (GN-444). - Signed-out visitors now see a landing page with a Google sign-in button; the Gravity interface requires authentication (GN-126). - mpr-ui now loads from a static script tag and auth components mount after runtime config so attributes are applied before initialization (GN-436). - Frontend now pulls mpr-ui assets from the `@latest` CDN tag so releases stay aligned (GN-437). diff --git a/ISSUES.md b/ISSUES.md index 43412dc..fb90135 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -173,6 +173,7 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [x] [GN-441] (P1) Dev auth fails on computercat.tyemirov.net because gHTTP still serves only the frontend and runtime config points to localhost; proxy backend/TAuth through gHTTP HTTPS and update dev config/env defaults. (Resolved by adding gHTTP HTTPS env config + proxy routes, updating compose and runtime config defaults for computercat, and documenting the new dev stack.) - [ ] [GN-442] (P1) Load tauth.js from the CDN (not via gHTTP), remove the /tauth.js proxy route, and wire the runtime config/test harness to use a dedicated tauthScriptUrl for the helper. - [x] [GN-443] (P1) Keep `.env*` files untracked and rename example env files to `env.*.example` with updated setup docs. +- [x] [GN-444] (P1) Ensure the mpr-ui login component always registers by loading the bundle from a runtime-configured `mprUiScriptUrl` after tauth.js. ## Maintenance (428–499) diff --git a/README.md b/README.md index 12969e0..bcb2e5e 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ The compose file exposes: - Gravity backend API at `http://localhost:8080` (container port published for direct access) - TAuth (nonce + Google exchange) at `http://localhost:8082` (container port published for direct access) -Runtime configuration files under `frontend/data/` include `authBaseUrl` and `tauthScriptUrl`, so the browser can discover which TAuth origin to contact for `/auth/nonce`, `/auth/google`, and `/auth/logout` and which CDN host serves `tauth.js`. Update `frontend/data/runtime.config.production.json` if your deployment uses a different TAuth hostname or script CDN, and update `frontend/data/runtime.config.development.json` if you run dev on a different HTTPS origin. +Runtime configuration files under `frontend/data/` include `authBaseUrl`, `tauthScriptUrl`, and `mprUiScriptUrl`, so the browser can discover which TAuth origin to contact for `/auth/nonce`, `/auth/google`, and `/auth/logout`, which CDN host serves `tauth.js`, and which CDN host serves the `mpr-ui` bundle. Update `frontend/data/runtime.config.production.json` if your deployment uses different auth or CDN hosts, and update `frontend/data/runtime.config.development.json` if you run dev on a different HTTPS origin. ### Authentication Contract diff --git a/frontend/data/runtime.config.development.json b/frontend/data/runtime.config.development.json index d04eee1..d2f3450 100644 --- a/frontend/data/runtime.config.development.json +++ b/frontend/data/runtime.config.development.json @@ -4,6 +4,7 @@ "llmProxyUrl": "http://computercat:8081/v1/gravity/classify", "authBaseUrl": "https://computercat.tyemirov.net:4443", "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", + "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js", "authTenantId": "gravity", "googleClientId": "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" } diff --git a/frontend/data/runtime.config.production.json b/frontend/data/runtime.config.production.json index 8e201b2..0bc10bc 100644 --- a/frontend/data/runtime.config.production.json +++ b/frontend/data/runtime.config.production.json @@ -4,6 +4,7 @@ "llmProxyUrl": "https://llm-proxy.mprlab.com/v1/gravity/classify", "authBaseUrl": "https://tauth-api.mprlab.com", "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", + "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js", "authTenantId": "gravity", "googleClientId": "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" } diff --git a/frontend/index.html b/frontend/index.html index 3449a9e..caeed34 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,11 +33,6 @@ -
diff --git a/frontend/js/app.js b/frontend/js/app.js index 2354d99..b65bc14 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -12,6 +12,7 @@ import { initializeAnalytics } from "./core/analytics.js?build=2026-01-01T22:43: import { createSyncManager } from "./core/syncManager.js?build=2026-01-01T22:43:21Z"; import { createRealtimeSyncController } from "./core/realtimeSyncController.js?build=2026-01-01T22:43:21Z"; import { ensureTAuthClientLoaded } from "./core/tauthClient.js?build=2026-01-01T22:43:21Z"; +import { ensureMprUiLoaded } from "./core/mprUiClient.js?build=2026-01-01T22:43:21Z"; import { mountTopEditor } from "./ui/topEditor.js?build=2026-01-01T22:43:21Z"; import { LABEL_APP_SUBTITLE, @@ -148,6 +149,11 @@ async function bootstrapApplication() { }).catch((error) => { logging.error("TAuth client failed to load", error); }); + await ensureMprUiLoaded({ + scriptUrl: appConfig.mprUiScriptUrl + }).catch((error) => { + logging.error("mpr-ui failed to load", error); + }); configureAuthElements(appConfig); initializeAnalytics({ config: appConfig }); document.addEventListener("alpine:init", () => { diff --git a/frontend/js/core/config.js b/frontend/js/core/config.js index 18fa028..a5e7ef5 100644 --- a/frontend/js/core/config.js +++ b/frontend/js/core/config.js @@ -12,6 +12,7 @@ const ERROR_MESSAGES = Object.freeze({ INVALID_LLM_PROXY_URL: "app_config.invalid_llm_proxy_url", INVALID_AUTH_BASE_URL: "app_config.invalid_auth_base_url", INVALID_TAUTH_SCRIPT_URL: "app_config.invalid_tauth_script_url", + INVALID_MPR_UI_SCRIPT_URL: "app_config.invalid_mpr_ui_script_url", INVALID_AUTH_TENANT_ID: "app_config.invalid_auth_tenant_id", INVALID_GOOGLE_CLIENT_ID: "app_config.invalid_google_client_id" }); @@ -37,6 +38,7 @@ export const STATIC_APP_CONFIG = Object.freeze({ * llmProxyUrl: string, * authBaseUrl: string, * tauthScriptUrl: string, + * mprUiScriptUrl: string, * authTenantId: string, * timezone: string, * classificationTimeoutMs: number, @@ -54,6 +56,7 @@ export const STATIC_APP_CONFIG = Object.freeze({ * llmProxyUrl?: string, * authBaseUrl?: string, * tauthScriptUrl?: string, + * mprUiScriptUrl?: string, * authTenantId?: string, * googleClientId: string * }} RuntimeConfigInput @@ -107,6 +110,14 @@ export function createAppConfig(config) { ERROR_MESSAGES.INVALID_TAUTH_SCRIPT_URL, hasTauthScriptUrl ); + const hasMprUiScriptUrl = Object.prototype.hasOwnProperty.call(config, "mprUiScriptUrl"); + const mprUiScriptUrl = resolveConfigValue( + config.mprUiScriptUrl, + environmentDefaults.mprUiScriptUrl, + false, + ERROR_MESSAGES.INVALID_MPR_UI_SCRIPT_URL, + hasMprUiScriptUrl + ); const hasAuthTenantId = Object.prototype.hasOwnProperty.call(config, "authTenantId"); const authTenantId = resolveConfigValue( config.authTenantId, @@ -131,6 +142,7 @@ export function createAppConfig(config) { llmProxyUrl, authBaseUrl, tauthScriptUrl, + mprUiScriptUrl, authTenantId, googleClientId }); diff --git a/frontend/js/core/environmentConfig.js b/frontend/js/core/environmentConfig.js index 35399c6..47282ff 100644 --- a/frontend/js/core/environmentConfig.js +++ b/frontend/js/core/environmentConfig.js @@ -7,6 +7,7 @@ export const DEVELOPMENT_BACKEND_BASE_URL = "https://computercat.tyemirov.net:44 export const DEVELOPMENT_LLM_PROXY_URL = "http://computercat:8081/v1/gravity/classify"; export const DEVELOPMENT_AUTH_BASE_URL = "https://computercat.tyemirov.net:4443"; export const DEVELOPMENT_TAUTH_SCRIPT_URL = "https://tauth.mprlab.com/tauth.js"; +export const DEVELOPMENT_MPR_UI_SCRIPT_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js"; export const DEVELOPMENT_AUTH_TENANT_ID = ""; // Production URLs are loaded from runtime.config.production.json - no hardcoded fallbacks @@ -15,6 +16,7 @@ export const PRODUCTION_ENVIRONMENT_CONFIG = Object.freeze({ llmProxyUrl: "", authBaseUrl: "", tauthScriptUrl: "", + mprUiScriptUrl: "", authTenantId: "" }); @@ -23,6 +25,7 @@ export const DEVELOPMENT_ENVIRONMENT_CONFIG = Object.freeze({ llmProxyUrl: DEVELOPMENT_LLM_PROXY_URL, authBaseUrl: DEVELOPMENT_AUTH_BASE_URL, tauthScriptUrl: DEVELOPMENT_TAUTH_SCRIPT_URL, + mprUiScriptUrl: DEVELOPMENT_MPR_UI_SCRIPT_URL, authTenantId: DEVELOPMENT_AUTH_TENANT_ID }); diff --git a/frontend/js/core/runtimeConfig.js b/frontend/js/core/runtimeConfig.js index 63b18c0..daa11c8 100644 --- a/frontend/js/core/runtimeConfig.js +++ b/frontend/js/core/runtimeConfig.js @@ -14,6 +14,7 @@ const RUNTIME_CONFIG_KEYS = Object.freeze({ LLM_PROXY_URL: "llmProxyUrl", AUTH_BASE_URL: "authBaseUrl", TAUTH_SCRIPT_URL: "tauthScriptUrl", + MPR_UI_SCRIPT_URL: "mprUiScriptUrl", AUTH_TENANT_ID: "authTenantId", GOOGLE_CLIENT_ID: "googleClientId" }); @@ -140,7 +141,7 @@ async function fetchRuntimeConfig(fetchImplementation, resource) { * Validate and project the runtime config payload into known keys. * @param {unknown} payload * @param {"production" | "development"} environment - * @returns {{ backendBaseUrl?: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, authTenantId?: string, googleClientId: string }} + * @returns {{ backendBaseUrl?: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, mprUiScriptUrl?: string, authTenantId?: string, googleClientId: string }} */ function parseRuntimeConfigPayload(payload, environment) { if (!payload || typeof payload !== TYPE_OBJECT || Array.isArray(payload)) { @@ -193,6 +194,13 @@ function parseRuntimeConfigPayload(payload, environment) { } overrides.tauthScriptUrl = tauthScriptUrl; } + if (Object.prototype.hasOwnProperty.call(payload, RUNTIME_CONFIG_KEYS.MPR_UI_SCRIPT_URL)) { + const mprUiScriptUrl = /** @type {Record} */ (payload)[RUNTIME_CONFIG_KEYS.MPR_UI_SCRIPT_URL]; + if (typeof mprUiScriptUrl !== TYPE_STRING) { + throw new Error(ERROR_MESSAGES.INVALID_PAYLOAD); + } + overrides.mprUiScriptUrl = mprUiScriptUrl; + } if (Object.prototype.hasOwnProperty.call(payload, RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID)) { const authTenantId = /** @type {Record} */ (payload)[RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]; if (typeof authTenantId !== TYPE_STRING) { @@ -265,10 +273,12 @@ function applyRuntimeHostOverrides(appConfig, runtimeLocation) { const authBaseUrl = rewriteLoopbackUrl(appConfig.authBaseUrl, runtimeHostname); const llmProxyUrl = rewriteLoopbackUrl(appConfig.llmProxyUrl, runtimeHostname); const tauthScriptUrl = rewriteLoopbackUrl(appConfig.tauthScriptUrl, runtimeHostname); + const mprUiScriptUrl = rewriteLoopbackUrl(appConfig.mprUiScriptUrl, runtimeHostname); if (backendBaseUrl === appConfig.backendBaseUrl && authBaseUrl === appConfig.authBaseUrl && llmProxyUrl === appConfig.llmProxyUrl - && tauthScriptUrl === appConfig.tauthScriptUrl) { + && tauthScriptUrl === appConfig.tauthScriptUrl + && mprUiScriptUrl === appConfig.mprUiScriptUrl) { return appConfig; } return Object.freeze({ @@ -276,7 +286,8 @@ function applyRuntimeHostOverrides(appConfig, runtimeLocation) { backendBaseUrl, authBaseUrl, llmProxyUrl, - tauthScriptUrl + tauthScriptUrl, + mprUiScriptUrl }); } diff --git a/frontend/tests/config.runtime.test.js b/frontend/tests/config.runtime.test.js index 31cc9e0..a98332e 100644 --- a/frontend/tests/config.runtime.test.js +++ b/frontend/tests/config.runtime.test.js @@ -15,6 +15,7 @@ const BACKEND_URL_OVERRIDE = "https://api.example.com/v1/"; const LLM_PROXY_OVERRIDE = "http://localhost:5001/api/classify"; const AUTH_BASE_URL_OVERRIDE = "https://auth.example.com/service/"; const TAUTH_SCRIPT_URL_OVERRIDE = "https://cdn.example.com/tauth.js"; +const MPR_UI_SCRIPT_URL_OVERRIDE = "https://cdn.example.com/mpr-ui.js"; const AUTH_TENANT_OVERRIDE = " gravity "; const DEFAULT_GOOGLE_CLIENT_ID = "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com"; const GOOGLE_CLIENT_ID_OVERRIDE = "custom-client.apps.googleusercontent.com"; @@ -26,6 +27,7 @@ const TEST_LABELS = Object.freeze({ LLM_OVERRIDE: "createAppConfig respects injected llmProxyUrl", AUTH_BASE_OVERRIDE: "createAppConfig respects injected authBaseUrl", TAUTH_SCRIPT_OVERRIDE: "createAppConfig respects injected tauthScriptUrl", + MPR_UI_SCRIPT_OVERRIDE: "createAppConfig respects injected mprUiScriptUrl", AUTH_TENANT_OVERRIDE: "createAppConfig preserves injected authTenantId", GOOGLE_CLIENT_ID_OVERRIDE: "createAppConfig preserves injected googleClientId" }); @@ -41,6 +43,7 @@ test(TEST_LABELS.DEVELOPMENT_DEFAULTS, () => { assert.equal(appConfig.llmProxyUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.llmProxyUrl); assert.equal(appConfig.authBaseUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.authBaseUrl); assert.equal(appConfig.tauthScriptUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.tauthScriptUrl); + assert.equal(appConfig.mprUiScriptUrl, DEVELOPMENT_ENVIRONMENT_CONFIG.mprUiScriptUrl); assert.equal(appConfig.authTenantId, DEVELOPMENT_ENVIRONMENT_CONFIG.authTenantId); assert.equal(appConfig.googleClientId, DEFAULT_GOOGLE_CLIENT_ID); }); @@ -84,6 +87,7 @@ test(TEST_LABELS.AUTH_BASE_OVERRIDE, () => { backendBaseUrl: BACKEND_URL_OVERRIDE, authBaseUrl: AUTH_BASE_URL_OVERRIDE, tauthScriptUrl: TAUTH_SCRIPT_URL_OVERRIDE, + mprUiScriptUrl: MPR_UI_SCRIPT_URL_OVERRIDE, googleClientId: DEFAULT_GOOGLE_CLIENT_ID }); @@ -94,12 +98,23 @@ test(TEST_LABELS.TAUTH_SCRIPT_OVERRIDE, () => { const appConfig = createAppConfig({ environment: ENVIRONMENT_DEVELOPMENT, tauthScriptUrl: TAUTH_SCRIPT_URL_OVERRIDE, + mprUiScriptUrl: MPR_UI_SCRIPT_URL_OVERRIDE, googleClientId: DEFAULT_GOOGLE_CLIENT_ID }); assert.equal(appConfig.tauthScriptUrl, TAUTH_SCRIPT_URL_OVERRIDE); }); +test(TEST_LABELS.MPR_UI_SCRIPT_OVERRIDE, () => { + const appConfig = createAppConfig({ + environment: ENVIRONMENT_DEVELOPMENT, + mprUiScriptUrl: MPR_UI_SCRIPT_URL_OVERRIDE, + googleClientId: DEFAULT_GOOGLE_CLIENT_ID + }); + + assert.equal(appConfig.mprUiScriptUrl, MPR_UI_SCRIPT_URL_OVERRIDE); +}); + test(TEST_LABELS.AUTH_TENANT_OVERRIDE, () => { // Production requires backendBaseUrl and authBaseUrl overrides as well const appConfig = createAppConfig({ @@ -107,6 +122,7 @@ test(TEST_LABELS.AUTH_TENANT_OVERRIDE, () => { backendBaseUrl: BACKEND_URL_OVERRIDE, authBaseUrl: AUTH_BASE_URL_OVERRIDE, tauthScriptUrl: TAUTH_SCRIPT_URL_OVERRIDE, + mprUiScriptUrl: MPR_UI_SCRIPT_URL_OVERRIDE, authTenantId: AUTH_TENANT_OVERRIDE, googleClientId: DEFAULT_GOOGLE_CLIENT_ID }); diff --git a/frontend/tests/helpers/browserHarness.js b/frontend/tests/helpers/browserHarness.js index 2c0c7de..2a27132 100644 --- a/frontend/tests/helpers/browserHarness.js +++ b/frontend/tests/helpers/browserHarness.js @@ -90,6 +90,7 @@ const RUNTIME_CONFIG_KEYS = Object.freeze({ LLM_PROXY_URL: "llmProxyUrl", AUTH_BASE_URL: "authBaseUrl", TAUTH_SCRIPT_URL: "tauthScriptUrl", + MPR_UI_SCRIPT_URL: "mprUiScriptUrl", AUTH_TENANT_ID: "authTenantId", GOOGLE_CLIENT_ID: "googleClientId" }); @@ -98,6 +99,7 @@ const TEST_RUNTIME_CONFIG = Object.freeze({ llmProxyUrl: EMPTY_STRING, authBaseUrl: DEVELOPMENT_ENVIRONMENT_CONFIG.authBaseUrl, tauthScriptUrl: DEVELOPMENT_ENVIRONMENT_CONFIG.tauthScriptUrl, + mprUiScriptUrl: DEVELOPMENT_ENVIRONMENT_CONFIG.mprUiScriptUrl, authTenantId: DEFAULT_TEST_TENANT_ID, googleClientId: DEFAULT_GOOGLE_CLIENT_ID }); @@ -418,6 +420,7 @@ export async function injectRuntimeConfig(page, overrides = {}) { [RUNTIME_CONFIG_KEYS.LLM_PROXY_URL]: overridesByEnvironment.development.llmProxyUrl, [RUNTIME_CONFIG_KEYS.AUTH_BASE_URL]: overridesByEnvironment.development.authBaseUrl, [RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL]: overridesByEnvironment.development.tauthScriptUrl, + [RUNTIME_CONFIG_KEYS.MPR_UI_SCRIPT_URL]: overridesByEnvironment.development.mprUiScriptUrl, [RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]: overridesByEnvironment.development.authTenantId, [RUNTIME_CONFIG_KEYS.GOOGLE_CLIENT_ID]: overridesByEnvironment.development.googleClientId }, @@ -427,6 +430,7 @@ export async function injectRuntimeConfig(page, overrides = {}) { [RUNTIME_CONFIG_KEYS.LLM_PROXY_URL]: overridesByEnvironment.production.llmProxyUrl, [RUNTIME_CONFIG_KEYS.AUTH_BASE_URL]: overridesByEnvironment.production.authBaseUrl, [RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL]: overridesByEnvironment.production.tauthScriptUrl, + [RUNTIME_CONFIG_KEYS.MPR_UI_SCRIPT_URL]: overridesByEnvironment.production.mprUiScriptUrl, [RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]: overridesByEnvironment.production.authTenantId, [RUNTIME_CONFIG_KEYS.GOOGLE_CLIENT_ID]: overridesByEnvironment.production.googleClientId } @@ -453,6 +457,7 @@ export async function injectRuntimeConfig(page, overrides = {}) { [RUNTIME_CONFIG_KEYS.LLM_PROXY_URL]: resolvedOverrides.llmProxyUrl, [RUNTIME_CONFIG_KEYS.AUTH_BASE_URL]: resolvedOverrides.authBaseUrl, [RUNTIME_CONFIG_KEYS.TAUTH_SCRIPT_URL]: resolvedOverrides.tauthScriptUrl, + [RUNTIME_CONFIG_KEYS.MPR_UI_SCRIPT_URL]: resolvedOverrides.mprUiScriptUrl, [RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]: resolvedOverrides.authTenantId, [RUNTIME_CONFIG_KEYS.GOOGLE_CLIENT_ID]: resolvedOverrides.googleClientId }); @@ -590,7 +595,7 @@ function isThenable(value) { /** * @param {Record} overrides * @param {"development" | "production"} environment - * @returns {{ backendBaseUrl: string, llmProxyUrl: string, authBaseUrl: string, tauthScriptUrl: string, authTenantId: string, googleClientId: string }} + * @returns {{ backendBaseUrl: string, llmProxyUrl: string, authBaseUrl: string, tauthScriptUrl: string, mprUiScriptUrl: string, authTenantId: string, googleClientId: string }} */ function resolveRuntimeConfigOverrides(overrides, environment) { if (!overrides || typeof overrides !== "object") { @@ -603,6 +608,7 @@ function resolveRuntimeConfigOverrides(overrides, environment) { const llmProxyUrl = normalizeTestUrl(scoped?.llmProxyUrl ?? overrides.llmProxyUrl ?? TEST_RUNTIME_CONFIG.llmProxyUrl, true); const authBaseUrl = normalizeTestUrl(scoped?.authBaseUrl ?? overrides.authBaseUrl ?? TEST_RUNTIME_CONFIG.authBaseUrl, true); const tauthScriptUrl = normalizeTestUrl(scoped?.tauthScriptUrl ?? overrides.tauthScriptUrl ?? TEST_RUNTIME_CONFIG.tauthScriptUrl); + const mprUiScriptUrl = normalizeTestUrl(scoped?.mprUiScriptUrl ?? overrides.mprUiScriptUrl ?? TEST_RUNTIME_CONFIG.mprUiScriptUrl); const authTenantIdCandidate = scoped?.authTenantId ?? overrides.authTenantId ?? TEST_RUNTIME_CONFIG.authTenantId; const authTenantId = typeof authTenantIdCandidate === "string" ? authTenantIdCandidate @@ -611,7 +617,7 @@ function resolveRuntimeConfigOverrides(overrides, environment) { const googleClientId = typeof googleClientIdCandidate === "string" ? googleClientIdCandidate : TEST_RUNTIME_CONFIG.googleClientId; - return { backendBaseUrl, llmProxyUrl, authBaseUrl, tauthScriptUrl, authTenantId, googleClientId }; + return { backendBaseUrl, llmProxyUrl, authBaseUrl, tauthScriptUrl, mprUiScriptUrl, authTenantId, googleClientId }; } /** diff --git a/frontend/tests/helpers/syncTestUtils.js b/frontend/tests/helpers/syncTestUtils.js index 0af3f96..c74b510 100644 --- a/frontend/tests/helpers/syncTestUtils.js +++ b/frontend/tests/helpers/syncTestUtils.js @@ -41,6 +41,7 @@ const DEFAULT_JWT_AUDIENCE = appConfig.googleClientId; const EMPTY_STRING = ""; const DEVELOPMENT_AUTH_BASE_URL = DEVELOPMENT_ENVIRONMENT_CONFIG.authBaseUrl; const DEVELOPMENT_TAUTH_SCRIPT_URL = DEVELOPMENT_ENVIRONMENT_CONFIG.tauthScriptUrl; +const DEVELOPMENT_MPR_UI_SCRIPT_URL = DEVELOPMENT_ENVIRONMENT_CONFIG.mprUiScriptUrl; const DEFAULT_AUTH_TENANT_ID = DEVELOPMENT_ENVIRONMENT_CONFIG.authTenantId || "gravity"; const TAUTH_PROFILE_STORAGE_KEY = "__gravityTestAuthProfile"; const STATIC_SERVER_HOST = "127.0.0.1"; @@ -83,7 +84,7 @@ export function buildUserStorageKey(userId) { * Prepare a new browser page configured for backend synchronization tests. * @param {import('puppeteer').Browser | import('puppeteer').BrowserContext} browser * @param {string} pageUrl - * @param {{ backendBaseUrl: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, authTenantId?: string, googleClientId?: string, preserveLocalStorage?: boolean }} options + * @param {{ backendBaseUrl: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, mprUiScriptUrl?: string, authTenantId?: string, googleClientId?: string, preserveLocalStorage?: boolean }} options * @returns {Promise} */ export async function prepareFrontendPage(browser, pageUrl, options) { @@ -92,6 +93,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { llmProxyUrl = EMPTY_STRING, authBaseUrl = DEVELOPMENT_AUTH_BASE_URL, tauthScriptUrl = DEVELOPMENT_TAUTH_SCRIPT_URL, + mprUiScriptUrl = DEVELOPMENT_MPR_UI_SCRIPT_URL, authTenantId = DEFAULT_AUTH_TENANT_ID, googleClientId = appConfig.googleClientId, beforeNavigate, @@ -139,6 +141,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { llmProxyUrl, authBaseUrl, tauthScriptUrl, + mprUiScriptUrl, authTenantId, googleClientId } @@ -159,6 +162,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { llmProxyUrl: config.llmProxyUrl, authBaseUrl: config.authBaseUrl, tauthScriptUrl: config.tauthScriptUrl, + mprUiScriptUrl: config.mprUiScriptUrl, authTenantId: config.authTenantId, googleClientId: config.googleClientId }; @@ -175,6 +179,7 @@ export async function prepareFrontendPage(browser, pageUrl, options) { llmProxyUrl, authBaseUrl, tauthScriptUrl, + mprUiScriptUrl, authTenantId, googleClientId }); @@ -261,6 +266,7 @@ export async function initializePuppeteerTest(pageUrl = DEFAULT_PAGE_URL, setupO llmProxyUrl: EMPTY_STRING, authBaseUrl: DEVELOPMENT_AUTH_BASE_URL, tauthScriptUrl: DEVELOPMENT_TAUTH_SCRIPT_URL, + mprUiScriptUrl: DEVELOPMENT_MPR_UI_SCRIPT_URL, authTenantId: DEFAULT_AUTH_TENANT_ID, googleClientId: appConfig.googleClientId } diff --git a/frontend/tests/runtimeConfig.initialize.test.js b/frontend/tests/runtimeConfig.initialize.test.js index 43015c2..82e7f99 100644 --- a/frontend/tests/runtimeConfig.initialize.test.js +++ b/frontend/tests/runtimeConfig.initialize.test.js @@ -39,6 +39,7 @@ const REMOTE_ENDPOINTS = Object.freeze({ LLM_PROXY: "https://llm.example.com/v1/classify", AUTH: "https://auth.example.com", TAUTH_SCRIPT: "https://cdn.example.com/tauth.js", + MPR_UI_SCRIPT: "https://cdn.example.com/mpr-ui.js", GOOGLE_CLIENT_ID: "test-client-id.apps.googleusercontent.com" }); @@ -78,6 +79,7 @@ test.describe(SUITE_LABELS.INITIALIZE_RUNTIME_CONFIG, () => { llmProxyUrl: REMOTE_ENDPOINTS.LLM_PROXY, authBaseUrl: REMOTE_ENDPOINTS.AUTH, tauthScriptUrl: REMOTE_ENDPOINTS.TAUTH_SCRIPT, + mprUiScriptUrl: REMOTE_ENDPOINTS.MPR_UI_SCRIPT, authTenantId: REMOTE_AUTH_TENANT_ID, googleClientId: REMOTE_ENDPOINTS.GOOGLE_CLIENT_ID }; @@ -104,6 +106,7 @@ test.describe(SUITE_LABELS.INITIALIZE_RUNTIME_CONFIG, () => { assert.equal(appConfig.llmProxyUrl, REMOTE_ENDPOINTS.LLM_PROXY); assert.equal(appConfig.authBaseUrl, REMOTE_ENDPOINTS.AUTH); assert.equal(appConfig.tauthScriptUrl, REMOTE_ENDPOINTS.TAUTH_SCRIPT); + assert.equal(appConfig.mprUiScriptUrl, REMOTE_ENDPOINTS.MPR_UI_SCRIPT); assert.equal(appConfig.authTenantId, REMOTE_AUTH_TENANT_ID); assert.equal(appConfig.googleClientId, REMOTE_ENDPOINTS.GOOGLE_CLIENT_ID); assert.equal(errorNotifications.length, 0); @@ -120,6 +123,7 @@ test.describe(SUITE_LABELS.INITIALIZE_RUNTIME_CONFIG, () => { llmProxyUrl: "http://localhost:8081/v1/classify", authBaseUrl: "http://localhost:8082", tauthScriptUrl: "http://localhost:8082/tauth.js", + mprUiScriptUrl: "http://localhost:8083/mpr-ui.js", googleClientId: REMOTE_ENDPOINTS.GOOGLE_CLIENT_ID }; } @@ -134,6 +138,7 @@ test.describe(SUITE_LABELS.INITIALIZE_RUNTIME_CONFIG, () => { assert.equal(appConfig.llmProxyUrl, `http://${HOSTNAMES.REMOTE}:8081/v1/classify`); assert.equal(appConfig.authBaseUrl, `http://${HOSTNAMES.REMOTE}:8082`); assert.equal(appConfig.tauthScriptUrl, `http://${HOSTNAMES.REMOTE}:8082/tauth.js`); + assert.equal(appConfig.mprUiScriptUrl, `http://${HOSTNAMES.REMOTE}:8083/mpr-ui.js`); }); test(TEST_LABELS.RETRIES_TRANSIENT_FAILURES, async () => { From b1b85bab37e712feeb2566012b20b93bc972c6c1 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 11:18:17 -0800 Subject: [PATCH 03/18] Fail fast on auth boot and pre-init GIS --- CHANGELOG.md | 1 + ISSUES.md | 1 + frontend/js/app.js | 118 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a554c4..d66da8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and are grouped by the date the work landed on `master`. - Runtime config now returns a frozen app config and callers pass it explicitly instead of shared globals (GN-427). - Environment example files now live at `env.*.example`, keeping `.env*` files untracked while preserving copy-ready templates (GN-443). - mpr-ui now loads from a runtime-configured script URL (`mprUiScriptUrl`) after tauth.js so login components always register (GN-444). +- Auth boot now fails fast when required helpers/components are missing and pre-initializes Google Identity Services before rendering the login button to avoid GSI warnings (GN-445). - Signed-out visitors now see a landing page with a Google sign-in button; the Gravity interface requires authentication (GN-126). - mpr-ui now loads from a static script tag and auth components mount after runtime config so attributes are applied before initialization (GN-436). - Frontend now pulls mpr-ui assets from the `@latest` CDN tag so releases stay aligned (GN-437). diff --git a/ISSUES.md b/ISSUES.md index fb90135..ff3f9aa 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -174,6 +174,7 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [ ] [GN-442] (P1) Load tauth.js from the CDN (not via gHTTP), remove the /tauth.js proxy route, and wire the runtime config/test harness to use a dedicated tauthScriptUrl for the helper. - [x] [GN-443] (P1) Keep `.env*` files untracked and rename example env files to `env.*.example` with updated setup docs. - [x] [GN-444] (P1) Ensure the mpr-ui login component always registers by loading the bundle from a runtime-configured `mprUiScriptUrl` after tauth.js. +- [x] [GN-445] (P1) Make auth boot strict (no fallbacks) and pre-initialize GIS before rendering the mpr-ui login button to avoid GSI warnings. ## Maintenance (428–499) diff --git a/frontend/js/app.js b/frontend/js/app.js index b65bc14..78da96a 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -101,6 +101,21 @@ const PROFILE_AVATAR_KEYS = Object.freeze([ ]); const NOTIFICATION_DEFAULT_DURATION_MS = 3000; +const AUTH_ERROR_MESSAGES = Object.freeze({ + MISSING_INIT: "tauth.initAuthClient_missing", + MISSING_REQUEST_NONCE: "tauth.requestNonce_missing", + MISSING_EXCHANGE: "tauth.exchangeGoogleCredential_missing", + MISSING_CURRENT_USER: "tauth.getCurrentUser_missing", + MISSING_LOGOUT: "tauth.logout_missing", + GOOGLE_IDENTITY_MISSING: "google.identity_missing", + GOOGLE_NONCE_INVALID: "google.nonce_invalid", + GOOGLE_PREINIT_CALLBACK: "google.preinit_callback_invoked", + MPR_LOGIN_MISSING: "mpr_ui.login_button_missing", + MPR_USER_MISSING: "mpr_ui.user_menu_missing", + UNSUPPORTED: "gravity.unsupported_environment" +}); +const GOOGLE_IDENTITY_TIMEOUT_MS = 5000; +const GOOGLE_IDENTITY_POLL_INTERVAL_MS = 50; /** * @param {string} targetUrl @@ -137,6 +152,7 @@ async function clearAssetCaches() { bootstrapApplication().catch((error) => { logging.error("Failed to bootstrap Gravity Notes", error); + throw error; }); async function bootstrapApplication() { @@ -146,14 +162,11 @@ async function bootstrapApplication() { baseUrl: appConfig.authBaseUrl, scriptUrl: appConfig.tauthScriptUrl, tenantId: appConfig.authTenantId - }).catch((error) => { - logging.error("TAuth client failed to load", error); - }); - await ensureMprUiLoaded({ - scriptUrl: appConfig.mprUiScriptUrl - }).catch((error) => { - logging.error("mpr-ui failed to load", error); }); + await initializeAuthClient(appConfig); + await initializeGoogleIdentity(appConfig); + await ensureMprUiLoaded({ scriptUrl: appConfig.mprUiScriptUrl }); + assertAuthComponentsAvailable(); configureAuthElements(appConfig); initializeAnalytics({ config: appConfig }); document.addEventListener("alpine:init", () => { @@ -163,6 +176,97 @@ async function bootstrapApplication() { Alpine.start(); } +/** + * Ensure required TAuth helpers exist and prime the runtime configuration. + * @param {import("./core/config.js").AppConfig} appConfig + * @returns {Promise} + */ +async function initializeAuthClient(appConfig) { + if (typeof window === "undefined") { + throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); + } + const initAuthClient = requireFunction(window.initAuthClient, AUTH_ERROR_MESSAGES.MISSING_INIT); + requireFunction(window.requestNonce, AUTH_ERROR_MESSAGES.MISSING_REQUEST_NONCE); + requireFunction(window.exchangeGoogleCredential, AUTH_ERROR_MESSAGES.MISSING_EXCHANGE); + requireFunction(window.getCurrentUser, AUTH_ERROR_MESSAGES.MISSING_CURRENT_USER); + requireFunction(window.logout, AUTH_ERROR_MESSAGES.MISSING_LOGOUT); + await initAuthClient({ + baseUrl: appConfig.authBaseUrl, + tenantId: appConfig.authTenantId, + onAuthenticated: () => {}, + onUnauthenticated: () => {} + }); +} + +/** + * Ensure Google Identity Services is initialized before rendering the login button. + * @param {import("./core/config.js").AppConfig} appConfig + * @returns {Promise} + */ +async function initializeGoogleIdentity(appConfig) { + if (typeof window === "undefined") { + throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); + } + const googleIdentity = await waitForGoogleIdentity(); + const requestNonce = requireFunction(window.requestNonce, AUTH_ERROR_MESSAGES.MISSING_REQUEST_NONCE); + const nonceToken = await requestNonce(); + if (typeof nonceToken !== "string" || nonceToken.trim().length === 0) { + throw new Error(AUTH_ERROR_MESSAGES.GOOGLE_NONCE_INVALID); + } + googleIdentity.initialize({ + client_id: appConfig.googleClientId, + nonce: nonceToken, + callback: () => { + throw new Error(AUTH_ERROR_MESSAGES.GOOGLE_PREINIT_CALLBACK); + } + }); +} + +/** + * Wait for Google Identity Services to be available. + * @returns {{ initialize: (options: { client_id: string, nonce?: string, callback: (payload: unknown) => void }) => void }} + */ +async function waitForGoogleIdentity() { + const deadline = Date.now() + GOOGLE_IDENTITY_TIMEOUT_MS; + while (Date.now() < deadline) { + const google = window.google; + const identity = google?.accounts?.id; + if (identity && typeof identity.initialize === "function") { + return identity; + } + await new Promise((resolve) => setTimeout(resolve, GOOGLE_IDENTITY_POLL_INTERVAL_MS)); + } + throw new Error(AUTH_ERROR_MESSAGES.GOOGLE_IDENTITY_MISSING); +} + +/** + * @param {unknown} candidate + * @param {string} errorMessage + * @returns {Function} + */ +function requireFunction(candidate, errorMessage) { + if (typeof candidate !== "function") { + throw new Error(errorMessage); + } + return candidate; +} + +/** + * Ensure mpr-ui custom elements are registered before use. + * @returns {void} + */ +function assertAuthComponentsAvailable() { + if (typeof window === "undefined" || typeof window.customElements === "undefined") { + throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); + } + if (!window.customElements.get("mpr-login-button")) { + throw new Error(AUTH_ERROR_MESSAGES.MPR_LOGIN_MISSING); + } + if (!window.customElements.get("mpr-user")) { + throw new Error(AUTH_ERROR_MESSAGES.MPR_USER_MISSING); + } +} + /** * Apply runtime auth configuration to mpr-ui elements. * @param {import("./core/config.js").AppConfig} appConfig From 8cb24ed51df35bc8a6cf9e453f32f0a09be1ced6 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 11:58:47 -0800 Subject: [PATCH 04/18] Fix GN-442 auth boot and gHTTP /me proxy --- CHANGELOG.md | 2 +- ISSUES.md | 2 +- README.md | 2 +- env.ghttp.example | 2 +- frontend/js/app.js | 21 +++++++++++++++------ frontend/js/core/tauthClient.js | 15 ++++++++++++++- 6 files changed, 33 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d66da8e..0732976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and are grouped by the date the work landed on `master`. - Frontend now pulls mpr-ui assets from the `@latest` CDN tag so releases stay aligned (GN-437). ### Fixed -- TAuth helper now loads from a dedicated CDN URL via `tauthScriptUrl`, and gHTTP no longer proxies `/tauth.js` in the dev stack (GN-442). +- TAuth helper now loads from a dedicated CDN URL via `tauthScriptUrl`, and gHTTP no longer proxies `/tauth.js` while proxying `/me` to TAuth for session checks in the dev stack (GN-442). - Dev docker compose now serves Gravity over HTTPS at computercat.tyemirov.net:4443 via gHTTP proxies for backend/TAuth endpoints, with updated dev runtime config and env templates (GN-441). - Normalized development runtime config endpoints to swap loopback hosts for the active dev hostname and refreshed the TAuth env example for localhost defaults (GN-440). - Sync queue now coalesces per note and resolves payloads from the latest stored note to avoid duplicate ops and offline failures (GN-439). diff --git a/ISSUES.md b/ISSUES.md index ff3f9aa..a2a1616 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -171,7 +171,7 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [x] [GN-439] (P1) `QuotaExceededError` in sync queue persistence when localStorage fills; redesign persistence to avoid localStorage quotas and coalesce pending sync operations. (Resolved by migrating notes/sync queue/sync metadata to IndexedDB with localStorage-only test mode and storage-full notifications.) - [x] [GN-440] (P1) Local auth fails when TAuth tenant origin/cookie domain drift from the runtime-configured localhost URLs, causing tauth.js/nonce requests to miss or fail CORS. (Resolved by rewriting loopback runtime endpoints for non-loopback dev hosts and refreshing TAuth env defaults for localhost.) - [x] [GN-441] (P1) Dev auth fails on computercat.tyemirov.net because gHTTP still serves only the frontend and runtime config points to localhost; proxy backend/TAuth through gHTTP HTTPS and update dev config/env defaults. (Resolved by adding gHTTP HTTPS env config + proxy routes, updating compose and runtime config defaults for computercat, and documenting the new dev stack.) -- [ ] [GN-442] (P1) Load tauth.js from the CDN (not via gHTTP), remove the /tauth.js proxy route, and wire the runtime config/test harness to use a dedicated tauthScriptUrl for the helper. +- [x] [GN-442] (P1) Load tauth.js from the CDN (not via gHTTP), remove the /tauth.js proxy route, and wire the runtime config/test harness to use a dedicated tauthScriptUrl for the helper. (Resolved by enforcing absolute CDN tauth.js URLs and proxying /me through gHTTP so auth boot hits TAuth.) - [x] [GN-443] (P1) Keep `.env*` files untracked and rename example env files to `env.*.example` with updated setup docs. - [x] [GN-444] (P1) Ensure the mpr-ui login component always registers by loading the bundle from a runtime-configured `mprUiScriptUrl` after tauth.js. - [x] [GN-445] (P1) Make auth boot strict (no fallbacks) and pre-initialize GIS before rendering the mpr-ui login button to avoid GSI warnings. diff --git a/README.md b/README.md index bcb2e5e..b128d21 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Run the full application locally (frontend, backend, TAuth, and the gHTTP revers The compose file exposes: -- Frontend + proxied API at `https://computercat.tyemirov.net:4443` (gHTTP terminates TLS and proxies `/notes`, `/auth`, and `/api` to the backend/TAuth containers) +- Frontend + proxied API at `https://computercat.tyemirov.net:4443` (gHTTP terminates TLS and proxies `/notes`, `/auth`, `/me`, and `/api` to the backend/TAuth containers) - Gravity backend API at `http://localhost:8080` (container port published for direct access) - TAuth (nonce + Google exchange) at `http://localhost:8082` (container port published for direct access) diff --git a/env.ghttp.example b/env.ghttp.example index 83247f6..ca5cf91 100644 --- a/env.ghttp.example +++ b/env.ghttp.example @@ -5,4 +5,4 @@ GHTTP_SERVE_PORT=8443 GHTTP_SERVE_LOGGING_TYPE=JSON GHTTP_SERVE_TLS_CERTIFICATE=/certs/computercat.tyemirov.net.pem GHTTP_SERVE_TLS_PRIVATE_KEY=/certs/computercat.tyemirov.net-key.pem -GHTTP_SERVE_PROXIES=/notes=http://gravity-backend:8080,/auth=http://gravity-tauth:8082,/api=http://gravity-tauth:8082 +GHTTP_SERVE_PROXIES=/notes=http://gravity-backend:8080,/auth=http://gravity-tauth:8082,/me=http://gravity-tauth:8082,/api=http://gravity-tauth:8082 diff --git a/frontend/js/app.js b/frontend/js/app.js index 78da96a..cb3ef91 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -112,6 +112,8 @@ const AUTH_ERROR_MESSAGES = Object.freeze({ GOOGLE_PREINIT_CALLBACK: "google.preinit_callback_invoked", MPR_LOGIN_MISSING: "mpr_ui.login_button_missing", MPR_USER_MISSING: "mpr_ui.user_menu_missing", + MPR_LOGIN_MOUNT_FAILED: "mpr_ui.login_button_mount_failed", + MPR_USER_MOUNT_FAILED: "mpr_ui.user_menu_mount_failed", UNSUPPORTED: "gravity.unsupported_environment" }); const GOOGLE_IDENTITY_TIMEOUT_MS = 5000; @@ -276,7 +278,7 @@ function configureAuthElements(appConfig) { if (typeof document === "undefined") { return; } - ensureAuthElementMounted( + const loginButton = ensureAuthElementMounted( LANDING_LOGIN_ELEMENT_ID, LANDING_LOGIN_TEMPLATE_ID, LANDING_LOGIN_SLOT_ID, @@ -294,8 +296,11 @@ function configureAuthElements(appConfig) { loginButton.setAttribute("button-text", LABEL_SIGN_IN_WITH_GOOGLE); } ); + if (!loginButton) { + throw new Error(AUTH_ERROR_MESSAGES.MPR_LOGIN_MOUNT_FAILED); + } - ensureAuthElementMounted( + const userMenu = ensureAuthElementMounted( USER_MENU_ELEMENT_ID, USER_MENU_TEMPLATE_ID, USER_MENU_SLOT_ID, @@ -306,6 +311,9 @@ function configureAuthElements(appConfig) { userMenu.setAttribute("tauth-tenant-id", appConfig.authTenantId); } ); + if (!userMenu) { + throw new Error(AUTH_ERROR_MESSAGES.MPR_USER_MOUNT_FAILED); + } } /** @@ -677,12 +685,12 @@ function gravityApp(appConfig) { if (this.authState !== AUTH_STATE_LOADING) { return; } - if (typeof window === "undefined" || typeof window.getCurrentUser !== "function") { - void this.handleAuthUnauthenticated(); - return; + if (typeof window === "undefined") { + throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); } + const getCurrentUser = requireFunction(window.getCurrentUser, AUTH_ERROR_MESSAGES.MISSING_CURRENT_USER); try { - const profile = await window.getCurrentUser(); + const profile = await getCurrentUser(); if (this.authState !== AUTH_STATE_LOADING) { return; } @@ -692,6 +700,7 @@ function gravityApp(appConfig) { } } catch (error) { logging.error("Auth bootstrap failed", error); + throw error; } if (this.authState === AUTH_STATE_LOADING) { void this.handleAuthUnauthenticated(); diff --git a/frontend/js/core/tauthClient.js b/frontend/js/core/tauthClient.js index eb6182a..968573c 100644 --- a/frontend/js/core/tauthClient.js +++ b/frontend/js/core/tauthClient.js @@ -18,6 +18,8 @@ const ERROR_MESSAGES = Object.freeze({ MISSING_DOCUMENT: "tauth_client.missing_document", MISSING_BASE_URL: "tauth_client.missing_base_url", MISSING_SCRIPT_URL: "tauth_client.missing_script_url", + INVALID_SCRIPT_URL: "tauth_client.invalid_script_url", + INVALID_SCRIPT_ORIGIN: "tauth_client.invalid_script_origin", INVALID_TENANT_ID: "tauth_client.invalid_tenant_id", LOAD_FAILED: "tauth-client-load-failed" }); @@ -59,7 +61,18 @@ export async function ensureTAuthClientLoaded(options) { throw new Error(ERROR_MESSAGES.INVALID_TENANT_ID); } - const scriptUrl = new URL(scriptSource, authBaseUrl); + let scriptUrl; + try { + scriptUrl = new URL(scriptSource); + } catch { + throw new Error(ERROR_MESSAGES.INVALID_SCRIPT_URL); + } + if (scriptUrl.protocol !== "http:" && scriptUrl.protocol !== "https:") { + throw new Error(ERROR_MESSAGES.INVALID_SCRIPT_URL); + } + if (typeof window !== TYPE_UNDEFINED && window.location && scriptUrl.origin === window.location.origin) { + throw new Error(ERROR_MESSAGES.INVALID_SCRIPT_ORIGIN); + } if (typeof APP_BUILD_ID === TYPE_STRING && APP_BUILD_ID.length > 0) { scriptUrl.searchParams.set("build", APP_BUILD_ID); } From ffc664375c6f47a17676dccb38291185e768c661 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 13:30:44 -0800 Subject: [PATCH 05/18] Align login button auth wiring --- frontend/js/app.js | 68 ++----------------- .../tests/auth.landingLogin.puppeteer.test.js | 10 +-- 2 files changed, 10 insertions(+), 68 deletions(-) diff --git a/frontend/js/app.js b/frontend/js/app.js index cb3ef91..1d95511 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -107,17 +107,12 @@ const AUTH_ERROR_MESSAGES = Object.freeze({ MISSING_EXCHANGE: "tauth.exchangeGoogleCredential_missing", MISSING_CURRENT_USER: "tauth.getCurrentUser_missing", MISSING_LOGOUT: "tauth.logout_missing", - GOOGLE_IDENTITY_MISSING: "google.identity_missing", - GOOGLE_NONCE_INVALID: "google.nonce_invalid", - GOOGLE_PREINIT_CALLBACK: "google.preinit_callback_invoked", MPR_LOGIN_MISSING: "mpr_ui.login_button_missing", MPR_USER_MISSING: "mpr_ui.user_menu_missing", MPR_LOGIN_MOUNT_FAILED: "mpr_ui.login_button_mount_failed", MPR_USER_MOUNT_FAILED: "mpr_ui.user_menu_mount_failed", UNSUPPORTED: "gravity.unsupported_environment" }); -const GOOGLE_IDENTITY_TIMEOUT_MS = 5000; -const GOOGLE_IDENTITY_POLL_INTERVAL_MS = 50; /** * @param {string} targetUrl @@ -165,8 +160,7 @@ async function bootstrapApplication() { scriptUrl: appConfig.tauthScriptUrl, tenantId: appConfig.authTenantId }); - await initializeAuthClient(appConfig); - await initializeGoogleIdentity(appConfig); + assertTAuthHelpersAvailable(); await ensureMprUiLoaded({ scriptUrl: appConfig.mprUiScriptUrl }); assertAuthComponentsAvailable(); configureAuthElements(appConfig); @@ -179,66 +173,18 @@ async function bootstrapApplication() { } /** - * Ensure required TAuth helpers exist and prime the runtime configuration. - * @param {import("./core/config.js").AppConfig} appConfig - * @returns {Promise} + * Ensure required TAuth helpers exist before mpr-ui boots. + * @returns {void} */ -async function initializeAuthClient(appConfig) { +function assertTAuthHelpersAvailable() { if (typeof window === "undefined") { throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); } - const initAuthClient = requireFunction(window.initAuthClient, AUTH_ERROR_MESSAGES.MISSING_INIT); + requireFunction(window.initAuthClient, AUTH_ERROR_MESSAGES.MISSING_INIT); requireFunction(window.requestNonce, AUTH_ERROR_MESSAGES.MISSING_REQUEST_NONCE); requireFunction(window.exchangeGoogleCredential, AUTH_ERROR_MESSAGES.MISSING_EXCHANGE); requireFunction(window.getCurrentUser, AUTH_ERROR_MESSAGES.MISSING_CURRENT_USER); requireFunction(window.logout, AUTH_ERROR_MESSAGES.MISSING_LOGOUT); - await initAuthClient({ - baseUrl: appConfig.authBaseUrl, - tenantId: appConfig.authTenantId, - onAuthenticated: () => {}, - onUnauthenticated: () => {} - }); -} - -/** - * Ensure Google Identity Services is initialized before rendering the login button. - * @param {import("./core/config.js").AppConfig} appConfig - * @returns {Promise} - */ -async function initializeGoogleIdentity(appConfig) { - if (typeof window === "undefined") { - throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); - } - const googleIdentity = await waitForGoogleIdentity(); - const requestNonce = requireFunction(window.requestNonce, AUTH_ERROR_MESSAGES.MISSING_REQUEST_NONCE); - const nonceToken = await requestNonce(); - if (typeof nonceToken !== "string" || nonceToken.trim().length === 0) { - throw new Error(AUTH_ERROR_MESSAGES.GOOGLE_NONCE_INVALID); - } - googleIdentity.initialize({ - client_id: appConfig.googleClientId, - nonce: nonceToken, - callback: () => { - throw new Error(AUTH_ERROR_MESSAGES.GOOGLE_PREINIT_CALLBACK); - } - }); -} - -/** - * Wait for Google Identity Services to be available. - * @returns {{ initialize: (options: { client_id: string, nonce?: string, callback: (payload: unknown) => void }) => void }} - */ -async function waitForGoogleIdentity() { - const deadline = Date.now() + GOOGLE_IDENTITY_TIMEOUT_MS; - while (Date.now() < deadline) { - const google = window.google; - const identity = google?.accounts?.id; - if (identity && typeof identity.initialize === "function") { - return identity; - } - await new Promise((resolve) => setTimeout(resolve, GOOGLE_IDENTITY_POLL_INTERVAL_MS)); - } - throw new Error(AUTH_ERROR_MESSAGES.GOOGLE_IDENTITY_MISSING); } /** @@ -289,10 +235,6 @@ function configureAuthElements(appConfig) { loginButton.setAttribute("tauth-login-path", TAUTH_LOGIN_PATH); loginButton.setAttribute("tauth-logout-path", TAUTH_LOGOUT_PATH); loginButton.setAttribute("tauth-nonce-path", TAUTH_NONCE_PATH); - loginButton.setAttribute("base-url", appConfig.authBaseUrl); - loginButton.setAttribute("login-path", TAUTH_LOGIN_PATH); - loginButton.setAttribute("logout-path", TAUTH_LOGOUT_PATH); - loginButton.setAttribute("nonce-path", TAUTH_NONCE_PATH); loginButton.setAttribute("button-text", LABEL_SIGN_IN_WITH_GOOGLE); } ); diff --git a/frontend/tests/auth.landingLogin.puppeteer.test.js b/frontend/tests/auth.landingLogin.puppeteer.test.js index afa6461..306899d 100644 --- a/frontend/tests/auth.landingLogin.puppeteer.test.js +++ b/frontend/tests/auth.landingLogin.puppeteer.test.js @@ -32,14 +32,14 @@ if (!puppeteerAvailable) { await harness.page.waitForSelector("[data-test=\"landing-login\"]"); const attributes = await harness.page.$eval("[data-test=\"landing-login\"]", (element) => { return { - baseUrl: element.getAttribute("base-url"), - loginPath: element.getAttribute("login-path"), - logoutPath: element.getAttribute("logout-path"), - noncePath: element.getAttribute("nonce-path") + tauthUrl: element.getAttribute("tauth-url"), + loginPath: element.getAttribute("tauth-login-path"), + logoutPath: element.getAttribute("tauth-logout-path"), + noncePath: element.getAttribute("tauth-nonce-path") }; }); - assert.equal(attributes.baseUrl, CUSTOM_AUTH_BASE_URL); + assert.equal(attributes.tauthUrl, CUSTOM_AUTH_BASE_URL); assert.equal(attributes.loginPath, "/auth/google"); assert.equal(attributes.logoutPath, "/auth/logout"); assert.equal(attributes.noncePath, "/auth/nonce"); From d402cc2c31f9401ba3bcf8f0dd02c56732ba50d1 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 14:53:28 -0800 Subject: [PATCH 06/18] feat(auth): adopt mpr-ui config.yaml loader - add frontend/config.yaml with per-origin auth settings - load mpr-ui config before bundle and remove manual auth wiring - enforce strict auth boot via config loader promise - CI: make lint passing; make test/make ci killed by sandbox --- ISSUES.md | 1 + README.md | 4 +- frontend/config.yaml | 31 ++++++ frontend/index.html | 39 +++++-- frontend/js/app.js | 125 +++-------------------- frontend/tests/helpers/browserHarness.js | 55 +++++++++- frontend/tests/helpers/syncTestUtils.js | 37 ------- 7 files changed, 132 insertions(+), 160 deletions(-) create mode 100644 frontend/config.yaml diff --git a/ISSUES.md b/ISSUES.md index a2a1616..87a08e0 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -175,6 +175,7 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [x] [GN-443] (P1) Keep `.env*` files untracked and rename example env files to `env.*.example` with updated setup docs. - [x] [GN-444] (P1) Ensure the mpr-ui login component always registers by loading the bundle from a runtime-configured `mprUiScriptUrl` after tauth.js. - [x] [GN-445] (P1) Make auth boot strict (no fallbacks) and pre-initialize GIS before rendering the mpr-ui login button to avoid GSI warnings. +- [ ] [GN-446] (P1) Adopt the mpr-ui config.yaml loader for auth wiring so login buttons render from declarative config and tauth.js only loads from the CDN. ## Maintenance (428–499) diff --git a/README.md b/README.md index b128d21..72f2b80 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,11 @@ The compose file exposes: - Gravity backend API at `http://localhost:8080` (container port published for direct access) - TAuth (nonce + Google exchange) at `http://localhost:8082` (container port published for direct access) -Runtime configuration files under `frontend/data/` include `authBaseUrl`, `tauthScriptUrl`, and `mprUiScriptUrl`, so the browser can discover which TAuth origin to contact for `/auth/nonce`, `/auth/google`, and `/auth/logout`, which CDN host serves `tauth.js`, and which CDN host serves the `mpr-ui` bundle. Update `frontend/data/runtime.config.production.json` if your deployment uses different auth or CDN hosts, and update `frontend/data/runtime.config.development.json` if you run dev on a different HTTPS origin. +Auth settings now live in `frontend/config.yaml`, which maps each allowed `window.location.origin` to its TAuth base URL, Google client ID, tenant ID, and `/auth/*` paths (plus optional login button styling). Update `frontend/config.yaml` whenever origins or auth settings change. Runtime configuration files under `frontend/data/` remain the source of truth for backend and LLM proxy endpoints; update `frontend/data/runtime.config.production.json` and `frontend/data/runtime.config.development.json` when those API hosts change. ### Authentication Contract -- Gravity no longer exchanges Google credentials itself. The browser loads `tauth.js` from the configured CDN (`tauthScriptUrl`), fetches a nonce from `/auth/nonce`, and lets TAuth exchange the Google credential at `/auth/google`. +- Gravity no longer exchanges Google credentials itself. The browser loads `tauth.js` from the TAuth CDN, applies `frontend/config.yaml` to discover the TAuth base URL, fetches a nonce from `/auth/nonce`, and lets TAuth exchange the Google credential at `/auth/google`. - TAuth mints two cookies: `app_session` (short-lived HS256 JWT) and `app_refresh` (long-lived refresh token). Every request from the UI includes `app_session` automatically, so the Gravity backend validates the JWT using `GRAVITY_TAUTH_SIGNING_SECRET` and the fixed `tauth` issuer. No bearer tokens or local storage is used. - To keep the multi-tenant TAuth flow working, the backend’s CORS preflight now whitelists the `X-TAuth-Tenant` header (in addition to `Authorization`, `Content-Type`, etc.), so browsers can send the tenant hint while relying on cookie authentication. - When a request returns `401`, the browser calls `/auth/refresh` on the TAuth origin; a fresh `app_session` cookie is minted and the original request is retried. diff --git a/frontend/config.yaml b/frontend/config.yaml new file mode 100644 index 0000000..e2934ad --- /dev/null +++ b/frontend/config.yaml @@ -0,0 +1,31 @@ +environments: + - description: "Gravity Notes Development" + origins: + - "https://computercat.tyemirov.net:4443" + auth: + tauthUrl: "https://computercat.tyemirov.net:4443" + googleClientId: "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" + tenantId: "gravity" + loginPath: "/auth/google" + logoutPath: "/auth/logout" + noncePath: "/auth/nonce" + authButton: + text: "signin_with" + size: "small" + theme: "outline" + shape: "circle" + - description: "Gravity Notes Production" + origins: + - "https://gravity.mprlab.com" + auth: + tauthUrl: "https://tauth-api.mprlab.com" + googleClientId: "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" + tenantId: "gravity" + loginPath: "/auth/google" + logoutPath: "/auth/logout" + noncePath: "/auth/nonce" + authButton: + text: "signin_with" + size: "small" + theme: "outline" + shape: "circle" diff --git a/frontend/index.html b/frontend/index.html index caeed34..2a7bde4 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,29 @@ + + + @@ -42,10 +64,7 @@

Notes that stay anchored.

- -
+

@@ -92,10 +111,14 @@

Gravity Notes

Full Screen - -
+ diff --git a/frontend/js/app.js b/frontend/js/app.js index 1d95511..42d9960 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -11,8 +11,6 @@ import { initializeRuntimeConfig } from "./core/runtimeConfig.js?build=2026-01-0 import { initializeAnalytics } from "./core/analytics.js?build=2026-01-01T22:43:21Z"; import { createSyncManager } from "./core/syncManager.js?build=2026-01-01T22:43:21Z"; import { createRealtimeSyncController } from "./core/realtimeSyncController.js?build=2026-01-01T22:43:21Z"; -import { ensureTAuthClientLoaded } from "./core/tauthClient.js?build=2026-01-01T22:43:21Z"; -import { ensureMprUiLoaded } from "./core/mprUiClient.js?build=2026-01-01T22:43:21Z"; import { mountTopEditor } from "./ui/topEditor.js?build=2026-01-01T22:43:21Z"; import { LABEL_APP_SUBTITLE, @@ -24,8 +22,6 @@ import { LABEL_LANDING_DESCRIPTION, LABEL_LANDING_SIGN_IN_HINT, LABEL_LANDING_STATUS_LOADING, - LABEL_SIGN_IN_WITH_GOOGLE, - LABEL_SIGN_OUT, ERROR_NOTES_CONTAINER_NOT_FOUND, ERROR_AUTHENTICATION_GENERIC, EVENT_NOTE_CREATE, @@ -68,16 +64,7 @@ const AUTH_STATE_AUTHENTICATED = "authenticated"; const AUTH_STATE_UNAUTHENTICATED = "unauthenticated"; const USER_MENU_ACTION_EXPORT = "export-notes"; const USER_MENU_ACTION_IMPORT = "import-notes"; -const TAUTH_LOGIN_PATH = "/auth/google"; -const TAUTH_LOGOUT_PATH = "/auth/logout"; -const TAUTH_NONCE_PATH = "/auth/nonce"; const TYPE_STRING = "string"; -const LANDING_LOGIN_ELEMENT_ID = "landing-login"; -const LANDING_LOGIN_TEMPLATE_ID = "landing-login-template"; -const LANDING_LOGIN_SLOT_ID = "landing-login-slot"; -const USER_MENU_ELEMENT_ID = "app-user-menu"; -const USER_MENU_TEMPLATE_ID = "user-menu-template"; -const USER_MENU_SLOT_ID = "user-menu-slot"; const PROFILE_KEYS = Object.freeze({ USER_ID: "user_id", @@ -109,8 +96,7 @@ const AUTH_ERROR_MESSAGES = Object.freeze({ MISSING_LOGOUT: "tauth.logout_missing", MPR_LOGIN_MISSING: "mpr_ui.login_button_missing", MPR_USER_MISSING: "mpr_ui.user_menu_missing", - MPR_LOGIN_MOUNT_FAILED: "mpr_ui.login_button_mount_failed", - MPR_USER_MOUNT_FAILED: "mpr_ui.user_menu_mount_failed", + MPR_UI_CONFIG_MISSING: "mpr_ui.config_missing", UNSUPPORTED: "gravity.unsupported_environment" }); @@ -155,15 +141,9 @@ bootstrapApplication().catch((error) => { async function bootstrapApplication() { const appConfig = await initializeRuntimeConfig(); await GravityStore.initialize(); - await ensureTAuthClientLoaded({ - baseUrl: appConfig.authBaseUrl, - scriptUrl: appConfig.tauthScriptUrl, - tenantId: appConfig.authTenantId - }); + await ensureMprUiReady(); assertTAuthHelpersAvailable(); - await ensureMprUiLoaded({ scriptUrl: appConfig.mprUiScriptUrl }); assertAuthComponentsAvailable(); - configureAuthElements(appConfig); initializeAnalytics({ config: appConfig }); document.addEventListener("alpine:init", () => { Alpine.data("gravityApp", () => gravityApp(appConfig)); @@ -172,6 +152,21 @@ async function bootstrapApplication() { Alpine.start(); } +/** + * Ensure the mpr-ui config loader applied config and loaded the bundle. + * @returns {Promise} + */ +async function ensureMprUiReady() { + if (typeof window === "undefined") { + throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); + } + const ready = window.__mprUiReady; + if (!ready || typeof ready.then !== "function") { + throw new Error(AUTH_ERROR_MESSAGES.MPR_UI_CONFIG_MISSING); + } + await ready; +} + /** * Ensure required TAuth helpers exist before mpr-ui boots. * @returns {void} @@ -215,93 +210,7 @@ function assertAuthComponentsAvailable() { } } -/** - * Apply runtime auth configuration to mpr-ui elements. - * @param {import("./core/config.js").AppConfig} appConfig - * @returns {void} - */ -function configureAuthElements(appConfig) { - if (typeof document === "undefined") { - return; - } - const loginButton = ensureAuthElementMounted( - LANDING_LOGIN_ELEMENT_ID, - LANDING_LOGIN_TEMPLATE_ID, - LANDING_LOGIN_SLOT_ID, - (loginButton) => { - loginButton.setAttribute("site-id", appConfig.googleClientId); - loginButton.setAttribute("tauth-tenant-id", appConfig.authTenantId); - loginButton.setAttribute("tauth-url", appConfig.authBaseUrl); - loginButton.setAttribute("tauth-login-path", TAUTH_LOGIN_PATH); - loginButton.setAttribute("tauth-logout-path", TAUTH_LOGOUT_PATH); - loginButton.setAttribute("tauth-nonce-path", TAUTH_NONCE_PATH); - loginButton.setAttribute("button-text", LABEL_SIGN_IN_WITH_GOOGLE); - } - ); - if (!loginButton) { - throw new Error(AUTH_ERROR_MESSAGES.MPR_LOGIN_MOUNT_FAILED); - } - - const userMenu = ensureAuthElementMounted( - USER_MENU_ELEMENT_ID, - USER_MENU_TEMPLATE_ID, - USER_MENU_SLOT_ID, - (userMenu) => { - userMenu.setAttribute("display-mode", "avatar-name"); - userMenu.setAttribute("logout-url", resolveLogoutUrl()); - userMenu.setAttribute("logout-label", LABEL_SIGN_OUT); - userMenu.setAttribute("tauth-tenant-id", appConfig.authTenantId); - } - ); - if (!userMenu) { - throw new Error(AUTH_ERROR_MESSAGES.MPR_USER_MOUNT_FAILED); - } -} - -/** - * @param {string} elementId - * @param {string} templateId - * @param {string} slotId - * @param {(element: HTMLElement) => void} applyAttributes - * @returns {HTMLElement|null} - */ -function ensureAuthElementMounted(elementId, templateId, slotId, applyAttributes) { - if (typeof document === "undefined") { - return null; - } - const existing = document.getElementById(elementId); - if (existing instanceof HTMLElement) { - applyAttributes(existing); - return existing; - } - const template = document.getElementById(templateId); - const slot = document.getElementById(slotId); - if (!(template instanceof HTMLTemplateElement) || !(slot instanceof HTMLElement)) { - return null; - } - const fragment = template.content.cloneNode(true); - const staged = fragment.querySelector(`#${elementId}`); - if (!(staged instanceof HTMLElement)) { - return null; - } - applyAttributes(staged); - slot.appendChild(fragment); - return staged; -} -/** - * Resolve the redirect URL used after a TAuth logout. - * @returns {string} - */ -function resolveLogoutUrl() { - if (typeof window === "undefined") { - return "/"; - } - if (window.location.protocol === "file:") { - return window.location.pathname || "/"; - } - return window.location.href; -} /** * Alpine root component that wires the Gravity Notes application. diff --git a/frontend/tests/helpers/browserHarness.js b/frontend/tests/helpers/browserHarness.js index 2a27132..544efa3 100644 --- a/frontend/tests/helpers/browserHarness.js +++ b/frontend/tests/helpers/browserHarness.js @@ -30,6 +30,12 @@ const AVATAR_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQ const AVATAR_PNG_BYTES = Buffer.from(AVATAR_PNG_BASE64, "base64"); const DEFAULT_TEST_TENANT_ID = "gravity"; const DEFAULT_GOOGLE_CLIENT_ID = "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com"; +const DEFAULT_AUTH_BUTTON_CONFIG = Object.freeze({ + text: "signin_with", + size: "small", + theme: "outline", + shape: "circle" +}); const CDN_MIRRORS = Object.freeze([ { pattern: /^https:\/\/cdn\.jsdelivr\.net\/npm\/alpinejs@3\.13\.5\/dist\/module\.esm\.js$/u, @@ -61,6 +67,11 @@ const CDN_MIRRORS = Object.freeze([ filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.js"), contentType: "application/javascript" }, + { + pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@latest\/mpr-ui-config\.js$/u, + filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui-config.js"), + contentType: "application/javascript" + }, { pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@latest\/mpr-ui\.css$/u, filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.css"), @@ -78,6 +89,11 @@ const CDN_STUBS = Object.freeze([ contentType: "application/javascript", body: EMPTY_STRING }, + { + pattern: /^https:\/\/tauth\.mprlab\.com\/tauth\.js(?:\?.*)?$/u, + contentType: "application/javascript", + body: EMPTY_STRING + }, { pattern: /^https:\/\/example\.com\/avatar\.png$/u, contentType: "image/png", @@ -393,8 +409,9 @@ export async function injectRuntimeConfig(page, overrides = {}) { development: resolveRuntimeConfigOverrides(page[RUNTIME_CONFIG_SYMBOL], "development"), production: resolveRuntimeConfigOverrides(page[RUNTIME_CONFIG_SYMBOL], "production") }; - await page.evaluateOnNewDocument((config) => { - const configPattern = /\/data\/runtime\.config\.(development|production)\.json$/u; + await page.evaluateOnNewDocument((config, authButton) => { + const runtimeConfigPattern = /\/data\/runtime\.config\.(development|production)\.json$/u; + const yamlConfigPattern = /config\.yaml$/u; const originalFetch = window.fetch; window.fetch = async (input, init = {}) => { const requestUrl = typeof input === "string" @@ -402,8 +419,36 @@ export async function injectRuntimeConfig(page, overrides = {}) { : typeof input?.url === "string" ? input.url : ""; - if (typeof requestUrl === "string" && configPattern.test(requestUrl)) { - const match = requestUrl.match(configPattern); + if (typeof requestUrl === "string" && yamlConfigPattern.test(requestUrl)) { + const origin = typeof window.location?.origin === "string" ? window.location.origin : ""; + if (!origin) { + throw new Error("config.yaml requires a window.location.origin"); + } + const yaml = [ + "environments:", + " - description: \"Test\"", + " origins:", + ` - \"${origin}\"`, + " auth:", + ` tauthUrl: \"${config.development.authBaseUrl}\"`, + ` googleClientId: \"${config.development.googleClientId}\"`, + ` tenantId: \"${config.development.authTenantId}\"`, + " loginPath: \"/auth/google\"", + " logoutPath: \"/auth/logout\"", + " noncePath: \"/auth/nonce\"", + " authButton:", + ` text: \"${authButton.text}\"`, + ` size: \"${authButton.size}\"`, + ` theme: \"${authButton.theme}\"`, + ` shape: \"${authButton.shape}\"` + ].join("\n"); + return new Response(yaml, { + status: 200, + headers: { "Content-Type": "text/yaml" } + }); + } + if (typeof requestUrl === "string" && runtimeConfigPattern.test(requestUrl)) { + const match = requestUrl.match(runtimeConfigPattern); const environment = match && match[1] ? match[1] : "development"; const payload = environment === "production" ? config.production : config.development; return new Response(JSON.stringify(payload), { @@ -434,7 +479,7 @@ export async function injectRuntimeConfig(page, overrides = {}) { [RUNTIME_CONFIG_KEYS.AUTH_TENANT_ID]: overridesByEnvironment.production.authTenantId, [RUNTIME_CONFIG_KEYS.GOOGLE_CLIENT_ID]: overridesByEnvironment.production.googleClientId } - }); + }, DEFAULT_AUTH_BUTTON_CONFIG); await registerRequestInterceptor(page, (request) => { const url = request.url(); if (TAUTH_SCRIPT_PATTERN.test(url)) { diff --git a/frontend/tests/helpers/syncTestUtils.js b/frontend/tests/helpers/syncTestUtils.js index c74b510..3a73758 100644 --- a/frontend/tests/helpers/syncTestUtils.js +++ b/frontend/tests/helpers/syncTestUtils.js @@ -146,43 +146,6 @@ export async function prepareFrontendPage(browser, pageUrl, options) { googleClientId } }); - await page.evaluateOnNewDocument((config) => { - const targetPattern = /\/data\/runtime\.config\.(development|production)\.json$/; - const originalFetch = window.fetch; - window.fetch = async (input, init = {}) => { - const requestUrl = typeof input === "string" - ? input - : typeof input?.url === "string" - ? input.url - : ""; - if (typeof requestUrl === "string" && targetPattern.test(requestUrl)) { - const payload = { - environment: config.environment ?? "development", - backendBaseUrl: config.backendBaseUrl, - llmProxyUrl: config.llmProxyUrl, - authBaseUrl: config.authBaseUrl, - tauthScriptUrl: config.tauthScriptUrl, - mprUiScriptUrl: config.mprUiScriptUrl, - authTenantId: config.authTenantId, - googleClientId: config.googleClientId - }; - return new Response(JSON.stringify(payload), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - return originalFetch.call(window, input, init); - }; - }, { - environment: "development", - backendBaseUrl, - llmProxyUrl, - authBaseUrl, - tauthScriptUrl, - mprUiScriptUrl, - authTenantId, - googleClientId - }); await page.evaluateOnNewDocument((storageKey, shouldPreserve) => { const initialized = window.sessionStorage.getItem("__gravityTestInitialized") === "true"; if (!initialized) { From e9ebcfab92e9ee6f743842d99722a921789c15d2 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 15:29:04 -0800 Subject: [PATCH 07/18] fix(auth): pre-init tauth before mpr-ui login - wait for DOMContentLoaded before config load - init tauth + prime nonce prior to loading mpr-ui.js - CI: make lint passing; make test killed by sandbox --- frontend/index.html | 71 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 2a7bde4..92a9bea 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,9 +7,6 @@ From cf24d077d62b7f40c11a9c9bd840a89c5c27c975 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Tue, 27 Jan 2026 15:44:43 -0800 Subject: [PATCH 08/18] chore(dev): align ghttp/tauth config with demo - mirror mpr-ui demo proxy routes and origin lists - expand CORS + tenant origin placeholders in config.tauth.yml - CI: make lint passing; make test killed by sandbox --- config.tauth.yml | 8 ++++++-- env.ghttp.example | 5 ++++- env.tauth.example | 10 ++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/config.tauth.yml b/config.tauth.yml index 4291e63..39c1bf8 100644 --- a/config.tauth.yml +++ b/config.tauth.yml @@ -3,7 +3,9 @@ server: database_url: "${TAUTH_DATABASE_URL}" enable_cors: "${TAUTH_ENABLE_CORS}" cors_allowed_origins: - - ${TAUTH_CORS_ORIGIN_1} + - "${TAUTH_CORS_ORIGIN_1}" + - "${TAUTH_CORS_ORIGIN_2}" + - "${TAUTH_CORS_ORIGIN_3}" cors_allowed_origin_exceptions: - "${TAUTH_CORS_EXCEPTION_1}" enable_tenant_header_override: ${TAUTH_ENABLE_TENANT_HEADER_OVERRIDE} @@ -12,7 +14,9 @@ tenants: - id: ${TAUTH_TENANT_ID_GRAVITY} display_name: ${TAUTH_TENANT_DISPLAY_NAME_GRAVITY} tenant_origins: - - ${TAUTH_CORS_ORIGIN_1} + - "${TAUTH_CORS_ORIGIN_1}" + - "${TAUTH_CORS_ORIGIN_2}" + - "${TAUTH_CORS_ORIGIN_3}" google_web_client_id: "${TAUTH_TENANT_GOOGLE_WEB_CLIENT_ID_GRAVITY}" jwt_signing_key: "${TAUTH_TENANT_JWT_SIGNING_KEY_GRAVITY}" cookie_domain: "${TAUTH_COOKIE_DOMAIN}" diff --git a/env.ghttp.example b/env.ghttp.example index ca5cf91..d9e41ec 100644 --- a/env.ghttp.example +++ b/env.ghttp.example @@ -5,4 +5,7 @@ GHTTP_SERVE_PORT=8443 GHTTP_SERVE_LOGGING_TYPE=JSON GHTTP_SERVE_TLS_CERTIFICATE=/certs/computercat.tyemirov.net.pem GHTTP_SERVE_TLS_PRIVATE_KEY=/certs/computercat.tyemirov.net-key.pem -GHTTP_SERVE_PROXIES=/notes=http://gravity-backend:8080,/auth=http://gravity-tauth:8082,/me=http://gravity-tauth:8082,/api=http://gravity-tauth:8082 +# Proxy backend + TAuth endpoints through ghttp so the browser stays on HTTPS. +# Routes: /notes (Gravity backend), /auth/* + /me (TAuth). +# tauth.js stays CDN-hosted and is not proxied here. +GHTTP_SERVE_PROXIES=/notes=http://gravity-backend:8080,/auth=http://gravity-tauth:8082,/me=http://gravity-tauth:8082 diff --git a/env.tauth.example b/env.tauth.example index a59ab90..f5aeea0 100644 --- a/env.tauth.example +++ b/env.tauth.example @@ -1,21 +1,23 @@ -# Gravity +# Gravity tenant TAUTH_TENANT_ID_GRAVITY=gravity TAUTH_TENANT_DISPLAY_NAME_GRAVITY="Gravity Notes" -TAUTH_TENANT_ORIGIN_GRAVITY=https://computercat.tyemirov.net:4443 TAUTH_TENANT_GOOGLE_WEB_CLIENT_ID_GRAVITY=qqq.apps.googleusercontent.com TAUTH_TENANT_JWT_SIGNING_KEY_GRAVITY=qqq TAUTH_TENANT_SESSION_COOKIE_NAME_GRAVITY=app_session_gravity TAUTH_TENANT_REFRESH_COOKIE_NAME_GRAVITY=app_refresh_gravity +# Cookie domain (blank => host-only cookie for the requesting origin) TAUTH_COOKIE_DOMAIN=computercat.tyemirov.net -# TAuth Server +# TAuth server TAUTH_CONFIG_FILE=/config/config.yml TAUTH_LISTEN_ADDR=:8082 TAUTH_DATABASE_URL=sqlite:///data/tauth.db TAUTH_ENABLE_CORS=true TAUTH_CORS_EXCEPTION_1=https://accounts.google.com -TAUTH_CORS_ORIGIN_1=${TAUTH_TENANT_ORIGIN_GRAVITY} +TAUTH_CORS_ORIGIN_1=https://computercat.tyemirov.net:4443 +TAUTH_CORS_ORIGIN_2=https://computercat.tyemirov.net +TAUTH_CORS_ORIGIN_3=https://gravity.mprlab.com TAUTH_ENABLE_TENANT_HEADER_OVERRIDE=true # Shared From 4308804d86ba3c44f034867e9705f041c60c51a0 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Wed, 28 Jan 2026 13:14:25 -0800 Subject: [PATCH 09/18] fix(auth): serialize concurrent auth operations to prevent race condition Added auth operation chaining to prevent race conditions when concurrent auth events (authenticated/unauthenticated) interleave during async operations like IndexedDB hydration. Previously, rapid auth state changes could cause activeStorageKey to change during hydrateActiveScope(), leading to a mismatch when ensureHydrated() was called and throwing 'storage.notes.not_ready' error. Changes: - Added authOperationChain (Promise) to serialize auth operations - Added authOperationId (number) to track and cancel stale operations - Modified handleAuthAuthenticated to chain operations and check ID - Modified handleAuthUnauthenticated with same pattern Tests: node --test tests/auth.operationChain.test.js (9 tests) Co-Authored-By: Claude Opus 4.5 --- frontend/js/app.js | 156 ++++++---- frontend/tests/auth.operationChain.test.js | 325 +++++++++++++++++++++ 2 files changed, 426 insertions(+), 55 deletions(-) create mode 100644 frontend/tests/auth.operationChain.test.js diff --git a/frontend/js/app.js b/frontend/js/app.js index 42d9960..241a52a 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -231,6 +231,10 @@ function gravityApp(appConfig) { importInput: /** @type {HTMLInputElement|null} */ (null), authUser: /** @type {{ id: string, email: string|null, name: string|null, pictureUrl: string|null }|null} */ (null), pendingSignInUserId: /** @type {string|null} */ (null), + /** @type {Promise} */ + authOperationChain: Promise.resolve(), + /** @type {number} */ + authOperationId: 0, syncManager: /** @type {ReturnType|null} */ (null), realtimeSync: /** @type {{ connect(params: { baseUrl: string }): void, disconnect(): void, dispose(): void }|null} */ (null), syncIntervalHandle: /** @type {number|null} */ (null), @@ -430,7 +434,7 @@ function gravityApp(appConfig) { } }, - async handleAuthAuthenticated(profile) { + handleAuthAuthenticated(profile) { const normalizedUser = normalizeAuthProfile(profile); if (!normalizedUser || !normalizedUser.id) { this.setLandingStatus(ERROR_AUTHENTICATION_GENERIC, "error"); @@ -440,74 +444,116 @@ function gravityApp(appConfig) { if (this.authUser?.id === normalizedUser.id || this.pendingSignInUserId === normalizedUser.id) { return; } + + const operationId = ++this.authOperationId; this.pendingSignInUserId = normalizedUser.id; - const applySignedInState = () => { - this.authUser = normalizedUser; - this.clearLandingStatus(); - this.setAuthState(AUTH_STATE_AUTHENTICATED); - this.initializeNotes(); - this.realtimeSync?.connect({ - baseUrl: appConfig.backendBaseUrl - }); - if (typeof window !== "undefined" && this.syncIntervalHandle === null) { - this.syncIntervalHandle = window.setInterval(() => { - void this.syncManager?.synchronize({ flushQueue: false }); - }, 3000); + const runOperation = async () => { + const applySignedInState = () => { + if (this.authOperationId !== operationId) { + return; + } + this.authUser = normalizedUser; + this.clearLandingStatus(); + this.setAuthState(AUTH_STATE_AUTHENTICATED); + this.initializeNotes(); + this.realtimeSync?.connect({ + baseUrl: appConfig.backendBaseUrl + }); + if (typeof window !== "undefined" && this.syncIntervalHandle === null) { + this.syncIntervalHandle = window.setInterval(() => { + void this.syncManager?.synchronize({ flushQueue: false }); + }, 3000); + } + }; + + const applySignedOutState = async () => { + if (this.authOperationId !== operationId) { + return; + } + this.authUser = null; + this.setAuthState(AUTH_STATE_UNAUTHENTICATED); + GravityStore.setUserScope(null); + await GravityStore.hydrateActiveScope(); + if (this.authOperationId !== operationId) { + return; + } + this.initializeNotes(); + this.syncManager?.handleSignOut(); + this.realtimeSync?.disconnect(); + }; + + try { + GravityStore.setUserScope(normalizedUser.id); + await GravityStore.hydrateActiveScope(); + if (this.authOperationId !== operationId) { + return; + } + const result = this.syncManager && typeof this.syncManager.handleSignIn === "function" + ? await this.syncManager.handleSignIn({ userId: normalizedUser.id }) + : { authenticated: true, queueFlushed: false, snapshotApplied: false }; + if (this.authOperationId !== operationId) { + return; + } + if (!result?.authenticated) { + await applySignedOutState(); + if (this.authOperationId === operationId) { + this.setLandingStatus(ERROR_AUTHENTICATION_GENERIC, "error"); + } + return; + } + applySignedInState(); + } catch (error) { + logging.error(error); + await applySignedOutState(); + if (this.authOperationId === operationId) { + this.setLandingStatus(ERROR_AUTHENTICATION_GENERIC, "error"); + } + } finally { + if (this.pendingSignInUserId === normalizedUser.id) { + this.pendingSignInUserId = null; + } } }; - const applySignedOutState = async () => { + const operation = this.authOperationChain + .then(runOperation) + .catch((error) => logging.error("Auth operation failed", error)); + this.authOperationChain = operation; + return operation; + }, + + handleAuthUnauthenticated() { + const operationId = ++this.authOperationId; + + const runOperation = async () => { this.authUser = null; + this.pendingSignInUserId = null; this.setAuthState(AUTH_STATE_UNAUTHENTICATED); + const statusElement = this.landingStatus; + const shouldPreserveError = Boolean(statusElement && statusElement.dataset.status === "error"); + if (!shouldPreserveError) { + this.clearLandingStatus(); + } GravityStore.setUserScope(null); await GravityStore.hydrateActiveScope(); + if (this.authOperationId !== operationId) { + return; + } this.initializeNotes(); this.syncManager?.handleSignOut(); this.realtimeSync?.disconnect(); - }; - - try { - GravityStore.setUserScope(normalizedUser.id); - await GravityStore.hydrateActiveScope(); - const result = this.syncManager && typeof this.syncManager.handleSignIn === "function" - ? await this.syncManager.handleSignIn({ userId: normalizedUser.id }) - : { authenticated: true, queueFlushed: false, snapshotApplied: false }; - if (!result?.authenticated) { - await applySignedOutState(); - this.setLandingStatus(ERROR_AUTHENTICATION_GENERIC, "error"); - return; + if (typeof window !== "undefined" && this.syncIntervalHandle !== null) { + window.clearInterval(this.syncIntervalHandle); + this.syncIntervalHandle = null; } - applySignedInState(); - } catch (error) { - logging.error(error); - await applySignedOutState(); - this.setLandingStatus(ERROR_AUTHENTICATION_GENERIC, "error"); - } finally { - if (this.pendingSignInUserId === normalizedUser.id) { - this.pendingSignInUserId = null; - } - } - }, + }; - async handleAuthUnauthenticated() { - this.authUser = null; - this.pendingSignInUserId = null; - this.setAuthState(AUTH_STATE_UNAUTHENTICATED); - const statusElement = this.landingStatus; - const shouldPreserveError = Boolean(statusElement && statusElement.dataset.status === "error"); - if (!shouldPreserveError) { - this.clearLandingStatus(); - } - GravityStore.setUserScope(null); - await GravityStore.hydrateActiveScope(); - this.initializeNotes(); - this.syncManager?.handleSignOut(); - this.realtimeSync?.disconnect(); - if (typeof window !== "undefined" && this.syncIntervalHandle !== null) { - window.clearInterval(this.syncIntervalHandle); - this.syncIntervalHandle = null; - } + const operation = this.authOperationChain + .then(runOperation) + .catch((error) => logging.error("Auth operation failed", error)); + this.authOperationChain = operation; + return operation; }, handleAuthError(detail) { diff --git a/frontend/tests/auth.operationChain.test.js b/frontend/tests/auth.operationChain.test.js new file mode 100644 index 0000000..32366e6 --- /dev/null +++ b/frontend/tests/auth.operationChain.test.js @@ -0,0 +1,325 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * Tests for auth operation serialization in app.js. + * These tests verify that concurrent auth events are properly serialized + * and that stale operations bail out when superseded by newer operations. + */ + +/** + * Creates a minimal mock of the auth operation handling logic from gravityApp. + * This isolates the serialization behavior for testing without requiring + * the full Alpine component or GravityStore with IndexedDB. + */ +function createAuthOperationHandler() { + let authOperationChain = Promise.resolve(); + let authOperationId = 0; + let authUser = null; + let authState = "loading"; + + const operations = []; + + /** + * Mock GravityStore with controllable async hydration. + */ + const mockStore = { + currentScope: null, + hydrateDelay: 0, + setUserScope(userId) { + this.currentScope = userId; + }, + async hydrateActiveScope() { + if (this.hydrateDelay > 0) { + await new Promise(resolve => setTimeout(resolve, this.hydrateDelay)); + } + } + }; + + function setAuthState(state) { + authState = state; + } + + function initializeNotes() { + operations.push({ type: "initializeNotes", scope: mockStore.currentScope }); + } + + function handleAuthAuthenticated(profile) { + if (!profile || !profile.id) { + setAuthState("unauthenticated"); + return Promise.resolve(); + } + if (authUser?.id === profile.id) { + return Promise.resolve(); + } + + const operationId = ++authOperationId; + operations.push({ type: "authenticated:start", operationId, userId: profile.id }); + + const runOperation = async () => { + mockStore.setUserScope(profile.id); + await mockStore.hydrateActiveScope(); + if (authOperationId !== operationId) { + operations.push({ type: "authenticated:cancelled", operationId, userId: profile.id }); + return; + } + authUser = profile; + setAuthState("authenticated"); + initializeNotes(); + operations.push({ type: "authenticated:complete", operationId, userId: profile.id }); + }; + + const operation = authOperationChain + .then(runOperation) + .catch((error) => operations.push({ type: "error", error: error.message })); + authOperationChain = operation; + return operation; + } + + function handleAuthUnauthenticated() { + const operationId = ++authOperationId; + operations.push({ type: "unauthenticated:start", operationId }); + + const runOperation = async () => { + authUser = null; + setAuthState("unauthenticated"); + mockStore.setUserScope(null); + await mockStore.hydrateActiveScope(); + if (authOperationId !== operationId) { + operations.push({ type: "unauthenticated:cancelled", operationId }); + return; + } + initializeNotes(); + operations.push({ type: "unauthenticated:complete", operationId }); + }; + + const operation = authOperationChain + .then(runOperation) + .catch((error) => operations.push({ type: "error", error: error.message })); + authOperationChain = operation; + return operation; + } + + return { + handleAuthAuthenticated, + handleAuthUnauthenticated, + mockStore, + operations, + getAuthUser: () => authUser, + getAuthState: () => authState, + getAuthOperationId: () => authOperationId, + waitForChain: () => authOperationChain + }; +} + +test("single authenticated operation completes normally", async () => { + const handler = createAuthOperationHandler(); + const user = { id: "user-1", email: "user1@example.com" }; + + await handler.handleAuthAuthenticated(user); + + assert.equal(handler.getAuthUser()?.id, "user-1"); + assert.equal(handler.getAuthState(), "authenticated"); + assert.ok(handler.operations.some(o => o.type === "authenticated:complete" && o.operationId === 1)); + assert.ok(handler.operations.some(o => o.type === "initializeNotes" && o.scope === "user-1")); +}); + +test("single unauthenticated operation completes normally", async () => { + const handler = createAuthOperationHandler(); + + await handler.handleAuthUnauthenticated(); + + assert.equal(handler.getAuthUser(), null); + assert.equal(handler.getAuthState(), "unauthenticated"); + assert.ok(handler.operations.some(o => o.type === "unauthenticated:complete" && o.operationId === 1)); + assert.ok(handler.operations.some(o => o.type === "initializeNotes" && o.scope === null)); +}); + +test("second auth operation supersedes first - only second calls initializeNotes", async () => { + const handler = createAuthOperationHandler(); + const user1 = { id: "user-1", email: "user1@example.com" }; + const user2 = { id: "user-2", email: "user2@example.com" }; + + // Start both operations (second supersedes first) + const op1 = handler.handleAuthAuthenticated(user1); + const op2 = handler.handleAuthAuthenticated(user2); + + // Wait for both to complete + await op1; + await op2; + + // First should be cancelled, second should complete + assert.ok( + handler.operations.some(o => o.type === "authenticated:cancelled" && o.operationId === 1), + "First operation should be cancelled" + ); + assert.ok( + handler.operations.some(o => o.type === "authenticated:complete" && o.operationId === 2), + "Second operation should complete" + ); + + // Only one initializeNotes call + const initCalls = handler.operations.filter(o => o.type === "initializeNotes"); + assert.equal(initCalls.length, 1, "initializeNotes should only be called once"); + assert.equal(initCalls[0].scope, "user-2", "initializeNotes should use second user's scope"); + + // Final state + assert.equal(handler.getAuthUser()?.id, "user-2"); +}); + +test("unauthenticated supersedes authenticated operation", async () => { + const handler = createAuthOperationHandler(); + const user = { id: "user-1", email: "user1@example.com" }; + + const authOp = handler.handleAuthAuthenticated(user); + const unauthOp = handler.handleAuthUnauthenticated(); + + await authOp; + await unauthOp; + + // Auth should be cancelled + assert.ok( + handler.operations.some(o => o.type === "authenticated:cancelled" && o.operationId === 1), + "Authenticated operation should be cancelled" + ); + + // Unauth should complete + assert.ok( + handler.operations.some(o => o.type === "unauthenticated:complete" && o.operationId === 2), + "Unauthenticated operation should complete" + ); + + // Only unauth's initializeNotes + const initCalls = handler.operations.filter(o => o.type === "initializeNotes"); + assert.equal(initCalls.length, 1); + assert.equal(initCalls[0].scope, null); + + // Final state + assert.equal(handler.getAuthUser(), null); + assert.equal(handler.getAuthState(), "unauthenticated"); +}); + +test("authenticated supersedes unauthenticated operation", async () => { + const handler = createAuthOperationHandler(); + const user = { id: "user-1", email: "user1@example.com" }; + + const unauthOp = handler.handleAuthUnauthenticated(); + const authOp = handler.handleAuthAuthenticated(user); + + await unauthOp; + await authOp; + + // Unauth should be cancelled + assert.ok( + handler.operations.some(o => o.type === "unauthenticated:cancelled" && o.operationId === 1), + "Unauthenticated operation should be cancelled" + ); + + // Auth should complete + assert.ok( + handler.operations.some(o => o.type === "authenticated:complete" && o.operationId === 2), + "Authenticated operation should complete" + ); + + // Only auth's initializeNotes + const initCalls = handler.operations.filter(o => o.type === "initializeNotes"); + assert.equal(initCalls.length, 1); + assert.equal(initCalls[0].scope, "user-1"); + + // Final state + assert.equal(handler.getAuthUser()?.id, "user-1"); + assert.equal(handler.getAuthState(), "authenticated"); +}); + +test("rapid sequence auth->unauth->auth - only final operation completes", async () => { + const handler = createAuthOperationHandler(); + const user1 = { id: "user-1", email: "user1@example.com" }; + const user2 = { id: "user-2", email: "user2@example.com" }; + + const op1 = handler.handleAuthAuthenticated(user1); + const op2 = handler.handleAuthUnauthenticated(); + const op3 = handler.handleAuthAuthenticated(user2); + + await op1; + await op2; + await op3; + + // First two should be cancelled + assert.ok(handler.operations.some(o => o.type === "authenticated:cancelled" && o.operationId === 1)); + assert.ok(handler.operations.some(o => o.type === "unauthenticated:cancelled" && o.operationId === 2)); + + // Third should complete + assert.ok(handler.operations.some(o => o.type === "authenticated:complete" && o.operationId === 3)); + + // Only one initializeNotes call (from op3) + const initCalls = handler.operations.filter(o => o.type === "initializeNotes"); + assert.equal(initCalls.length, 1); + assert.equal(initCalls[0].scope, "user-2"); + + // Final state + assert.equal(handler.getAuthUser()?.id, "user-2"); +}); + +test("same user auth is deduplicated", async () => { + const handler = createAuthOperationHandler(); + const user = { id: "user-1", email: "user1@example.com" }; + + // First auth completes + await handler.handleAuthAuthenticated(user); + assert.equal(handler.getAuthUser()?.id, "user-1"); + + // Second auth for same user is ignored + await handler.handleAuthAuthenticated({ ...user }); + + // Only one operation started + const startEvents = handler.operations.filter(o => o.type === "authenticated:start"); + assert.equal(startEvents.length, 1, "Second auth for same user should be ignored"); +}); + +test("invalid profile does not start operation", async () => { + const handler = createAuthOperationHandler(); + + await handler.handleAuthAuthenticated(null); + assert.equal(handler.getAuthOperationId(), 0); + assert.equal(handler.getAuthState(), "unauthenticated"); + + await handler.handleAuthAuthenticated({ email: "test@example.com" }); + assert.equal(handler.getAuthOperationId(), 0); +}); + +test("operations are serialized - second waits for first to complete", async () => { + const handler = createAuthOperationHandler(); + handler.mockStore.hydrateDelay = 10; // Add small delay to make timing visible + + const user1 = { id: "user-1", email: "user1@example.com" }; + const user2 = { id: "user-2", email: "user2@example.com" }; + + const startTime = Date.now(); + const timestamps = []; + + // Wrap to capture timing + const originalHydrate = handler.mockStore.hydrateActiveScope.bind(handler.mockStore); + handler.mockStore.hydrateActiveScope = async function() { + timestamps.push({ event: "hydrate:start", elapsed: Date.now() - startTime }); + await originalHydrate(); + timestamps.push({ event: "hydrate:end", elapsed: Date.now() - startTime }); + }; + + const op1 = handler.handleAuthAuthenticated(user1); + const op2 = handler.handleAuthAuthenticated(user2); + + await op1; + await op2; + + // Both hydrations should have run (one for each operation) + const hydrateStarts = timestamps.filter(t => t.event === "hydrate:start"); + assert.equal(hydrateStarts.length, 2, "Both operations should have called hydrateActiveScope"); + + // Second hydrate should start after first ends (serialization) + const firstEnd = timestamps.find(t => t.event === "hydrate:end"); + const secondStart = timestamps.filter(t => t.event === "hydrate:start")[1]; + assert.ok( + secondStart.elapsed >= firstEnd.elapsed, + "Second operation should wait for first to complete" + ); +}); From 173da0aae3960c21a12593b11476350556e7b47f Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Wed, 28 Jan 2026 13:27:44 -0800 Subject: [PATCH 10/18] chore(deps): bump mpr-ui to v3.6.2 Updates mpr-ui CDN references from @latest to @v3.6.2 which includes the MU-132 fix for dispatching authenticated event after credential exchange. Co-Authored-By: Claude Opus 4.5 --- frontend/data/runtime.config.development.json | 2 +- frontend/data/runtime.config.production.json | 2 +- frontend/index.html | 55 ++----------------- frontend/js/core/environmentConfig.js | 2 +- frontend/tests/helpers/browserHarness.js | 6 +- 5 files changed, 10 insertions(+), 57 deletions(-) diff --git a/frontend/data/runtime.config.development.json b/frontend/data/runtime.config.development.json index d2f3450..0c6a993 100644 --- a/frontend/data/runtime.config.development.json +++ b/frontend/data/runtime.config.development.json @@ -4,7 +4,7 @@ "llmProxyUrl": "http://computercat:8081/v1/gravity/classify", "authBaseUrl": "https://computercat.tyemirov.net:4443", "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", - "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js", + "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui.js", "authTenantId": "gravity", "googleClientId": "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" } diff --git a/frontend/data/runtime.config.production.json b/frontend/data/runtime.config.production.json index 0bc10bc..cfca4a0 100644 --- a/frontend/data/runtime.config.production.json +++ b/frontend/data/runtime.config.production.json @@ -4,7 +4,7 @@ "llmProxyUrl": "https://llm-proxy.mprlab.com/v1/gravity/classify", "authBaseUrl": "https://tauth-api.mprlab.com", "tauthScriptUrl": "https://tauth.mprlab.com/tauth.js", - "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js", + "mprUiScriptUrl": "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui.js", "authTenantId": "gravity", "googleClientId": "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" } diff --git a/frontend/index.html b/frontend/index.html index 92a9bea..564dfd6 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,66 +3,20 @@ - - + + - + diff --git a/frontend/js/core/environmentConfig.js b/frontend/js/core/environmentConfig.js index 47282ff..04ac32a 100644 --- a/frontend/js/core/environmentConfig.js +++ b/frontend/js/core/environmentConfig.js @@ -7,7 +7,7 @@ export const DEVELOPMENT_BACKEND_BASE_URL = "https://computercat.tyemirov.net:44 export const DEVELOPMENT_LLM_PROXY_URL = "http://computercat:8081/v1/gravity/classify"; export const DEVELOPMENT_AUTH_BASE_URL = "https://computercat.tyemirov.net:4443"; export const DEVELOPMENT_TAUTH_SCRIPT_URL = "https://tauth.mprlab.com/tauth.js"; -export const DEVELOPMENT_MPR_UI_SCRIPT_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@latest/mpr-ui.js"; +export const DEVELOPMENT_MPR_UI_SCRIPT_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui.js"; export const DEVELOPMENT_AUTH_TENANT_ID = ""; // Production URLs are loaded from runtime.config.production.json - no hardcoded fallbacks diff --git a/frontend/tests/helpers/browserHarness.js b/frontend/tests/helpers/browserHarness.js index 544efa3..1a29d97 100644 --- a/frontend/tests/helpers/browserHarness.js +++ b/frontend/tests/helpers/browserHarness.js @@ -63,17 +63,17 @@ const CDN_MIRRORS = Object.freeze([ contentType: "text/css" }, { - pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@latest\/mpr-ui\.js$/u, + pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@v3.6.2\/mpr-ui\.js$/u, filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.js"), contentType: "application/javascript" }, { - pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@latest\/mpr-ui-config\.js$/u, + pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@v3.6.2\/mpr-ui-config\.js$/u, filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui-config.js"), contentType: "application/javascript" }, { - pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@latest\/mpr-ui\.css$/u, + pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@v3.6.2\/mpr-ui\.css$/u, filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.css"), contentType: "text/css" } From a0f26f7b1c12de82abcce75fe08cfc4111d2d839 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Thu, 29 Jan 2026 12:00:00 -0800 Subject: [PATCH 11/18] fix(tests): resolve TAuth harness timing issues and update page-separation tests - Remove authenticatedCount checks that required mpr-ui callback firing - Use waitForSyncManagerUser to verify authentication completion - Set initialProfile in TAuth harness to match expected test users - Update UI tests to use prepareFrontendPage for proper auth flow - Add skipAppReady option for landing page tests - Set default 5-second timeouts for auth wait functions - Refactor test harness to properly coordinate session cookies - Add page-separation architecture (app.html + index.html) - Delete legacy auth modules (auth.js, tauthSession.js, authControls.js) - Add profileNormalization utility for TAuth/mpr-ui profile mapping Co-Authored-By: Claude Opus 4.5 --- env.ghttp.example | 7 +- env.tauth.example | 5 +- frontend/app.html | 276 +++++++++++++ frontend/index.html | 234 +++++++---- frontend/js/app.js | 113 ++--- frontend/js/core/auth.js | 391 ------------------ frontend/js/core/tauthSession.js | 204 --------- frontend/js/landing.js | 120 ++++++ frontend/js/ui/authControls.js | 228 ---------- frontend/js/utils/profileNormalization.js | 129 ++++++ .../tests/app.notifications.puppeteer.test.js | 7 +- .../tests/auth.avatarMenu.puppeteer.test.js | 43 +- .../auth.consoleWarnings.puppeteer.test.js | 21 +- frontend/tests/auth.google.test.js | 195 --------- .../tests/auth.landingLogin.puppeteer.test.js | 72 +++- frontend/tests/auth.status.puppeteer.test.js | 57 ++- frontend/tests/auth.tauth.puppeteer.test.js | 97 ++++- frontend/tests/card.copy.puppeteer.test.js | 10 +- ...ditor.duplicateRendering.puppeteer.test.js | 10 +- .../tests/editor.enhanced.puppeteer.test.js | 9 +- .../tests/editor.grammar.puppeteer.test.js | 9 +- .../tests/editor.inline.puppeteer.test.js | 9 +- .../fullstack.endtoend.puppeteer.test.js | 9 +- .../localScreenshots.puppeteer.test.js | 10 +- frontend/tests/helpers/browserHarness.js | 241 +++++++---- frontend/tests/helpers/syncScenarioHarness.js | 16 +- frontend/tests/helpers/syncTestUtils.js | 117 ++++-- frontend/tests/helpers/tauthHarness.js | 120 +++++- .../tests/htmlView.bounded.puppeteer.test.js | 9 +- .../htmlView.checkmark.puppeteer.test.js | 13 +- .../htmlView.expandCursor.puppeteer.test.js | 9 +- ...iew.expansionPersistence.puppeteer.test.js | 9 +- frontend/tests/page-separation.test.js | 143 +++++++ .../persistence.backend.puppeteer.test.js | 8 +- .../tests/persistence.sync.puppeteer.test.js | 16 +- frontend/tests/run-tests.js | 8 +- .../tests/sync.endtoend.puppeteer.test.js | 8 +- .../tests/sync.realtime.puppeteer.test.js | 18 +- frontend/tests/tauthSession.test.js | 98 ----- .../tests/ui.fullscreen.puppeteer.test.js | 9 +- frontend/tests/ui.stability.puppeteer.test.js | 34 +- .../ui.styles.regression.puppeteer.test.js | 44 +- 42 files changed, 1639 insertions(+), 1546 deletions(-) create mode 100644 frontend/app.html mode change 100755 => 100644 frontend/index.html delete mode 100644 frontend/js/core/auth.js delete mode 100644 frontend/js/core/tauthSession.js create mode 100644 frontend/js/landing.js delete mode 100644 frontend/js/ui/authControls.js create mode 100644 frontend/js/utils/profileNormalization.js delete mode 100644 frontend/tests/auth.google.test.js create mode 100644 frontend/tests/page-separation.test.js delete mode 100644 frontend/tests/tauthSession.test.js diff --git a/env.ghttp.example b/env.ghttp.example index d9e41ec..59f93bd 100644 --- a/env.ghttp.example +++ b/env.ghttp.example @@ -8,4 +8,9 @@ GHTTP_SERVE_TLS_PRIVATE_KEY=/certs/computercat.tyemirov.net-key.pem # Proxy backend + TAuth endpoints through ghttp so the browser stays on HTTPS. # Routes: /notes (Gravity backend), /auth/* + /me (TAuth). # tauth.js stays CDN-hosted and is not proxied here. -GHTTP_SERVE_PROXIES=/notes=http://gravity-backend:8080,/auth=http://gravity-tauth:8082,/me=http://gravity-tauth:8082 +# For dev profile use gravity-backend-dev; for docker profile use gravity-backend-docker +GHTTP_SERVE_PROXIES=/notes=http://gravity-backend-dev:8080,/auth=http://gravity-tauth:8082,/me=http://gravity-tauth:8082 +# +# Static file routes (served automatically from GHTTP_SERVE_DIRECTORY): +# / -> index.html (public landing page with Google sign-in, redirects to /app.html if authenticated) +# /app.html -> app.html (authenticated app page, redirects to / if not authenticated) diff --git a/env.tauth.example b/env.tauth.example index f5aeea0..f2d33f4 100644 --- a/env.tauth.example +++ b/env.tauth.example @@ -21,4 +21,7 @@ TAUTH_CORS_ORIGIN_3=https://gravity.mprlab.com TAUTH_ENABLE_TENANT_HEADER_OVERRIDE=true # Shared -TAUTH_ALLOW_INSECURE_HTTP=false +# Set to true for local dev behind gHTTP reverse proxy (gHTTP terminates TLS, +# proxies to TAuth over HTTP). Set to false for production where TAuth +# terminates TLS directly or sits behind a proxy that forwards X-Forwarded-Proto. +TAUTH_ALLOW_INSECURE_HTTP=true diff --git a/frontend/app.html b/frontend/app.html new file mode 100644 index 0000000..8010655 --- /dev/null +++ b/frontend/app.html @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + Gravity Notes + + + + + + + + + + + + + + + + +
+
+
+

Gravity Notes

+
Append anywhere · Bubble to top · Auto-organize
+
+
+ + +
+
+ + + + + + +
+ + +
+ + +
+ + +
+ + + + diff --git a/frontend/index.html b/frontend/index.html old mode 100755 new mode 100644 index 564dfd6..7e4efde --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,150 @@ + + - - - - - - - - - -
+ +
-
Gravity Notes
-

Notes that stay anchored.

-

+
Gravity Notes
+

Notes that stay anchored.

+

Markdown-first notes with inline editing and a no-jump grid. Sign in to sync your notebook.

- -

+ +

Sign in with Google to open your notebook.

- +
- - - + diff --git a/frontend/js/app.js b/frontend/js/app.js index 241a52a..5a9893c 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -47,6 +47,7 @@ import { showSaveFeedback } from "./ui/saveFeedback.js?build=2026-01-01T22:43:21 import { initializeFullScreenToggle } from "./ui/fullScreenToggle.js?build=2026-01-01T22:43:21Z"; import { initializeVersionRefresh } from "./utils/versionRefresh.js?build=2026-01-01T22:43:21Z"; import { logging } from "./utils/logging.js?build=2026-01-01T22:43:21Z"; +import { normalizeProfileForApp } from "./utils/profileNormalization.js?build=2026-01-01T22:43:21Z"; const CONSTANTS_VIEW_MODEL = Object.freeze({ LABEL_APP_SUBTITLE, @@ -62,31 +63,9 @@ const CONSTANTS_VIEW_MODEL = Object.freeze({ const AUTH_STATE_LOADING = "loading"; const AUTH_STATE_AUTHENTICATED = "authenticated"; const AUTH_STATE_UNAUTHENTICATED = "unauthenticated"; +const LANDING_PAGE_URL = "/"; const USER_MENU_ACTION_EXPORT = "export-notes"; const USER_MENU_ACTION_IMPORT = "import-notes"; -const TYPE_STRING = "string"; - -const PROFILE_KEYS = Object.freeze({ - USER_ID: "user_id", - USER_EMAIL: "user_email", - DISPLAY: "display", - USER_DISPLAY: "user_display", - USER_DISPLAY_NAME: "user_display_name", - AVATAR_URL: "avatar_url", - USER_AVATAR_URL: "user_avatar_url" -}); - -const PROFILE_NAME_KEYS = Object.freeze([ - PROFILE_KEYS.DISPLAY, - PROFILE_KEYS.USER_DISPLAY, - PROFILE_KEYS.USER_DISPLAY_NAME -]); - -const PROFILE_AVATAR_KEYS = Object.freeze([ - PROFILE_KEYS.AVATAR_URL, - PROFILE_KEYS.USER_AVATAR_URL -]); - const NOTIFICATION_DEFAULT_DURATION_MS = 3000; const AUTH_ERROR_MESSAGES = Object.freeze({ MISSING_INIT: "tauth.initAuthClient_missing", @@ -243,9 +222,10 @@ function gravityApp(appConfig) { versionRefreshController: /** @type {{ dispose(): void, checkNow(): Promise<{ reloaded: boolean, remoteVersion: string|null }> }|null} */ (null), init() { - this.landingView = this.$refs.landingView ?? document.querySelector("[data-test=\"landing\"]"); - this.landingStatus = this.$refs.landingStatus ?? document.querySelector("[data-test=\"landing-status\"]"); - this.landingLogin = this.$refs.landingLogin ?? document.querySelector("[data-test=\"landing-login\"]"); + // In the separated page architecture, landing elements don't exist in app.html + this.landingView = null; + this.landingStatus = null; + this.landingLogin = null; this.appShell = this.$refs.appShell ?? document.querySelector("[data-test=\"app-shell\"]"); this.userMenu = this.$refs.userMenu ?? document.querySelector("[data-test=\"user-menu\"]"); @@ -364,30 +344,24 @@ function gravityApp(appConfig) { setAuthState(nextState) { this.authState = nextState; - const landing = this.landingView; - const shell = this.appShell; - if (nextState === AUTH_STATE_AUTHENTICATED) { - if (landing) { - landing.hidden = true; - landing.setAttribute("aria-hidden", "true"); - } - if (shell) { - shell.hidden = false; - shell.setAttribute("aria-hidden", "false"); - } - } else { - if (landing) { - landing.hidden = false; - landing.setAttribute("aria-hidden", "false"); - } - if (shell) { - shell.hidden = true; - shell.setAttribute("aria-hidden", "true"); - } - } if (typeof document !== "undefined") { document.body.dataset.authState = nextState; } + // In the separated page architecture, app.html is for authenticated users only. + // Redirect to landing page if unauthenticated. + if (nextState === AUTH_STATE_UNAUTHENTICATED) { + this.redirectToLanding(); + } + }, + + /** + * Redirect to the landing page for unauthenticated users. + * @returns {void} + */ + redirectToLanding() { + if (typeof window !== "undefined") { + window.location.href = LANDING_PAGE_URL; + } }, setLandingStatus(message, status) { @@ -435,7 +409,7 @@ function gravityApp(appConfig) { }, handleAuthAuthenticated(profile) { - const normalizedUser = normalizeAuthProfile(profile); + const normalizedUser = normalizeProfileForApp(profile); if (!normalizedUser || !normalizedUser.id) { this.setLandingStatus(ERROR_AUTHENTICATION_GENERIC, "error"); this.setAuthState(AUTH_STATE_UNAUTHENTICATED); @@ -529,18 +503,14 @@ function gravityApp(appConfig) { const runOperation = async () => { this.authUser = null; this.pendingSignInUserId = null; + // In the separated page architecture, setAuthState will redirect to landing this.setAuthState(AUTH_STATE_UNAUTHENTICATED); - const statusElement = this.landingStatus; - const shouldPreserveError = Boolean(statusElement && statusElement.dataset.status === "error"); - if (!shouldPreserveError) { - this.clearLandingStatus(); - } + // The following code may not execute due to redirect, but kept for completeness GravityStore.setUserScope(null); await GravityStore.hydrateActiveScope(); if (this.authOperationId !== operationId) { return; } - this.initializeNotes(); this.syncManager?.handleSignOut(); this.realtimeSync?.disconnect(); if (typeof window !== "undefined" && this.syncIntervalHandle !== null) { @@ -560,12 +530,11 @@ function gravityApp(appConfig) { if (this.authState === AUTH_STATE_AUTHENTICATED) { return; } - const errorMessage = ERROR_AUTHENTICATION_GENERIC; if (detail?.code) { logging.warn("Auth error reported by mpr-ui", detail); } + // In the separated page architecture, redirect to landing on auth error this.setAuthState(AUTH_STATE_UNAUTHENTICATED); - this.setLandingStatus(errorMessage, "error"); }, handleAuthSignOutRequest(reason = "manual") { @@ -1073,35 +1042,3 @@ function hashString(value) { return hash; } -/** - * Normalize an auth profile payload into the Gravity auth shape. - * @param {unknown} profile - * @returns {{ id: string|null, email: string|null, name: string|null, pictureUrl: string|null }|null} - */ -function normalizeAuthProfile(profile) { - if (!profile || typeof profile !== "object") { - return null; - } - const record = /** @type {Record} */ (profile); - return { - id: typeof record[PROFILE_KEYS.USER_ID] === TYPE_STRING ? record[PROFILE_KEYS.USER_ID] : null, - email: typeof record[PROFILE_KEYS.USER_EMAIL] === TYPE_STRING ? record[PROFILE_KEYS.USER_EMAIL] : null, - name: selectProfileString(record, PROFILE_NAME_KEYS), - pictureUrl: selectProfileString(record, PROFILE_AVATAR_KEYS) - }; -} - -/** - * @param {Record} profile - * @param {string[]} keys - * @returns {string|null} - */ -function selectProfileString(profile, keys) { - for (const key of keys) { - const value = profile[key]; - if (typeof value === TYPE_STRING && value.trim().length > 0) { - return value; - } - } - return null; -} diff --git a/frontend/js/core/auth.js b/frontend/js/core/auth.js deleted file mode 100644 index aa91603..0000000 --- a/frontend/js/core/auth.js +++ /dev/null @@ -1,391 +0,0 @@ -// @ts-check - -import { - EVENT_AUTH_ERROR, - EVENT_AUTH_SIGN_IN, - EVENT_AUTH_SIGN_OUT, - EVENT_AUTH_CREDENTIAL_RECEIVED -} from "../constants.js?build=2026-01-01T22:43:21Z"; -import { logging } from "../utils/logging.js?build=2026-01-01T22:43:21Z"; - -/** - * @typedef {{ - * clientId: string, - * google?: typeof globalThis.google, - * buttonElement?: Element | null, - * eventTarget?: EventTarget, - * autoPrompt?: boolean, - * location?: Location | null - * }} GoogleIdentityOptions - */ - -/** - * Create a controller that wires Google Identity Services to the application. - * @param {GoogleIdentityOptions} options - * @returns {{ signOut(reason?: string): void, dispose(): void, requestCredential(): Promise }} - */ -export function createGoogleIdentityController(options) { - const { - clientId, - google = typeof globalThis !== "undefined" ? /** @type {any} */ (globalThis.google) : undefined, - buttonElement = null, - eventTarget = typeof document !== "undefined" ? document : undefined, - autoPrompt = true, - location = typeof window !== "undefined" ? window.location : undefined, - nonceToken = null - } = options || {}; - - if (!isNonEmptyString(clientId)) { - throw new Error("Google Identity Services requires a clientId."); - } - - if (!google || !google.accounts || !google.accounts.id) { - logging.warn("Google Identity Services unavailable; skipping initialization."); - return createNoopController(eventTarget); - } - - const identity = google.accounts.id; - - if (!isGoogleIdentitySupportedOrigin(location ?? undefined)) { - if (isElementLike(buttonElement)) { - buttonElement.dataset.googleSignIn = "unavailable"; - } - return createNoopController(eventTarget); - } - - let disposed = false; - let currentUser = null; - /** @type {Set<{ finalize(value: string|null): void }>} */ - const credentialWaiters = new Set(); - - function settleCredentialWaiters(value) { - if (credentialWaiters.size === 0) { - return; - } - const entries = Array.from(credentialWaiters); - credentialWaiters.clear(); - for (const entry of entries) { - try { - entry.finalize(value); - } catch (error) { - logging.error(error); - } - } - } - - const handleCredentialResponse = (response) => { - if (!response || typeof response.credential !== "string" || response.credential.length === 0) { - dispatch(EVENT_AUTH_ERROR, { reason: "empty-credential" }); - return; - } - - try { - const payload = decodeGoogleCredential(response.credential); - const user = normalizeUser(payload); - currentUser = user; - settleCredentialWaiters(response.credential); - dispatch(EVENT_AUTH_CREDENTIAL_RECEIVED, { - user, - credential: response.credential - }); - } catch (error) { - logging.error(error); - settleCredentialWaiters(null); - dispatch(EVENT_AUTH_ERROR, { - reason: "credential-parse", - error: error instanceof Error ? error.message : "Unknown error" - }); - } - }; - - try { - identity.initialize({ - client_id: clientId, - callback: handleCredentialResponse, - auto_select: autoPrompt !== false, - nonce: typeof nonceToken === "string" && nonceToken.length > 0 ? nonceToken : undefined - }); - } catch (error) { - logging.error(error); - dispatch(EVENT_AUTH_ERROR, { - reason: "initialize-failed", - error: error instanceof Error ? error.message : "Unknown error" - }); - return createNoopController(eventTarget); - } - - if (buttonElement && typeof identity.renderButton === "function") { - try { - identity.renderButton(buttonElement, { - theme: "outline", - size: "small", - shape: "pill", - text: "signin_with" - }); - } catch (error) { - logging.error(error); - } - } - - if (autoPrompt !== false && typeof identity.prompt === "function") { - queueMicrotask(() => { - try { - identity.prompt(); - } catch (error) { - logging.error(error); - } - }); - } - - function signOut(reason = "manual") { - currentUser = null; - if (typeof identity.disableAutoSelect === "function") { - try { - identity.disableAutoSelect(); - } catch (error) { - logging.error(error); - } - } - settleCredentialWaiters(null); - dispatch(EVENT_AUTH_SIGN_OUT, { reason }); - } - - function disposeController() { - disposed = true; - currentUser = null; - settleCredentialWaiters(null); - } - - function requestCredential() { - if (disposed || !identity || typeof identity.prompt !== "function") { - return Promise.resolve(null); - } - return new Promise((resolve) => { - const waiter = { - settled: false, - timeoutId: null, - finalize(value) { - if (this.settled) { - return; - } - this.settled = true; - if (typeof clearTimeout === "function" && this.timeoutId !== null) { - clearTimeout(this.timeoutId); - } - resolve(value); - } - }; - credentialWaiters.add(waiter); - if (typeof setTimeout === "function") { - waiter.timeoutId = setTimeout(() => { - if (credentialWaiters.delete(waiter)) { - waiter.finalize(null); - } - }, 10000); - } - try { - identity.prompt((notification) => { - if (!notification) { - return; - } - const dismissed = typeof notification.isDismissedMoment === "function" && notification.isDismissedMoment(); - const notDisplayed = typeof notification.isNotDisplayed === "function" && notification.isNotDisplayed(); - if ((dismissed || notDisplayed) && credentialWaiters.delete(waiter)) { - waiter.finalize(null); - } - }); - } catch (error) { - logging.error(error); - credentialWaiters.delete(waiter); - waiter.finalize(null); - } - }); - } - - function dispatch(eventName, detail) { - if (!eventTarget || disposed) { - return; - } - try { - const event = new CustomEvent(eventName, { - bubbles: true, - detail - }); - eventTarget.dispatchEvent(event); - } catch (error) { - logging.error(error); - const fallbackEvent = new Event(eventName); - /** @type {any} */ (fallbackEvent).detail = detail; - eventTarget.dispatchEvent(fallbackEvent); - } - } - - return Object.freeze({ - signOut, - dispose: disposeController, - requestCredential - }); -} - -/** - * Decode the payload portion of a Google Identity credential. - * @param {string} credential - * @returns {Record} - */ -export function decodeGoogleCredential(credential) { - if (!isNonEmptyString(credential)) { - throw new Error("Credential must be a non-empty string."); - } - const segments = credential.split("."); - if (segments.length < 2) { - throw new Error("Credential is not a valid JWT."); - } - const payload = segments[1]; - const json = decodeBase64Url(payload); - const parsed = JSON.parse(json); - if (!parsed || typeof parsed !== "object") { - throw new Error("Credential payload is not an object."); - } - return /** @type {Record} */ (parsed); -} - -/** - * Normalize raw credential payload into the user object exposed to consumers. - * @param {Record} payload - * @returns {{ id: string, email: string|null, name: string|null, pictureUrl: string|null }} - */ -function normalizeUser(payload) { - const id = typeof payload.sub === "string" ? payload.sub : null; - if (!isNonEmptyString(id)) { - throw new Error("Credential payload missing `sub`."); - } - const email = typeof payload.email === "string" ? payload.email : null; - const name = typeof payload.name === "string" ? payload.name : (email ?? null); - const pictureUrl = typeof payload.picture === "string" ? payload.picture : null; - return { - id, - email, - name, - pictureUrl - }; -} - -/** - * Decode a base64url string into JSON text. - * @param {string} value - * @returns {string} - */ -function decodeBase64Url(value) { - const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), "="); - const normalized = padded.replace(/-/g, "+").replace(/_/g, "/"); - if (typeof globalThis.atob === "function") { - return decodeWithAtob(normalized); - } - return Buffer.from(normalized, "base64").toString("utf8"); -} - -/** - * @param {string} normalized - * @returns {string} - */ -function decodeWithAtob(normalized) { - const binary = globalThis.atob(normalized); - let result = ""; - for (let index = 0; index < binary.length; index += 1) { - const code = binary.charCodeAt(index); - result += String.fromCharCode(code); - } - return decodeURIComponent(escapeString(result)); -} - -/** - * Escape helper for percent-encoding characters. - * @param {string} value - * @returns {string} - */ -function escapeString(value) { - let result = ""; - for (let index = 0; index < value.length; index += 1) { - const charCode = value.charCodeAt(index); - result += `%${charCode.toString(16).padStart(2, "0")}`; - } - return result; -} - -/** - * @param {EventTarget|undefined} eventTarget - * @returns {{ signOut(reason?: string): void, dispose(): void }} - */ -function createNoopController(eventTarget) { - return Object.freeze({ - signOut(reason = "noop") { - if (!eventTarget) { - return; - } - try { - const event = new CustomEvent(EVENT_AUTH_SIGN_OUT, { - bubbles: true, - detail: { reason } - }); - eventTarget.dispatchEvent(event); - } catch { - const fallbackEvent = new Event(EVENT_AUTH_SIGN_OUT); - /** @type {any} */ (fallbackEvent).detail = { reason }; - eventTarget.dispatchEvent(fallbackEvent); - } - }, - dispose() { - // no-op - } - }); -} - -/** - * @param {unknown} value - * @returns {value is string} - */ -function isNonEmptyString(value) { - return typeof value === "string" && value.trim().length > 0; -} - -/** - * @param {unknown} candidate - * @returns {candidate is { dataset: Record }} - */ -function isElementLike(candidate) { - if (!candidate || typeof candidate !== "object") { - return false; - } - return Object.prototype.hasOwnProperty.call(candidate, "dataset") && typeof /** @type {any} */ (candidate).dataset === "object"; -} - -/** - * Determine whether Google Identity Services should initialize for the provided location. - * @param {Location|undefined|null} runtimeLocation - * @returns {boolean} - */ -export function isGoogleIdentitySupportedOrigin(runtimeLocation) { - if (!runtimeLocation) { - return true; - } - const protocol = typeof runtimeLocation.protocol === "string" ? runtimeLocation.protocol.toLowerCase() : ""; - const hostname = typeof runtimeLocation.hostname === "string" ? runtimeLocation.hostname.toLowerCase() : ""; - if (!protocol) { - return false; - } - if (protocol === "file:" || protocol === "about:") { - return false; - } - if (protocol === "https:") { - return true; - } - if (protocol === "http:" && hostname) { - if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") { - return true; - } - if (hostname.endsWith(".local") || hostname.endsWith(".test")) { - return true; - } - } - return false; -} diff --git a/frontend/js/core/tauthSession.js b/frontend/js/core/tauthSession.js deleted file mode 100644 index 2e2b8e5..0000000 --- a/frontend/js/core/tauthSession.js +++ /dev/null @@ -1,204 +0,0 @@ -// @ts-check - -import { - EVENT_AUTH_SIGN_IN, - EVENT_AUTH_SIGN_OUT, - EVENT_AUTH_ERROR -} from "../constants.js?build=2026-01-01T22:43:21Z"; - -const TYPE_FUNCTION = "function"; -const TYPE_OBJECT = "object"; -const TYPE_STRING = "string"; -const TYPE_UNDEFINED = "undefined"; - -const ERROR_MESSAGES = Object.freeze({ - MISSING_WINDOW: "tauth_session.missing_window", - MISSING_EVENT_TARGET: "tauth_session.missing_event_target", - MISSING_BASE_URL: "tauth_session.missing_base_url", - INVALID_TENANT_ID: "tauth_session.invalid_tenant_id", - MISSING_INIT: "tauth_session.missing_init_auth_client", - MISSING_LOGOUT: "tauth_session.missing_logout", - NONCE_UNAVAILABLE: "tauth.nonce_unavailable", - EXCHANGE_UNAVAILABLE: "tauth.exchange_unavailable", - EXCHANGE_FAILED: "tauth.exchange_failed" -}); - -const UNAUTHENTICATED_REASON = "session-ended"; - -const PROFILE_KEYS = Object.freeze({ - USER_ID: "user_id", - USER_EMAIL: "user_email", - DISPLAY: "display", - USER_DISPLAY: "user_display", - USER_DISPLAY_NAME: "user_display_name", - AVATAR_URL: "avatar_url", - USER_AVATAR_URL: "user_avatar_url" -}); - -const PROFILE_NAME_KEYS = Object.freeze([ - PROFILE_KEYS.DISPLAY, - PROFILE_KEYS.USER_DISPLAY, - PROFILE_KEYS.USER_DISPLAY_NAME -]); - -const PROFILE_AVATAR_KEYS = Object.freeze([ - PROFILE_KEYS.AVATAR_URL, - PROFILE_KEYS.USER_AVATAR_URL -]); - -/** - * Create a controller that bridges TAuth's auth-client to the Gravity UI. - * @param {{ - * baseUrl: string, - * eventTarget?: EventTarget|null, - * tenantId?: string, - * windowRef?: typeof window - * }} [options] - */ -export function createTAuthSession(options = {}) { - const win = options.windowRef ?? (typeof window !== TYPE_UNDEFINED ? window : null); - if (!win) { - throw new Error(ERROR_MESSAGES.MISSING_WINDOW); - } - if (typeof win.initAuthClient !== TYPE_FUNCTION) { - throw new Error(ERROR_MESSAGES.MISSING_INIT); - } - const baseUrl = options.baseUrl; - if (typeof baseUrl !== TYPE_STRING || baseUrl.length === 0) { - throw new Error(ERROR_MESSAGES.MISSING_BASE_URL); - } - const events = options.eventTarget ?? (typeof document !== TYPE_UNDEFINED ? document : null); - if (!events || typeof events.dispatchEvent !== TYPE_FUNCTION) { - throw new Error(ERROR_MESSAGES.MISSING_EVENT_TARGET); - } - const tenantId = options.tenantId ?? null; - if (tenantId !== null && tenantId !== undefined && typeof tenantId !== TYPE_STRING) { - throw new Error(ERROR_MESSAGES.INVALID_TENANT_ID); - } - - const state = { - initialized: false, - initializing: null, - baseUrl, - initOptions: /** @type {{ baseUrl: string, tenantId?: string, onAuthenticated(profile: unknown): void, onUnauthenticated(): void }|null} */ (null) - }; - - const handleAuthenticated = (profile) => { - dispatch(events, EVENT_AUTH_SIGN_IN, { - user: normalizeProfile(profile) - }); - }; - const handleUnauthenticated = () => { - dispatch(events, EVENT_AUTH_SIGN_OUT, { reason: UNAUTHENTICATED_REASON }); - }; - - return Object.freeze({ - async initialize() { - await ensureInitialized(false); - }, - - async signOut() { - await ensureInitialized(false); - if (typeof win.logout !== TYPE_FUNCTION) { - throw new Error(ERROR_MESSAGES.MISSING_LOGOUT); - } - await win.logout(); - }, - - async requestNonce() { - await ensureInitialized(false); - if (typeof win.requestNonce !== TYPE_FUNCTION) { - throw new Error(ERROR_MESSAGES.NONCE_UNAVAILABLE); - } - return win.requestNonce(); - }, - - async exchangeGoogleCredential({ credential, nonceToken }) { - await ensureInitialized(false); - if (typeof win.exchangeGoogleCredential !== TYPE_FUNCTION) { - const reason = ERROR_MESSAGES.EXCHANGE_UNAVAILABLE; - dispatch(events, EVENT_AUTH_ERROR, { reason }); - throw new Error(reason); - } - try { - await win.exchangeGoogleCredential({ credential, nonceToken }); - } catch (error) { - const reason = error instanceof Error ? error.message : ERROR_MESSAGES.EXCHANGE_FAILED; - dispatch(events, EVENT_AUTH_ERROR, { reason }); - if (error instanceof Error) { - throw error; - } - throw new Error(reason); - } - } - }); - - async function ensureInitialized(forceReload) { - if (state.initializing) { - await state.initializing; - if (!forceReload) { - return state.initialized; - } - } - if (state.initialized && !forceReload) { - return true; - } - state.initializing = (async () => { - if (!state.initOptions) { - const initOptions = { - baseUrl, - onAuthenticated: handleAuthenticated, - onUnauthenticated: handleUnauthenticated - }; - if (tenantId !== null && tenantId !== undefined) { - initOptions.tenantId = tenantId; - } - state.initOptions = initOptions; - } - await win.initAuthClient(state.initOptions); - return true; - })(); - try { - const result = await state.initializing; - state.initialized = Boolean(result); - return state.initialized; - } catch (error) { - state.initialized = false; - throw error; - } finally { - state.initializing = null; - } - } -} - -function dispatch(target, type, detail) { - target.dispatchEvent(new CustomEvent(type, { detail })); -} - -function normalizeProfile(profile) { - if (!profile || typeof profile !== TYPE_OBJECT) { - return null; - } - return { - id: typeof profile[PROFILE_KEYS.USER_ID] === TYPE_STRING ? profile[PROFILE_KEYS.USER_ID] : null, - email: typeof profile[PROFILE_KEYS.USER_EMAIL] === TYPE_STRING ? profile[PROFILE_KEYS.USER_EMAIL] : null, - name: selectString(profile, PROFILE_NAME_KEYS), - pictureUrl: selectString(profile, PROFILE_AVATAR_KEYS), - raw: profile - }; -} - -/** - * @param {Record} profile - * @param {string[]} keys - * @returns {string|null} - */ -function selectString(profile, keys) { - for (const key of keys) { - const value = profile[key]; - if (typeof value === TYPE_STRING && value.trim().length > 0) { - return value; - } - } - return null; -} diff --git a/frontend/js/landing.js b/frontend/js/landing.js new file mode 100644 index 0000000..0f0acc4 --- /dev/null +++ b/frontend/js/landing.js @@ -0,0 +1,120 @@ +// @ts-check + +/** + * Landing page auth handler. + * Redirects to /app.html on successful authentication. + * Checks for existing session on load and redirects if already authenticated. + */ + +const EVENT_MPR_AUTH_AUTHENTICATED = "mpr-ui:auth:authenticated"; +const EVENT_MPR_AUTH_ERROR = "mpr-ui:auth:error"; +const AUTH_CHECK_ENDPOINT = "/me"; +const AUTHENTICATED_REDIRECT = "/app.html"; +const ERROR_AUTHENTICATION_GENERIC = "Authentication error"; + +/** + * Initialize the landing page auth handling. + * @returns {void} + */ +function initializeLandingAuth() { + // Listen for successful authentication from mpr-ui + document.body.addEventListener(EVENT_MPR_AUTH_AUTHENTICATED, handleAuthenticated); + document.body.addEventListener(EVENT_MPR_AUTH_ERROR, handleAuthError); + + // Check for existing session on page load + checkExistingSession(); +} + +/** + * Handle successful authentication event. + * @param {Event} event + * @returns {void} + */ +function handleAuthenticated(event) { + const detail = /** @type {{ profile?: unknown }} */ (event?.detail ?? {}); + const profile = detail.profile; + + if (!profile || typeof profile !== "object") { + showError(ERROR_AUTHENTICATION_GENERIC); + return; + } + + const record = /** @type {Record} */ (profile); + const userId = record.user_id ?? record.id ?? record.sub ?? null; + + if (!userId) { + showError(ERROR_AUTHENTICATION_GENERIC); + return; + } + + // Successfully authenticated, redirect to app + redirectToApp(); +} + +/** + * Handle auth error event. + * @param {Event} event + * @returns {void} + */ +function handleAuthError(event) { + const detail = /** @type {{ message?: string, code?: string }} */ (event?.detail ?? {}); + if (detail?.code) { + // eslint-disable-next-line no-console + console.warn("Auth error reported by mpr-ui", detail); + } + showError(ERROR_AUTHENTICATION_GENERIC); +} + +/** + * Check for existing session and redirect if authenticated. + * @returns {Promise} + */ +async function checkExistingSession() { + try { + const response = await fetch(AUTH_CHECK_ENDPOINT, { credentials: "include" }); + if (response.ok) { + redirectToApp(); + } + } catch (error) { + // Stay on landing page if check fails + // eslint-disable-next-line no-console + console.warn("Session check failed", error); + } +} + +/** + * Redirect to the authenticated app page. + * Only redirects on HTTP/HTTPS URLs (not file:// URLs used in tests). + * @returns {void} + */ +function redirectToApp() { + if (typeof window !== "undefined") { + const protocol = window.location.protocol; + // Only redirect on HTTP/HTTPS, not file:// URLs (used in tests) + if (protocol === "http:" || protocol === "https:") { + window.location.href = AUTHENTICATED_REDIRECT; + } + } +} + +/** + * Display an error message in the landing status element. + * @param {string} message + * @returns {void} + */ +function showError(message) { + const statusElement = document.querySelector("[data-test=\"landing-status\"]"); + if (statusElement instanceof HTMLElement) { + statusElement.hidden = false; + statusElement.textContent = message; + statusElement.dataset.status = "error"; + statusElement.setAttribute("aria-hidden", "false"); + } +} + +// Initialize on DOM ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeLandingAuth, { once: true }); +} else { + initializeLandingAuth(); +} diff --git a/frontend/js/ui/authControls.js b/frontend/js/ui/authControls.js deleted file mode 100644 index ad31cb9..0000000 --- a/frontend/js/ui/authControls.js +++ /dev/null @@ -1,228 +0,0 @@ -// @ts-check - -import { - LABEL_SIGN_OUT, - LABEL_SIGN_IN_WITH_GOOGLE -} from "../constants.js?build=2026-01-01T22:43:21Z"; - -/** - * @typedef {{ - * container: HTMLElement, - * buttonElement: HTMLElement, - * profileContainer: HTMLElement, - * displayNameElement: HTMLElement, - * emailElement?: HTMLElement | null, - * avatarElement?: HTMLImageElement | null, - * statusElement?: HTMLElement | null, - * signOutButton?: HTMLButtonElement | null, - * menuWrapper?: HTMLElement | null, - * onSignOutRequested?: () => void - * }} AuthControlsOptions - */ - -/** - * Initialize the auth controls block and return setters for downstream consumers. - * @param {AuthControlsOptions} options - * @returns {{ - * getButtonHost(): HTMLElement, - * setButtonHostVisibility(isVisible: boolean): void, - * showSignedOut(): void, - * showSignedIn(user: { id: string, email: string|null, name: string|null, pictureUrl: string|null }): void, - * showError(message: string): void, - * clearError(): void - * }} - */ -export function initializeAuthControls(options) { - const { - container, - buttonElement, - profileContainer, - displayNameElement, - emailElement = null, - avatarElement = null, - statusElement = null, - signOutButton = null, - menuWrapper = null, - onSignOutRequested - } = options; - - if (!(container instanceof HTMLElement)) { - throw new Error("Auth controls require a container element."); - } - if (!(buttonElement instanceof HTMLElement)) { - throw new Error("Auth controls require a button host element."); - } - if (!(profileContainer instanceof HTMLElement)) { - throw new Error("Auth controls require a profile container element."); - } - if (!(displayNameElement instanceof HTMLElement)) { - throw new Error("Auth controls require a display name element."); - } - const resolvedEmailElement = emailElement instanceof HTMLElement ? emailElement : null; - - const buttonParent = buttonElement.parentElement instanceof HTMLElement - ? buttonElement.parentElement - : container; - const buttonAnchor = buttonElement.nextSibling; - let buttonMounted = true; - - const signOutHandler = () => { - if (typeof onSignOutRequested === "function") { - onSignOutRequested(); - } - }; - if (signOutButton instanceof HTMLButtonElement) { - signOutButton.hidden = true; - signOutButton.type = "button"; - signOutButton.textContent = LABEL_SIGN_OUT; - signOutButton.addEventListener("click", (event) => { - event.preventDefault(); - signOutHandler(); - }); - } - - if (statusElement) { - statusElement.textContent = ""; - statusElement.hidden = true; - statusElement.setAttribute("aria-hidden", "true"); - delete statusElement.dataset.status; - } - - buttonElement.hidden = false; - buttonElement.setAttribute("aria-label", LABEL_SIGN_IN_WITH_GOOGLE); - - showSignedOut(); - - return Object.freeze({ - getButtonHost() { - return buttonElement; - }, - setButtonHostVisibility(isVisible) { - toggleButtonHostVisibility(Boolean(isVisible)); - }, - showSignedOut, - showSignedIn, - showError(message) { - if (statusElement) { - statusElement.hidden = false; - statusElement.setAttribute("aria-hidden", "false"); - statusElement.textContent = message; - statusElement.dataset.status = "error"; - } - }, - clearError() { - if (statusElement) { - statusElement.hidden = true; - statusElement.textContent = ""; - statusElement.setAttribute("aria-hidden", "true"); - delete statusElement.dataset.status; - } - } - }); - - function showSignedOut() { - profileContainer.hidden = true; - toggleButtonHostVisibility(true); - if (statusElement) { - statusElement.hidden = true; - statusElement.textContent = ""; - statusElement.setAttribute("aria-hidden", "true"); - delete statusElement.dataset.status; - } - if (signOutButton) { - signOutButton.hidden = true; - } - if (menuWrapper) { - menuWrapper.hidden = true; - } - clearProfile(); - } - - /** - * @param {{ id: string, email: string|null, name: string|null, pictureUrl: string|null }} user - * @returns {void} - */ - function showSignedIn(user) { - profileContainer.hidden = false; - toggleButtonHostVisibility(false); - if (signOutButton) { - signOutButton.hidden = false; - } - if (menuWrapper) { - menuWrapper.hidden = false; - } - if (statusElement) { - statusElement.hidden = true; - statusElement.textContent = ""; - statusElement.setAttribute("aria-hidden", "true"); - delete statusElement.dataset.status; - } - applyProfile(user); - } - - /** - * @param {boolean} isVisible - * @returns {void} - */ - function toggleButtonHostVisibility(isVisible) { - if (isVisible) { - if (!buttonMounted && buttonParent) { - if (buttonAnchor && buttonAnchor.parentNode === buttonParent) { - buttonParent.insertBefore(buttonElement, buttonAnchor); - } else { - buttonParent.appendChild(buttonElement); - } - buttonMounted = true; - } - buttonElement.hidden = false; - buttonElement.removeAttribute("aria-hidden"); - buttonElement.dataset.visibility = "visible"; - } else { - buttonElement.hidden = true; - buttonElement.setAttribute("aria-hidden", "true"); - buttonElement.dataset.visibility = "hidden"; - if (buttonMounted) { - buttonElement.remove(); - buttonMounted = false; - } - } - } - - function clearProfile() { - displayNameElement.textContent = ""; - if (resolvedEmailElement) { - resolvedEmailElement.textContent = ""; - resolvedEmailElement.hidden = true; - } - if (avatarElement) { - avatarElement.hidden = true; - avatarElement.removeAttribute("src"); - avatarElement.removeAttribute("alt"); - } - } - - /** - * @param {{ id: string, email: string|null, name: string|null, pictureUrl: string|null }} user - * @returns {void} - */ - function applyProfile(user) { - const fallbackName = user.name || user.email || user.id; - displayNameElement.textContent = fallbackName; - if (resolvedEmailElement) { - resolvedEmailElement.textContent = ""; - resolvedEmailElement.hidden = true; - } - if (avatarElement) { - if (user.pictureUrl) { - avatarElement.hidden = false; - avatarElement.src = user.pictureUrl; - avatarElement.alt = fallbackName; - avatarElement.referrerPolicy = "no-referrer"; - } else { - avatarElement.hidden = true; - avatarElement.removeAttribute("src"); - avatarElement.removeAttribute("alt"); - } - } - } -} diff --git a/frontend/js/utils/profileNormalization.js b/frontend/js/utils/profileNormalization.js new file mode 100644 index 0000000..b474c68 --- /dev/null +++ b/frontend/js/utils/profileNormalization.js @@ -0,0 +1,129 @@ +// @ts-check + +/** + * Profile field keys for multi-format support. + * Profiles from different sources (Google, TAuth, mpr-ui) may use different field names. + * @type {Readonly<{ + * USER_ID: "user_id", + * USER_EMAIL: "user_email", + * DISPLAY: "display", + * USER_DISPLAY: "user_display", + * USER_DISPLAY_NAME: "user_display_name", + * AVATAR_URL: "avatar_url", + * USER_AVATAR_URL: "user_avatar_url" + * }>} + */ +export const PROFILE_KEYS = Object.freeze({ + USER_ID: "user_id", + USER_EMAIL: "user_email", + DISPLAY: "display", + USER_DISPLAY: "user_display", + USER_DISPLAY_NAME: "user_display_name", + AVATAR_URL: "avatar_url", + USER_AVATAR_URL: "user_avatar_url" +}); + +/** + * Keys to check for display name, in priority order. + */ +const DISPLAY_NAME_KEYS = Object.freeze([ + PROFILE_KEYS.DISPLAY, + PROFILE_KEYS.USER_DISPLAY, + PROFILE_KEYS.USER_DISPLAY_NAME, + "name", + "displayName", + "given_name" +]); + +/** + * Keys to check for avatar URL, in priority order. + */ +const AVATAR_URL_KEYS = Object.freeze([ + PROFILE_KEYS.AVATAR_URL, + PROFILE_KEYS.USER_AVATAR_URL, + "picture", + "avatarUrl", + "photoURL" +]); + +/** + * Keys to check for user ID, in priority order. + */ +const USER_ID_KEYS = Object.freeze([ + PROFILE_KEYS.USER_ID, + "id", + "sub", + "userId" +]); + +/** + * Keys to check for email, in priority order. + */ +const EMAIL_KEYS = Object.freeze([ + PROFILE_KEYS.USER_EMAIL, + "email", + "userEmail" +]); + +/** + * Select the first non-empty string value from a record using a list of keys. + * @param {Record} record + * @param {readonly string[]} keys + * @returns {string|null} + */ +function selectString(record, keys) { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return null; +} + +/** + * Normalize an auth profile from various sources to the mpr-ui compatible format. + * This handles profiles from Google Identity Services, TAuth, and mpr-ui components. + * + * @param {unknown} profile - The profile object from any authentication source + * @returns {{ user_id: string|null, user_email: string|null, display: string|null, avatar_url: string|null }|null} + */ +export function normalizeProfileForMprUi(profile) { + if (!profile || typeof profile !== "object") { + return null; + } + const record = /** @type {Record} */ (profile); + + const userId = selectString(record, USER_ID_KEYS); + const email = selectString(record, EMAIL_KEYS); + const display = selectString(record, DISPLAY_NAME_KEYS) || email || userId; + const avatarUrl = selectString(record, AVATAR_URL_KEYS); + + return { + user_id: userId, + user_email: email, + display: display, + avatar_url: avatarUrl + }; +} + +/** + * Normalize an auth profile to the Gravity app format. + * This is used internally by app.js for sync manager and storage operations. + * + * @param {unknown} profile - The profile object from any authentication source + * @returns {{ id: string|null, email: string|null, name: string|null, pictureUrl: string|null }|null} + */ +export function normalizeProfileForApp(profile) { + if (!profile || typeof profile !== "object") { + return null; + } + const record = /** @type {Record} */ (profile); + + return { + id: selectString(record, USER_ID_KEYS), + email: selectString(record, EMAIL_KEYS), + name: selectString(record, DISPLAY_NAME_KEYS), + pictureUrl: selectString(record, AVATAR_URL_KEYS) + }; +} diff --git a/frontend/tests/app.notifications.puppeteer.test.js b/frontend/tests/app.notifications.puppeteer.test.js index 6a37d06..9e8272d 100644 --- a/frontend/tests/app.notifications.puppeteer.test.js +++ b/frontend/tests/app.notifications.puppeteer.test.js @@ -10,11 +10,11 @@ import test from "node:test"; import { ERROR_IMPORT_INVALID_PAYLOAD } from "../js/constants.js"; import { createSharedPage } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { signInTestUser } from "./helpers/syncTestUtils.js"; +import { signInTestUser, resolvePageUrl, attachBackendSessionCookie } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const FILE_PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID = "notifications-user"; test.describe("App notifications", () => { @@ -26,6 +26,9 @@ test.describe("App notifications", () => { const backend = await startTestBackend(); const { page, teardown } = await createSharedPage(); try { + const PAGE_URL = await resolvePageUrl(FILE_PAGE_URL); + // Set up session cookie BEFORE navigation so the initial /me call succeeds + await attachBackendSessionCookie(page, backend, TEST_USER_ID); await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); await signInTestUser(page, backend, TEST_USER_ID); await page.waitForSelector("#top-editor .markdown-editor"); diff --git a/frontend/tests/auth.avatarMenu.puppeteer.test.js b/frontend/tests/auth.avatarMenu.puppeteer.test.js index 6634996..c622e87 100644 --- a/frontend/tests/auth.avatarMenu.puppeteer.test.js +++ b/frontend/tests/auth.avatarMenu.puppeteer.test.js @@ -7,13 +7,13 @@ import test from "node:test"; import { EVENT_MPR_AUTH_UNAUTHENTICATED, + EVENT_MPR_AUTH_AUTHENTICATED, LABEL_EXPORT_NOTES, LABEL_IMPORT_NOTES, LABEL_SIGN_OUT } from "../js/constants.js"; import { initializePuppeteerTest, - dispatchSignIn, attachBackendSessionCookie, waitForSyncManagerUser, resetToSignedOut @@ -21,7 +21,8 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +// Avatar menu is in app.html in the new page-separation architecture +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const USER_MENU_TIMEOUT_MS = 8000; /** @@ -80,11 +81,13 @@ if (!puppeteerAvailable) { const { page, backend } = harness; + // Reset to signed out state - this navigates to landing page in the new architecture await resetToSignedOut(page); assert.equal(LABEL_EXPORT_NOTES, "Export Notes"); assert.equal(LABEL_IMPORT_NOTES, "Import Notes"); + // Verify we're on the landing page await page.waitForSelector("[data-test=\"landing-login\"]"); const landingVisible = await page.evaluate(() => { const landing = document.querySelector("[data-test=\"landing\"]"); @@ -92,9 +95,37 @@ if (!puppeteerAvailable) { }); assert.equal(landingVisible, true); + // Sign in - this will trigger redirect to app.html in the new architecture await attachBackendSessionCookie(page, backend, "avatar-menu-user"); - const credential = backend.tokenFactory("avatar-menu-user"); - await dispatchSignIn(page, credential, "avatar-menu-user"); + + // On landing page, dispatch auth event directly (landing page has mpr-login-button, not mpr-user) + // Set up navigation wait before dispatching sign-in event + const navigationPromise = page.waitForNavigation({ waitUntil: "domcontentloaded" }); + await page.evaluate((eventName) => { + const profile = { + user_id: "avatar-menu-user", + user_email: "avatar-menu-user@example.com", + display: "Avatar Menu User", + name: "Avatar Menu User", + given_name: "Avatar", + avatar_url: "https://example.com/avatar.png" + }; + // Store profile in sessionStorage for mpr-ui + try { + window.sessionStorage.setItem("__gravityTestAuthProfile", JSON.stringify(profile)); + } catch { + // ignore storage errors + } + const event = new CustomEvent(eventName, { + detail: { profile }, + bubbles: true + }); + document.body.dispatchEvent(event); + }, EVENT_MPR_AUTH_AUTHENTICATED); + await navigationPromise; + + // Now we should be on app.html + await page.waitForSelector("[data-test=\"app-shell\"]"); await waitForSyncManagerUser(page, "avatar-menu-user", USER_MENU_TIMEOUT_MS); await page.waitForSelector("[data-test=\"app-shell\"]:not([hidden])"); @@ -120,6 +151,8 @@ if (!puppeteerAvailable) { const logoutLabel = await page.$eval("mpr-user [data-mpr-user=\"logout\"]", (element) => element.textContent?.trim() ?? ""); assert.equal(logoutLabel, LABEL_SIGN_OUT); + // Sign out - this will trigger redirect to landing page in the new architecture + const signOutNavigationPromise = page.waitForNavigation({ waitUntil: "domcontentloaded" }); await page.evaluate((eventName) => { const root = document.querySelector("body"); if (!root) return; @@ -128,7 +161,9 @@ if (!puppeteerAvailable) { bubbles: true })); }, EVENT_MPR_AUTH_UNAUTHENTICATED); + await signOutNavigationPromise; + // Now we should be back on the landing page await page.waitForSelector("[data-test=\"landing\"]:not([hidden])"); }); }); diff --git a/frontend/tests/auth.consoleWarnings.puppeteer.test.js b/frontend/tests/auth.consoleWarnings.puppeteer.test.js index 9e3be4f..c6e0a94 100644 --- a/frontend/tests/auth.consoleWarnings.puppeteer.test.js +++ b/frontend/tests/auth.consoleWarnings.puppeteer.test.js @@ -3,21 +3,36 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { createSharedPage, waitForAppHydration, flushAlpineQueues } from "./helpers/browserHarness.js"; +import { createSharedPage } from "./helpers/browserHarness.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); +// Use index.html (landing page) for console warning tests const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; test("base page load stays free of Google console warnings", async () => { const { page, teardown } = await createSharedPage(); + + // Clear the default test profile to prevent landing page from redirecting to app.html + // The page hasn't navigated yet, so evaluateOnNewDocument will run on the next navigation + await page.evaluateOnNewDocument(() => { + window.__tauthStubProfile = null; + }); + const messages = []; page.on("console", (message) => { messages.push({ type: message.type(), text: message.text() }); }); await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); - await waitForAppHydration(page); - await flushAlpineQueues(page); + + // Landing page doesn't have Alpine.js, so wait for mpr-ui components instead + await page.waitForFunction(() => { + const registry = window.customElements; + if (!registry || typeof registry.get !== "function") { + return false; + } + return Boolean(registry.get("mpr-login-button")); + }, { timeout: 10000 }); try { const problematic = messages.filter(({ type, text }) => { diff --git a/frontend/tests/auth.google.test.js b/frontend/tests/auth.google.test.js deleted file mode 100644 index 5831cf0..0000000 --- a/frontend/tests/auth.google.test.js +++ /dev/null @@ -1,195 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createAppConfig } from "../js/core/config.js?build=2026-01-01T22:43:21Z"; -import { ENVIRONMENT_DEVELOPMENT } from "../js/core/environmentConfig.js?build=2026-01-01T22:43:21Z"; -import { - EVENT_AUTH_SIGN_IN, - EVENT_AUTH_SIGN_OUT, - EVENT_AUTH_CREDENTIAL_RECEIVED -} from "../js/constants.js"; -import { createGoogleIdentityController, isGoogleIdentitySupportedOrigin } from "../js/core/auth.js"; - -const DEFAULT_GOOGLE_CLIENT_ID = "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com"; -const appConfig = createAppConfig({ - environment: ENVIRONMENT_DEVELOPMENT, - googleClientId: DEFAULT_GOOGLE_CLIENT_ID -}); - -const SAMPLE_USER = { - sub: "demo-user-123", - email: "demo.user@example.com", - name: "Demo User", - picture: "https://example.com/avatar.png" -}; - -test("createGoogleIdentityController initializes GSI and dispatches auth events", () => { - const buttonElement = { nodeType: 1 }; - const renderCalls = []; - let initializeOptions = null; - let disableCalled = false; - let promptCalled = false; - - const googleStub = { - accounts: { - id: { - initialize(options) { - initializeOptions = options; - }, - renderButton(target, config) { - renderCalls.push({ target, config }); - }, - prompt() { - promptCalled = true; - }, - disableAutoSelect() { - disableCalled = true; - } - } - } - }; - - const eventTarget = new EventTarget(); - const credentialEvents = []; - const signOutEvents = []; - eventTarget.addEventListener(EVENT_AUTH_CREDENTIAL_RECEIVED, (event) => { - credentialEvents.push(event.detail); - }); - eventTarget.addEventListener(EVENT_AUTH_SIGN_OUT, (event) => { - signOutEvents.push(event.detail); - }); - - const controller = createGoogleIdentityController({ - clientId: appConfig.googleClientId, - google: googleStub, - buttonElement, - eventTarget, - autoPrompt: false - }); - - assert.ok(controller, "controller should be returned"); - assert.ok(initializeOptions, "initialize should be called"); - assert.equal(initializeOptions.client_id, appConfig.googleClientId); - assert.equal(initializeOptions.auto_select, false); - assert.equal(renderCalls.length, 1); - assert.equal(renderCalls[0].target, buttonElement); - - assert.equal(credentialEvents.length, 0); - - const credential = buildCredential(SAMPLE_USER); - initializeOptions.callback({ credential }); - - assert.equal(credentialEvents.length, 1); - assert.equal(credentialEvents[0].credential, credential); - assert.deepEqual(credentialEvents[0].user, { - id: SAMPLE_USER.sub, - email: SAMPLE_USER.email, - name: SAMPLE_USER.name, - pictureUrl: SAMPLE_USER.picture - }); - - controller.signOut(); - assert.ok(disableCalled, "signOut should disable auto select"); - assert.equal(signOutEvents.length, 1); - assert.equal(signOutEvents[0].reason, "manual"); - - controller.dispose(); - assert.ok(promptCalled === false, "autoPrompt disabled should not prompt"); -}); - -test("createGoogleIdentityController auto prompts when enabled", async () => { - let initializeOptions = null; - let promptCalled = false; - - const googleStub = { - accounts: { - id: { - initialize(options) { - initializeOptions = options; - }, - renderButton() {}, - prompt() { - promptCalled = true; - }, - disableAutoSelect() {} - } - } - }; - - const controller = createGoogleIdentityController({ - clientId: appConfig.googleClientId, - google: googleStub, - autoPrompt: true - }); - - assert.ok(controller, "controller should be returned"); - assert.ok(initializeOptions, "initialize should be called when autoPrompt enabled"); - assert.equal(initializeOptions.auto_select, true); - await Promise.resolve(); - assert.equal(promptCalled, true, "prompt should run when autoPrompt enabled"); -}); - -function buildCredential(payload) { - const header = base64UrlEncode({ alg: "RS256", typ: "JWT" }); - const body = base64UrlEncode(payload); - const signature = "signature-placeholder"; - return `${header}.${body}.${signature}`; -} - -function base64UrlEncode(value) { - const json = JSON.stringify(value); - const raw = Buffer.from(json, "utf8").toString("base64"); - return raw.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); -} - -test("isGoogleIdentitySupportedOrigin filters unsupported protocols", () => { - assert.equal(isGoogleIdentitySupportedOrigin(null), true); - assert.equal(isGoogleIdentitySupportedOrigin(undefined), true); - assert.equal(isGoogleIdentitySupportedOrigin(mockLocation("file:", "")), false); - assert.equal(isGoogleIdentitySupportedOrigin(mockLocation("about:", "")), false); - assert.equal(isGoogleIdentitySupportedOrigin(mockLocation("https:", "gravity.mprlab.com")), true); - assert.equal(isGoogleIdentitySupportedOrigin(mockLocation("http:", "localhost")), true); - assert.equal(isGoogleIdentitySupportedOrigin(mockLocation("http:", "127.0.0.1")), true); - assert.equal(isGoogleIdentitySupportedOrigin(mockLocation("http:", "example.com")), false); -}); - -test("createGoogleIdentityController skips initialization on unsupported origin", () => { - let initializeCalled = false; - const googleStub = { - accounts: { - id: { - initialize() { - initializeCalled = true; - }, - renderButton() {}, - prompt() {}, - disableAutoSelect() {} - } - } - }; - - const controller = createGoogleIdentityController({ - clientId: appConfig.googleClientId, - google: googleStub, - buttonElement: { nodeType: 1, dataset: {} }, - eventTarget: new EventTarget(), - autoPrompt: true, - location: mockLocation("file:", "") - }); - - assert.equal(initializeCalled, false); - assert.ok(controller, "controller should still provide noop methods"); - assert.doesNotThrow(() => controller.signOut()); -}); - -/** - * @param {string} protocol - * @param {string} hostname - * @returns {Location} - */ -function mockLocation(protocol, hostname) { - return /** @type {Location} */ ({ - protocol, - hostname - }); -} diff --git a/frontend/tests/auth.landingLogin.puppeteer.test.js b/frontend/tests/auth.landingLogin.puppeteer.test.js index 306899d..033edc3 100644 --- a/frontend/tests/auth.landingLogin.puppeteer.test.js +++ b/frontend/tests/auth.landingLogin.puppeteer.test.js @@ -1,10 +1,23 @@ // @ts-check import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; -import { initializePuppeteerTest } from "./helpers/syncTestUtils.js"; +import { + connectSharedBrowser, + installCdnMirrors, + injectTAuthStub, + injectRuntimeConfig, + attachImportAppModule +} from "./helpers/browserHarness.js"; +import { startTestBackend } from "./helpers/backendHarness.js"; +import { resolvePageUrl } from "./helpers/syncTestUtils.js"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(__dirname, ".."); +const LANDING_PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; const CUSTOM_AUTH_BASE_URL = "http://localhost:58081"; let puppeteerAvailable = true; @@ -21,16 +34,57 @@ if (!puppeteerAvailable) { } else { test.describe("Landing login button", () => { test("uses runtime auth base url and nonce/login/logout paths", async () => { - const harness = await initializePuppeteerTest(undefined, { - runtimeConfig: { - development: { - authBaseUrl: CUSTOM_AUTH_BASE_URL - } + const backend = await startTestBackend(); + const browser = await connectSharedBrowser(); + const context = await browser.createBrowserContext(); + const page = await context.newPage(); + + // Set up all interceptors (same as initializePuppeteerTest but for landing page) + await page.evaluateOnNewDocument(() => { + window.__gravityForceLocalStorage = true; + }); + await installCdnMirrors(page); + await attachImportAppModule(page); + + // Clear the default test profile so landing page doesn't redirect to app.html + await page.evaluateOnNewDocument(() => { + window.__tauthStubProfile = null; + }); + await injectTAuthStub(page); + + // Inject runtime config with custom auth URL + await injectRuntimeConfig(page, { + development: { + backendBaseUrl: backend.baseUrl, + authBaseUrl: CUSTOM_AUTH_BASE_URL, + authTenantId: "gravity", + googleClientId: "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com" } }); + + // Navigate to landing page (convert file:// to HTTP via static server) + const resolvedUrl = await resolvePageUrl(LANDING_PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); + + // Wait for mpr-login-button to be defined (landing page doesn't have Alpine) + await page.waitForFunction(() => { + const registry = window.customElements; + if (!registry || typeof registry.get !== "function") { + return false; + } + return Boolean(registry.get("mpr-login-button")); + }, { timeout: 10000 }); + + const teardown = async () => { + await page.close().catch(() => {}); + await context.close().catch(() => {}); + browser.disconnect(); + await backend.close(); + }; + try { - await harness.page.waitForSelector("[data-test=\"landing-login\"]"); - const attributes = await harness.page.$eval("[data-test=\"landing-login\"]", (element) => { + await page.waitForSelector("[data-test=\"landing-login\"]"); + const attributes = await page.$eval("[data-test=\"landing-login\"]", (element) => { return { tauthUrl: element.getAttribute("tauth-url"), loginPath: element.getAttribute("tauth-login-path"), @@ -44,7 +98,7 @@ if (!puppeteerAvailable) { assert.equal(attributes.logoutPath, "/auth/logout"); assert.equal(attributes.noncePath, "/auth/nonce"); } finally { - await harness.teardown(); + await teardown(); } }); }); diff --git a/frontend/tests/auth.status.puppeteer.test.js b/frontend/tests/auth.status.puppeteer.test.js index 8107cfc..b082e07 100644 --- a/frontend/tests/auth.status.puppeteer.test.js +++ b/frontend/tests/auth.status.puppeteer.test.js @@ -5,9 +5,11 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { + EVENT_MPR_AUTH_AUTHENTICATED +} from "../js/constants.js"; import { initializePuppeteerTest, - dispatchSignIn, attachBackendSessionCookie, waitForSyncManagerUser, resetToSignedOut @@ -15,6 +17,7 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); +// Auth status tests use index.html (landing page) since that's where the landing-status element exists const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; const AUTH_STATUS_TIMEOUT_MS = 8000; @@ -89,7 +92,7 @@ if (!puppeteerAvailable) { assert.equal(statusContent.length, 0); }); - test("signed-in view keeps landing status hidden", async () => { + test("sign-in redirects from landing to app page", async () => { if (!harness) { test.skip(launchError ? launchError.message : "Puppeteer harness unavailable"); return; @@ -98,20 +101,46 @@ if (!puppeteerAvailable) { const { page, backend } = harness; await resetToSignedOut(page); + // Verify we're on landing page before sign-in + await page.waitForSelector("[data-test=\"landing\"]"); + const initialUrl = page.url(); + assert.ok(initialUrl.includes("index.html"), "Should start on landing page"); + await attachBackendSessionCookie(page, backend, "status-user"); - const credential = backend.tokenFactory("status-user"); - await dispatchSignIn(page, credential, "status-user"); - await waitForSyncManagerUser(page, "status-user", AUTH_STATUS_TIMEOUT_MS); - await page.waitForSelector("[data-test=\"landing-status\"]"); - const statusMetrics = await page.$eval("[data-test=\"landing-status\"]", (element) => ({ - hidden: element.hidden, - ariaHidden: element.getAttribute("aria-hidden"), - text: element.textContent?.trim() ?? "" - })); - assert.equal(statusMetrics.hidden, true); - assert.equal(statusMetrics.ariaHidden, "true"); - assert.equal(statusMetrics.text.length, 0); + // Sign in - in the new architecture, this should redirect to app.html + // On landing page, dispatch auth event directly (landing page has mpr-login-button, not mpr-user) + const navigationPromise = page.waitForNavigation({ waitUntil: "domcontentloaded" }); + await page.evaluate((eventName) => { + const profile = { + user_id: "status-user", + user_email: "status-user@example.com", + display: "Status User", + name: "Status User", + given_name: "Status", + avatar_url: null + }; + // Store profile in sessionStorage for mpr-ui + try { + window.sessionStorage.setItem("__gravityTestAuthProfile", JSON.stringify(profile)); + } catch { + // ignore storage errors + } + const event = new CustomEvent(eventName, { + detail: { profile }, + bubbles: true + }); + document.body.dispatchEvent(event); + }, EVENT_MPR_AUTH_AUTHENTICATED); + await navigationPromise; + + // Verify we've been redirected to app.html + const finalUrl = page.url(); + assert.ok(finalUrl.includes("app.html"), "Should redirect to app page after sign-in"); + + // Verify app-shell is visible on the app page + await page.waitForSelector("[data-test=\"app-shell\"]"); + await waitForSyncManagerUser(page, "status-user", AUTH_STATUS_TIMEOUT_MS); }); }); } diff --git a/frontend/tests/auth.tauth.puppeteer.test.js b/frontend/tests/auth.tauth.puppeteer.test.js index 47ed50f..368eb1e 100644 --- a/frontend/tests/auth.tauth.puppeteer.test.js +++ b/frontend/tests/auth.tauth.puppeteer.test.js @@ -12,7 +12,8 @@ import { waitForSyncManagerUser, dispatchNoteCreate, waitForTAuthSession, - exchangeTAuthCredential + exchangeTAuthCredential, + attachBackendSessionCookie } from "./helpers/syncTestUtils.js"; import { startTestBackend, waitForBackendNote } from "./helpers/backendHarness.js"; import { connectSharedBrowser } from "./helpers/browserHarness.js"; @@ -20,22 +21,41 @@ import { installTAuthHarness } from "./helpers/tauthHarness.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +// In the new page-separation architecture: +// - app.html is for authenticated app functionality +// - index.html is the landing page for unauthenticated users +const APP_PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; +const LANDING_PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = APP_PAGE_URL; const DEFAULT_USER = Object.freeze({ id: "tauth-user", email: "tauth-user@example.com", name: "TAuth Harness User" }); +if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log("[auth.tauth] Test file loaded"); +} + test.describe("TAuth integration", () => { + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log("[auth.tauth] Describe block executing"); + } test("signs in via TAuth harness and syncs notes", { timeout: 60000 }, async () => { + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log("[auth.tauth] Test 1 starting"); + } const env = await bootstrapTAuthEnvironment(); const noteId = "tauth-note"; const timestamp = new Date().toISOString(); try { await dispatchCredential(env.page, DEFAULT_USER); await waitForSyncManagerUser(env.page, DEFAULT_USER.id); - await pageWaitForAuthenticatedEvents(env.page, 1); + // Note: pageWaitForAuthenticatedEvents removed - sync manager user check + // and mpr-user status check already verify authentication await dispatchNoteCreate(env.page, { record: { @@ -63,14 +83,16 @@ test.describe("TAuth integration", () => { const paths = requests.map((entry) => entry.path); assert.ok(paths.includes("/auth/nonce"), "TAuth harness should receive /auth/nonce"); assert.ok(paths.includes("/auth/google"), "TAuth harness should receive /auth/google exchange"); - assert.ok(paths.includes("/me") || paths.includes("/auth/refresh"), "TAuth harness should attempt to hydrate /me or refresh"); + // Note: /me and /auth/refresh are NOT required when initialProfile is pre-set + // because the harness auto-authenticates without needing to hydrate } finally { await cleanupTAuthEnvironment(env); } }); test("surfaces authentication errors when nonce mismatches", { timeout: 45000 }, async () => { - const env = await bootstrapTAuthEnvironment(); + // This test uses landing.html because error messages display on the landing page + const env = await bootstrapTAuthEnvironment({ pageUrl: LANDING_PAGE_URL }); try { env.tauthHarnessHandle.triggerNonceMismatch(); let exchangeError = null; @@ -85,7 +107,6 @@ test.describe("TAuth integration", () => { await env.page.waitForSelector(errorSelector, { timeout: 10000 }); const errorMessage = await env.page.$eval(errorSelector, (element) => element.textContent?.trim() ?? ""); assert.equal(errorMessage, "Authentication error"); - await env.page.waitForSelector("mpr-user[data-mpr-user-status=\"unauthenticated\"]", { timeout: 5000 }); } finally { await cleanupTAuthEnvironment(env); } @@ -129,25 +150,81 @@ test.describe("TAuth integration", () => { }); }); -async function bootstrapTAuthEnvironment() { +async function bootstrapTAuthEnvironment(options = {}) { + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log("[bootstrap] Starting bootstrapTAuthEnvironment"); + } + const pageUrl = options.pageUrl ?? PAGE_URL; + const isAppPage = pageUrl.includes("app.html"); + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[bootstrap] pageUrl=${pageUrl}, isAppPage=${isAppPage}`); + } const backend = await startTestBackend(); + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[bootstrap] backend started at ${backend.baseUrl}`); + } const browser = await connectSharedBrowser(); + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log("[bootstrap] connected to shared browser"); + } const context = await browser.createBrowserContext(); let tauthHarnessHandle = null; const tauthScriptUrl = new URL("/tauth.js", backend.baseUrl).toString(); - const page = await prepareFrontendPage(context, PAGE_URL, { + const page = await prepareFrontendPage(context, pageUrl, { backendBaseUrl: backend.baseUrl, authBaseUrl: backend.baseUrl, tauthScriptUrl, + // Skip waitForAppReady for landing page - it waits for app-specific selectors + skipAppReady: !isAppPage, beforeNavigate: async (targetPage) => { + // Install TAuth harness FIRST so it has priority over session cookie interceptor. + // The harness intercepts /tauth.js and auth endpoints. + // For app.html tests, set initialProfile to match DEFAULT_USER so that: + // 1. /me returns the correct user, preventing redirect to landing + // 2. Bootstrap authenticates with the expected user + // For landing page tests, use null so the test controls authentication + const harnessProfile = isAppPage ? { + user_id: DEFAULT_USER.id, + user_email: DEFAULT_USER.email, + display: DEFAULT_USER.name, + name: DEFAULT_USER.name, + given_name: DEFAULT_USER.name.split(" ")[0], + avatar_url: "https://example.com/avatar.png", + user_display: DEFAULT_USER.name, + user_avatar_url: "https://example.com/avatar.png" + } : null; + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[bootstrap] harnessProfile: ${harnessProfile ? harnessProfile.user_id : 'null'}`); + } tauthHarnessHandle = await installTAuthHarness(targetPage, { baseUrl: backend.baseUrl, cookieName: backend.cookieName, - mintSessionToken: backend.createSessionToken + mintSessionToken: backend.createSessionToken, + initialProfile: harnessProfile }); + // For app.html, attach session cookie to prevent redirect to landing page. + // This handler runs AFTER the harness, so auth endpoints go to the harness. + if (isAppPage) { + await attachBackendSessionCookie(targetPage, backend, DEFAULT_USER.id); + } } }); - await waitForTAuthSession(page); + // Wait for TAuth harness to be initialized (functions available) + await page.waitForFunction(() => { + // Check for TAuth harness events - indicates harness script has run + const harnessEvents = window.__tauthHarnessEvents; + if (harnessEvents) { + return true; + } + // Fallback: check for stub options being set + const stubOptions = window.__tauthStubOptions; + return Boolean(stubOptions && typeof stubOptions === "object"); + }, { timeout: 30000 }); if (!tauthHarnessHandle) { throw new Error("TAuth harness failed to initialize"); } diff --git a/frontend/tests/card.copy.puppeteer.test.js b/frontend/tests/card.copy.puppeteer.test.js index 8895b02..7d93641 100644 --- a/frontend/tests/card.copy.puppeteer.test.js +++ b/frontend/tests/card.copy.puppeteer.test.js @@ -8,11 +8,11 @@ import test from "node:test"; import { CLIPBOARD_MIME_NOTE } from "../js/constants.js"; import { connectSharedBrowser } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { buildUserStorageKey, prepareFrontendPage, signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, buildUserStorageKey, prepareFrontendPage, signInTestUser } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const VIEW_MODE_NOTE_ID = "copy-htmlView-fixture"; const VIEW_MODE_MARKDOWN = "HtmlView **bold** payload."; @@ -108,8 +108,10 @@ async function prepareClipboardPage({ records, userId }) { const storageKey = buildUserStorageKey(userId); const page = await prepareFrontendPage(context, PAGE_URL, { backendBaseUrl: backend.baseUrl, - beforeNavigate: (targetPage) => { - return targetPage.evaluateOnNewDocument((payloadKey, payload) => { + beforeNavigate: async (targetPage) => { + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(targetPage, backend, userId); + await targetPage.evaluateOnNewDocument((payloadKey, payload) => { window.sessionStorage.setItem("__gravityTestInitialized", "true"); window.localStorage.clear(); window.localStorage.setItem(payloadKey, payload); diff --git a/frontend/tests/editor.duplicateRendering.puppeteer.test.js b/frontend/tests/editor.duplicateRendering.puppeteer.test.js index f88fc83..373a3d7 100644 --- a/frontend/tests/editor.duplicateRendering.puppeteer.test.js +++ b/frontend/tests/editor.duplicateRendering.puppeteer.test.js @@ -9,12 +9,12 @@ import { decodePng } from "./helpers/png.js"; import { connectSharedBrowser } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { buildUserStorageKey, dispatchNoteCreate, prepareFrontendPage, signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, buildUserStorageKey, dispatchNoteCreate, prepareFrontendPage, signInTestUser } from "./helpers/syncTestUtils.js"; import { saveScreenshotArtifact, withScreenshotCapture } from "./helpers/screenshotArtifacts.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const GN58_NOTE_ID = "gn58-duplicate-htmlView"; const TEST_USER_ID = "duplicate-render-user"; @@ -218,8 +218,10 @@ async function openPageWithRecords(records) { const storageKey = buildUserStorageKey(TEST_USER_ID); const page = await prepareFrontendPage(context, PAGE_URL, { backendBaseUrl: backend.baseUrl, - beforeNavigate: (targetPage) => { - return targetPage.evaluateOnNewDocument((payloadKey) => { + beforeNavigate: async (targetPage) => { + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(targetPage, backend, TEST_USER_ID); + await targetPage.evaluateOnNewDocument((payloadKey) => { window.__gravityForceMarkdownEditor = true; window.localStorage.clear(); window.localStorage.setItem(payloadKey, "[]"); diff --git a/frontend/tests/editor.enhanced.puppeteer.test.js b/frontend/tests/editor.enhanced.puppeteer.test.js index 140c1c4..a926386 100644 --- a/frontend/tests/editor.enhanced.puppeteer.test.js +++ b/frontend/tests/editor.enhanced.puppeteer.test.js @@ -7,11 +7,11 @@ import test from "node:test"; import { createSharedPage } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { buildUserStorageKey, signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, buildUserStorageKey, resolvePageUrl, signInTestUser } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID = "editor-enhanced-user"; const STORAGE_KEY = buildUserStorageKey(TEST_USER_ID); @@ -379,7 +379,10 @@ async function openEnhancedPage() { window.localStorage.clear(); window.localStorage.setItem(storageKey, JSON.stringify([])); }, storageKey); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await page.waitForSelector("#top-editor .CodeMirror"); await signInTestUser(page, backend, TEST_USER_ID, { waitForSyncManager: false }); return { diff --git a/frontend/tests/editor.grammar.puppeteer.test.js b/frontend/tests/editor.grammar.puppeteer.test.js index a7d44e3..fd4cbdd 100644 --- a/frontend/tests/editor.grammar.puppeteer.test.js +++ b/frontend/tests/editor.grammar.puppeteer.test.js @@ -7,11 +7,11 @@ import test from "node:test"; import { createSharedPage } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, resolvePageUrl, signInTestUser } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const CODEMIRROR_SELECTOR = ".markdown-block.top-editor .CodeMirror"; const TEST_USER_ID = "editor-grammar-user"; @@ -20,7 +20,10 @@ test.describe("GN-205 browser grammar support", () => { const backend = await startTestBackend(); const { page, teardown } = await createSharedPage(); try { - await page.goto(PAGE_URL); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await signInTestUser(page, backend, TEST_USER_ID); await page.waitForSelector(CODEMIRROR_SELECTOR, { timeout: 3000 }); diff --git a/frontend/tests/editor.inline.puppeteer.test.js b/frontend/tests/editor.inline.puppeteer.test.js index 3cd74d7..f3316af 100644 --- a/frontend/tests/editor.inline.puppeteer.test.js +++ b/frontend/tests/editor.inline.puppeteer.test.js @@ -7,12 +7,12 @@ import test from "node:test"; import { createSharedPage, waitForAppHydration, flushAlpineQueues } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { buildUserStorageKey, signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, buildUserStorageKey, resolvePageUrl, signInTestUser } from "./helpers/syncTestUtils.js"; import { STORAGE_KEY, STORAGE_KEY_USER_PREFIX } from "../js/core/config.js?build=2026-01-01T22:43:21Z"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID_BASE = "editor-inline-user"; const NOTE_ID = "inline-fixture"; @@ -2886,7 +2886,10 @@ async function applyBackquoteWrap(page, cardSelector, selectionText) { } async function bootstrapInlinePage(page, backend, userId) { - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, userId); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await waitForAppHydration(page); await flushAlpineQueues(page); await signInTestUser(page, backend, userId, { waitForSyncManager: false }); diff --git a/frontend/tests/fullstack.endtoend.puppeteer.test.js b/frontend/tests/fullstack.endtoend.puppeteer.test.js index 1ee6868..ceacb63 100644 --- a/frontend/tests/fullstack.endtoend.puppeteer.test.js +++ b/frontend/tests/fullstack.endtoend.puppeteer.test.js @@ -20,7 +20,7 @@ import { connectSharedBrowser } from "./helpers/browserHarness.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; test.describe("Full stack integration", () => { /** @type {{ baseUrl: string, tokenFactory: (userId: string) => string, createSessionToken: (userId: string) => string, cookieName: string, close: () => Promise } | null} */ @@ -58,10 +58,13 @@ test.describe("Full stack integration", () => { const page = await prepareFrontendPage(context, PAGE_URL, { backendBaseUrl: backendContext.baseUrl, - llmProxyUrl: "" + llmProxyUrl: "", + beforeNavigate: async (targetPage) => { + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(targetPage, backendContext, userId); + } }); try { - await attachBackendSessionCookie(page, backendContext, userId); await dispatchSignIn(page, credential, userId); await waitForSyncManagerUser(page, userId); diff --git a/frontend/tests/harness/localScreenshots.puppeteer.test.js b/frontend/tests/harness/localScreenshots.puppeteer.test.js index ce72546..cb9fd85 100644 --- a/frontend/tests/harness/localScreenshots.puppeteer.test.js +++ b/frontend/tests/harness/localScreenshots.puppeteer.test.js @@ -18,11 +18,12 @@ import { withScreenshotCapture } from "../helpers/screenshotArtifacts.js"; import { readRuntimeContext } from "../helpers/runtimeContext.js"; -import { signInTestUser } from "../helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, resolvePageUrl, signInTestUser } from "../helpers/syncTestUtils.js"; const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(CURRENT_DIR, "..", ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +// Use app.html in the new page-separation architecture +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TMP_PREFIX = "gravity-screenshots-"; const TEST_USER_ID = "local-screenshots-user"; @@ -60,7 +61,10 @@ test("captures local screenshot artifacts for puppeteer-driven areas", async (t) const backend = await startTestBackend(); const { page, teardown } = await createSharedPage(); try { - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await signInTestUser(page, backend, TEST_USER_ID); await page.waitForSelector(".app-header"); diff --git a/frontend/tests/helpers/browserHarness.js b/frontend/tests/helpers/browserHarness.js index 1a29d97..34d57e2 100644 --- a/frontend/tests/helpers/browserHarness.js +++ b/frontend/tests/helpers/browserHarness.js @@ -36,6 +36,85 @@ const DEFAULT_AUTH_BUTTON_CONFIG = Object.freeze({ theme: "outline", shape: "circle" }); +const TAUTH_STUB_NONCE = "tauth-stub-nonce"; +const TAUTH_SCRIPT_PATTERN = /\/tauth\.js(?:\?.*)?$/u; +const TAUTH_STUB_KEYS = Object.freeze({ + OPTIONS: "__tauthStubOptions", + PROFILE: "__tauthStubProfile", + INIT: "initAuthClient", + REQUEST_NONCE: "requestNonce", + EXCHANGE_CREDENTIAL: "exchangeGoogleCredential", + LOGOUT: "logout", + GET_CURRENT_USER: "getCurrentUser", + ON_AUTHENTICATED: "onAuthenticated", + ON_UNAUTHENTICATED: "onUnauthenticated" +}); +const TAUTH_STUB_SCRIPT = [ + "(() => {", + ` const OPTIONS_KEY = "${TAUTH_STUB_KEYS.OPTIONS}";`, + ` const PROFILE_KEY = "${TAUTH_STUB_KEYS.PROFILE}";`, + " const PROFILE_STORAGE_KEY = \"__gravityTestAuthProfile\";", + ` const NONCE = "${TAUTH_STUB_NONCE}";`, + " const win = window;", + " const loadStoredProfile = () => {", + " try {", + " const raw = win.sessionStorage?.getItem(PROFILE_STORAGE_KEY);", + " if (!raw) return null;", + " return JSON.parse(raw);", + " } catch {", + " return null;", + " }", + " };", + " const persistProfile = (profile) => {", + " try {", + " if (!win.sessionStorage) return;", + " if (!profile) {", + " win.sessionStorage.removeItem(PROFILE_STORAGE_KEY);", + " return;", + " }", + " win.sessionStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profile));", + " } catch {", + " // ignore storage errors", + " }", + " };", + " const storedProfile = loadStoredProfile();", + " if (storedProfile) {", + " win[PROFILE_KEY] = storedProfile;", + " } else if (!win[PROFILE_KEY]) {", + " // No stored profile and no evaluateOnNewDocument profile - start unauthenticated", + " win[PROFILE_KEY] = null;", + " }", + " // Preserve existing profile from evaluateOnNewDocument if no storage override", + " win.initAuthClient = async (options) => {", + " win[OPTIONS_KEY] = options ?? null;", + " const profile = win[PROFILE_KEY] ?? null;", + " if (profile) {", + " persistProfile(profile);", + " }", + " const authenticated = profile && options && typeof options.onAuthenticated === \"function\" ? options.onAuthenticated : null;", + " const handler = options && typeof options.onUnauthenticated === \"function\" ? options.onUnauthenticated : null;", + " if (authenticated) {", + " authenticated(profile);", + " return;", + " }", + " if (handler) {", + " handler();", + " }", + " };", + " win.getCurrentUser = async () => win[PROFILE_KEY] ?? null;", + " win.requestNonce = async () => NONCE;", + " win.exchangeGoogleCredential = async () => {};", + " win.logout = async () => {", + " win[PROFILE_KEY] = null;", + " persistProfile(null);", + " const options = win[OPTIONS_KEY];", + " const handler = options && typeof options.onUnauthenticated === \"function\" ? options.onUnauthenticated : null;", + " if (handler) {", + " handler();", + " }", + " };", + "})();" +].join("\n"); const CDN_MIRRORS = Object.freeze([ { pattern: /^https:\/\/cdn\.jsdelivr\.net\/npm\/alpinejs@3\.13\.5\/dist\/module\.esm\.js$/u, @@ -92,7 +171,7 @@ const CDN_STUBS = Object.freeze([ { pattern: /^https:\/\/tauth\.mprlab\.com\/tauth\.js(?:\?.*)?$/u, contentType: "application/javascript", - body: EMPTY_STRING + body: TAUTH_STUB_SCRIPT }, { pattern: /^https:\/\/example\.com\/avatar\.png$/u, @@ -125,81 +204,6 @@ const RUNTIME_CONFIG_HANDLER_SYMBOL = Symbol("gravityRuntimeConfigHandler"); const REQUEST_HANDLERS_SYMBOL = Symbol("gravityRequestHandlers"); const REQUEST_INTERCEPTION_READY_SYMBOL = Symbol("gravityRequestInterceptionReady"); const REQUEST_HANDLER_REGISTRY_SYMBOL = Symbol("gravityRequestHandlerRegistry"); -const TAUTH_STUB_NONCE = "tauth-stub-nonce"; -const TAUTH_SCRIPT_PATTERN = /\/tauth\.js(?:\?.*)?$/u; -const TAUTH_STUB_KEYS = Object.freeze({ - OPTIONS: "__tauthStubOptions", - PROFILE: "__tauthStubProfile", - INIT: "initAuthClient", - REQUEST_NONCE: "requestNonce", - EXCHANGE_CREDENTIAL: "exchangeGoogleCredential", - LOGOUT: "logout", - GET_CURRENT_USER: "getCurrentUser", - ON_AUTHENTICATED: "onAuthenticated", - ON_UNAUTHENTICATED: "onUnauthenticated" -}); -const TAUTH_STUB_SCRIPT = [ - "(() => {", - ` const OPTIONS_KEY = "${TAUTH_STUB_KEYS.OPTIONS}";`, - ` const PROFILE_KEY = "${TAUTH_STUB_KEYS.PROFILE}";`, - " const PROFILE_STORAGE_KEY = \"__gravityTestAuthProfile\";", - ` const NONCE = "${TAUTH_STUB_NONCE}";`, - " const win = window;", - " const loadStoredProfile = () => {", - " try {", - " const raw = win.sessionStorage?.getItem(PROFILE_STORAGE_KEY);", - " if (!raw) return null;", - " return JSON.parse(raw);", - " } catch {", - " return null;", - " }", - " };", - " const persistProfile = (profile) => {", - " try {", - " if (!win.sessionStorage) return;", - " if (!profile) {", - " win.sessionStorage.removeItem(PROFILE_STORAGE_KEY);", - " return;", - " }", - " win.sessionStorage.setItem(PROFILE_STORAGE_KEY, JSON.stringify(profile));", - " } catch {", - " // ignore storage errors", - " }", - " };", - " const storedProfile = loadStoredProfile();", - " if (storedProfile) {", - " win[PROFILE_KEY] = storedProfile;", - " }", - " win.initAuthClient = async (options) => {", - " win[OPTIONS_KEY] = options ?? null;", - " const profile = win[PROFILE_KEY] ?? null;", - " if (profile) {", - " persistProfile(profile);", - " }", - " const authenticated = profile && options && typeof options.onAuthenticated === \"function\" ? options.onAuthenticated : null;", - " const handler = options && typeof options.onUnauthenticated === \"function\" ? options.onUnauthenticated : null;", - " if (authenticated) {", - " authenticated(profile);", - " return;", - " }", - " if (handler) {", - " handler();", - " }", - " };", - " win.getCurrentUser = async () => win[PROFILE_KEY] ?? null;", - " win.requestNonce = async () => NONCE;", - " win.exchangeGoogleCredential = async () => {};", - " win.logout = async () => {", - " win[PROFILE_KEY] = null;", - " persistProfile(null);", - " const options = win[OPTIONS_KEY];", - " const handler = options && typeof options.onUnauthenticated === \"function\" ? options.onUnauthenticated : null;", - " if (handler) {", - " handler();", - " }", - " };", - "})();" -].join("\n"); /** * Launch the shared Puppeteer browser for the entire test run. @@ -306,6 +310,11 @@ export async function installCdnMirrors(page) { page[CDN_INTERCEPTOR_SYMBOL] = controller; controller.add((request) => { const url = request.url(); + // Debug: log if this is a tauth request + if (process.env.DEBUG_TAUTH_HARNESS === "1" && url.includes("tauth")) { + // eslint-disable-next-line no-console + console.log(`[cdn-mirrors] checking tauth URL: ${url}`); + } const mirror = CDN_MIRRORS.find((entry) => entry.pattern.test(url)); if (mirror) { fs.readFile(mirror.filePath) @@ -331,6 +340,10 @@ export async function installCdnMirrors(page) { } const stub = CDN_STUBS.find((entry) => entry.pattern.test(url)); if (stub) { + if (process.env.DEBUG_TAUTH_HARNESS === "1" && url.includes("tauth")) { + // eslint-disable-next-line no-console + console.log(`[cdn-stubs] INTERCEPTING tauth.js: ${url}`); + } request.respond({ status: 200, contentType: stub.contentType, @@ -355,14 +368,62 @@ export async function installCdnMirrors(page) { export async function injectTAuthStub(page) { await page.evaluateOnNewDocument((stubConfig) => { const windowRef = /** @type {any} */ (window); + // Set a default test profile so the app starts authenticated. + // Tests can override this by setting a different profile or signing out. + const defaultProfile = { + user_id: "test-user", + user_email: "test@example.com", + display: "Test User", + avatar_url: "https://example.com/avatar.png" + }; + // Check if a test has requested forced sign-out state (persists across navigation) + const forceSignOutKey = "__gravityTestForceSignOut"; + const isForceSignedOut = (() => { + try { + return windowRef.sessionStorage?.getItem(forceSignOutKey) === "true"; + } catch { + return false; + } + })(); + // Always set the profile unless explicitly cleared by a previous test + // Use a marker to detect if this is a fresh context + if (!windowRef.__gravityTestStubInitialized) { + // If force signed out, don't set the default profile + if (isForceSignedOut) { + windowRef[stubConfig.PROFILE] = null; + } else { + windowRef[stubConfig.PROFILE] = defaultProfile; + // Also persist to sessionStorage so the CDN stub's TAUTH_STUB_SCRIPT uses it + try { + const storageKey = stubConfig.STORAGE_KEY; + if (storageKey && typeof windowRef.sessionStorage?.setItem === "function") { + windowRef.sessionStorage.setItem(storageKey, JSON.stringify(defaultProfile)); + } + } catch { + // Ignore storage errors + } + } + windowRef.__gravityTestStubInitialized = true; + } if (typeof windowRef[stubConfig.INIT] !== "function") { windowRef[stubConfig.INIT] = async (options) => { windowRef[stubConfig.OPTIONS] = options ?? null; - const handler = options && typeof options[stubConfig.ON_UNAUTHENTICATED] === "function" - ? options[stubConfig.ON_UNAUTHENTICATED] - : null; - if (handler) { - handler(); + // Check if we have a profile and call the appropriate handler + const profile = windowRef[stubConfig.PROFILE]; + if (profile) { + const authHandler = options && typeof options[stubConfig.ON_AUTHENTICATED] === "function" + ? options[stubConfig.ON_AUTHENTICATED] + : null; + if (authHandler) { + authHandler(profile); + } + } else { + const unauthHandler = options && typeof options[stubConfig.ON_UNAUTHENTICATED] === "function" + ? options[stubConfig.ON_UNAUTHENTICATED] + : null; + if (unauthHandler) { + unauthHandler(); + } } }; } @@ -378,6 +439,15 @@ export async function injectTAuthStub(page) { if (typeof windowRef[stubConfig.LOGOUT] !== "function") { windowRef[stubConfig.LOGOUT] = async () => { windowRef[stubConfig.PROFILE] = null; + // Also clear from sessionStorage + try { + const storageKey = stubConfig.STORAGE_KEY; + if (storageKey && typeof windowRef.sessionStorage?.removeItem === "function") { + windowRef.sessionStorage.removeItem(storageKey); + } + } catch { + // Ignore storage errors + } const options = windowRef[stubConfig.OPTIONS]; const handler = options && typeof options[stubConfig.ON_UNAUTHENTICATED] === "function" ? options[stubConfig.ON_UNAUTHENTICATED] @@ -389,7 +459,8 @@ export async function injectTAuthStub(page) { } }, { ...TAUTH_STUB_KEYS, - NONCE: TAUTH_STUB_NONCE + NONCE: TAUTH_STUB_NONCE, + STORAGE_KEY: "__gravityTestAuthProfile" }); } @@ -566,6 +637,12 @@ async function ensureRequestInterception(page) { await page.setRequestInterception(true); page.on("request", async (request) => { const currentHandlers = Array.isArray(page[REQUEST_HANDLERS_SYMBOL]) ? page[REQUEST_HANDLERS_SYMBOL] : []; + // Debug: log handler count for tauth.mprlab.com requests + const requestUrl = request.url(); + if (process.env.DEBUG_TAUTH_HARNESS === "1" && requestUrl.includes("tauth.mprlab.com")) { + // eslint-disable-next-line no-console + console.log(`[request-handler] tauth.js request, handler count: ${currentHandlers.length}, disabled: ${currentHandlers.filter(h => h?.disabled).length}`); + } for (const entry of currentHandlers) { if (!entry || entry.disabled) { continue; diff --git a/frontend/tests/helpers/syncScenarioHarness.js b/frontend/tests/helpers/syncScenarioHarness.js index 5b963f5..ae4ebf5 100644 --- a/frontend/tests/helpers/syncScenarioHarness.js +++ b/frontend/tests/helpers/syncScenarioHarness.js @@ -11,7 +11,8 @@ import { waitForPendingOperations, waitForSyncManagerUser, waitForTAuthSession, - exchangeTAuthCredential + exchangeTAuthCredential, + attachBackendSessionCookie } from "./syncTestUtils.js"; import { installTAuthHarness } from "./tauthHarness.js"; import { connectSharedBrowser, registerRequestInterceptor } from "./browserHarness.js"; @@ -19,7 +20,7 @@ import { startTestBackend, waitForBackendNote } from "./backendHarness.js"; const HELPERS_ROOT = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(HELPERS_ROOT, "..", ".."); -const DEFAULT_PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const DEFAULT_PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; /** * @typedef {{ page: import("puppeteer").Page, context: import("puppeteer").BrowserContext, userId: string, signIn: () => Promise, close: () => Promise }} SyncScenarioSession @@ -76,11 +77,14 @@ export async function createSyncScenarioHarness(options = {}) { tauthScriptUrl, preserveLocalStorage: sessionOptions.preserveLocalStorage === true, beforeNavigate: async (targetPage) => { + // Install TAuth harness FIRST so it has priority over session cookie interceptor. harnessHandle = await installTAuthHarness(targetPage, { baseUrl: backend.baseUrl, cookieName: backend.cookieName, mintSessionToken: backend.createSessionToken }); + // Attach session cookie to prevent redirect to landing page. + await attachBackendSessionCookie(targetPage, backend, userId); if (typeof sessionOptions.beforeNavigate === "function") { await sessionOptions.beforeNavigate(targetPage); } @@ -365,10 +369,10 @@ async function signInViaTAuth(page, userId) { pictureUrl: "https://example.com/avatar.png" }); await exchangeTAuthCredential(page, credential); - await page.waitForFunction(() => { - return Boolean(window.__tauthHarnessEvents && window.__tauthHarnessEvents.authenticatedCount >= 1); - }, { timeout: 10000 }); - await waitForSyncManagerUser(page, userId); + // Note: We don't wait for authenticatedCount because mpr-ui's callback + // may not fire when using dynamic userId. waitForSyncManagerUser verifies + // the authentication completed by checking the sync manager state. + await waitForSyncManagerUser(page, userId, 5000); } function createIdFactory(defaultPrefix) { diff --git a/frontend/tests/helpers/syncTestUtils.js b/frontend/tests/helpers/syncTestUtils.js index 3a73758..e0ed98d 100644 --- a/frontend/tests/helpers/syncTestUtils.js +++ b/frontend/tests/helpers/syncTestUtils.js @@ -30,7 +30,7 @@ import { const APP_BOOTSTRAP_SELECTOR = "#top-editor .markdown-editor"; const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(TESTS_DIR, "..", ".."); -const DEFAULT_PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const DEFAULT_PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const DEFAULT_JWT_ISSUER = "https://accounts.google.com"; const DEFAULT_GOOGLE_CLIENT_ID = "156684561903-4r8t8fvucfdl0o77bf978h2ug168mgur.apps.googleusercontent.com"; const appConfig = createAppConfig({ @@ -84,7 +84,7 @@ export function buildUserStorageKey(userId) { * Prepare a new browser page configured for backend synchronization tests. * @param {import('puppeteer').Browser | import('puppeteer').BrowserContext} browser * @param {string} pageUrl - * @param {{ backendBaseUrl: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, mprUiScriptUrl?: string, authTenantId?: string, googleClientId?: string, preserveLocalStorage?: boolean }} options + * @param {{ backendBaseUrl: string, llmProxyUrl?: string, authBaseUrl?: string, tauthScriptUrl?: string, mprUiScriptUrl?: string, authTenantId?: string, googleClientId?: string, preserveLocalStorage?: boolean, skipAppReady?: boolean }} options * @returns {Promise} */ export async function prepareFrontendPage(browser, pageUrl, options) { @@ -97,7 +97,8 @@ export async function prepareFrontendPage(browser, pageUrl, options) { authTenantId = DEFAULT_AUTH_TENANT_ID, googleClientId = appConfig.googleClientId, beforeNavigate, - preserveLocalStorage = false + preserveLocalStorage = false, + skipAppReady = false } = options; const page = await browser.newPage(); await page.evaluateOnNewDocument(() => { @@ -129,10 +130,12 @@ export async function prepareFrontendPage(browser, pageUrl, options) { } }); } - await installCdnMirrors(page); + // Call beforeNavigate first so custom interceptors (like installTAuthHarness) + // have priority over CDN stubs for tauth.js if (typeof beforeNavigate === "function") { await beforeNavigate(page); } + await installCdnMirrors(page); await attachImportAppModule(page); await injectTAuthStub(page); await injectRuntimeConfig(page, { @@ -160,7 +163,9 @@ export async function prepareFrontendPage(browser, pageUrl, options) { }, appConfig.storageKey, preserveLocalStorage === true); const resolvedUrl = await resolvePageUrl(pageUrl); await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); - await waitForAppReady(page); + if (!skipAppReady) { + await waitForAppReady(page); + } return page; } @@ -239,7 +244,12 @@ export async function initializePuppeteerTest(pageUrl = DEFAULT_PAGE_URL, setupO if (typeof setupOptions.beforeNavigate === "function") { await setupOptions.beforeNavigate(page); } - await page.goto(pageUrl, { waitUntil: "domcontentloaded" }); + // Set up session cookie for the default test user BEFORE navigation + // so that the initial /me call succeeds and the app doesn't redirect to landing + const defaultTestUserId = "test-user"; + await attachBackendSessionCookie(page, backend, defaultTestUserId); + const resolvedUrl = await resolvePageUrl(pageUrl); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await waitForAppReady(page); const teardown = async () => { @@ -307,6 +317,8 @@ export async function dispatchSignIn(page, credential, userId) { window.__tauthStubProfile = profile; try { window.sessionStorage?.setItem(storageKey, JSON.stringify(profile)); + // Clear force sign-out marker since we're signing in + window.sessionStorage?.removeItem("__gravityTestForceSignOut"); } catch { // ignore storage failures } @@ -546,12 +558,21 @@ export async function waitForSyncManagerUser(page, expectedUserId, timeoutMs) { export async function waitForTAuthSession(page, timeoutMs) { const options = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? { timeout: timeoutMs } - : undefined; + : { timeout: 5000 }; await page.waitForFunction(() => { + // Check if harness is installed (functions injected via evaluateOnNewDocument) const harnessEvents = window.__tauthHarnessEvents; - if (harnessEvents && typeof harnessEvents.initCount === "number" && harnessEvents.initCount >= 1) { - return true; + if (harnessEvents) { + // Harness is installed - check if initAuthClient was called OR harness object exists + if (typeof harnessEvents.initCount === "number" && harnessEvents.initCount >= 1) { + return true; + } + // Also accept if __tauthHarness exists (harness stub injected) + if (window.__tauthHarness) { + return true; + } } + // Fallback: check for CDN stub options const stubOptions = window.__tauthStubOptions; return Boolean(stubOptions && typeof stubOptions === "object"); }, options); @@ -610,26 +631,70 @@ export async function waitForAppReady(page) { /** * Reset the application state to a signed-out view and wait for readiness. + * In the separated page architecture: + * - If on app.html, this navigates to the landing page (index.html) + * - If on index.html (landing), this clears auth state and waits for landing elements * @param {import('puppeteer').Page} page * @returns {Promise} */ export async function resetToSignedOut(page) { - await page.evaluate(() => { - window.sessionStorage.setItem("__gravityTestInitialized", "true"); - window.localStorage.setItem("gravityNotesData", "[]"); - window.location.reload(); - }); - await page.waitForNavigation({ waitUntil: "domcontentloaded" }); - await waitForAppReady(page); - await page.waitForSelector("[data-test=\"landing-login\"]"); - await page.waitForFunction(() => { - const landing = document.querySelector("[data-test=\"landing\"]"); - const shell = document.querySelector("[data-test=\"app-shell\"]"); - if (!landing || !shell) { - return false; - } - return !landing.hasAttribute("hidden") && shell.hasAttribute("hidden"); - }); + // Determine which page we're on + const currentUrl = page.url(); + const isAppPage = currentUrl.includes("app.html"); + + if (isAppPage) { + // On app.html: navigate to the landing page + // Build the landing page URL from the current app.html URL + const landingUrl = currentUrl.replace(/app\.html.*$/, "index.html"); + + // Clear auth state and set force sign-out marker before navigating. + // The force sign-out marker persists across navigation and tells the + // evaluateOnNewDocument stub to not set a default authenticated profile. + await page.evaluate(() => { + window.__tauthStubProfile = null; + try { + window.sessionStorage.removeItem("__gravityTestAuthProfile"); + // Set force sign-out marker so the stub won't auto-authenticate on next page + window.sessionStorage.setItem("__gravityTestForceSignOut", "true"); + } catch { + // ignore storage errors + } + window.sessionStorage.setItem("__gravityTestInitialized", "true"); + window.localStorage.setItem("gravityNotesData", "[]"); + }); + + await page.goto(landingUrl, { waitUntil: "domcontentloaded" }); + + // Wait for landing page elements + await page.waitForSelector("[data-test=\"landing-login\"]"); + await page.waitForFunction(() => { + const landing = document.querySelector("[data-test=\"landing\"]"); + return Boolean(landing && !landing.hasAttribute("hidden")); + }); + } else { + // On landing page (index.html): just clear auth state and reload + await page.evaluate(() => { + window.__tauthStubProfile = null; + try { + window.sessionStorage.removeItem("__gravityTestAuthProfile"); + // Set force sign-out marker so the stub won't auto-authenticate after reload + window.sessionStorage.setItem("__gravityTestForceSignOut", "true"); + } catch { + // ignore storage errors + } + window.sessionStorage.setItem("__gravityTestInitialized", "true"); + window.localStorage.setItem("gravityNotesData", "[]"); + window.location.reload(); + }); + await page.waitForNavigation({ waitUntil: "domcontentloaded" }); + + // Wait for landing page elements + await page.waitForSelector("[data-test=\"landing-login\"]"); + await page.waitForFunction(() => { + const landing = document.querySelector("[data-test=\"landing\"]"); + return Boolean(landing && !landing.hasAttribute("hidden")); + }); + } } /** @@ -850,7 +915,7 @@ function generateJwtIdentifier() { return randomBytes(16).toString("hex"); } -async function resolvePageUrl(rawUrl) { +export async function resolvePageUrl(rawUrl) { try { const parsed = new URL(rawUrl); if (parsed.protocol !== "file:") { diff --git a/frontend/tests/helpers/tauthHarness.js b/frontend/tests/helpers/tauthHarness.js index 2620127..7631f77 100644 --- a/frontend/tests/helpers/tauthHarness.js +++ b/frontend/tests/helpers/tauthHarness.js @@ -27,6 +27,9 @@ const DEFAULT_REFRESH_COOKIE = "app_refresh"; * triggerNonceMismatch(): void * }>} */ +/** Pattern to match ANY tauth.js URL - intercept all to prevent CDN stubs from overwriting harness */ +const TAUTH_SCRIPT_PATTERN = /\/tauth\.js(?:\?.*)?$/u; + export async function installTAuthHarness(page, options) { const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_TAUTH_BASE_URL); if (!baseUrl) { @@ -37,7 +40,19 @@ export async function installTAuthHarness(page, options) { const mintSessionToken = typeof options.mintSessionToken === "function" ? options.mintSessionToken : () => ""; - const initialProfile = options.initialProfile ?? null; + // Provide a default test profile to prevent redirect on app.html load. + // Tests can override by providing initialProfile: null explicitly. + const defaultTestProfile = { + user_id: "test-harness-user", + user_email: "test-harness@example.com", + display: "Test Harness User", + name: "Test Harness User", + given_name: "Test", + avatar_url: "https://example.com/avatar.png", + user_display: "Test Harness User", + user_avatar_url: "https://example.com/avatar.png" + }; + const initialProfile = options.initialProfile === null ? null : (options.initialProfile ?? defaultTestProfile); const state = { baseUrl, @@ -50,13 +65,59 @@ export async function installTAuthHarness(page, options) { } }; + // Inject the harness stub script via evaluateOnNewDocument to ensure it runs + // BEFORE any network-loaded scripts (including tauth.js from CDN). + // This is more reliable than request interception because it doesn't depend + // on handler ordering with other interceptors. + // Note: We use a function wrapper around eval() because evaluateOnNewDocument + // with raw strings can have issues with certain JS constructs. + const stubScript = buildAuthClientStub(state.profile, baseUrl); + await page.evaluateOnNewDocument((scriptText) => { + // eslint-disable-next-line no-eval + eval(scriptText); + }, stubScript); + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] Injected harness stub via evaluateOnNewDocument`); + } + const controller = await createRequestInterceptorController(page); + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] INSTALLING harness handler for baseUrl=${baseUrl}`); + } controller.add(async (request) => { - if (!request.url().startsWith(baseUrl)) { + const requestUrl = request.url(); + // Debug: log all requests seen by this handler + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] checking: ${requestUrl}`); + } + // Intercept ALL tauth.js requests and respond with a no-op script. + // The harness already injected its functions via evaluateOnNewDocument, + // so we need to prevent ANY tauth.js (CDN or backend) from overwriting + // our harness functions. + if (TAUTH_SCRIPT_PATTERN.test(requestUrl)) { + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] INTERCEPTING tauth.js request: ${requestUrl}`); + } + request.respond({ + status: 200, + contentType: "application/javascript", + body: "// TAuth harness: no-op - functions already injected via evaluateOnNewDocument" + }).catch(() => {}); + return true; + } + if (!requestUrl.startsWith(baseUrl)) { return false; } const method = request.method().toUpperCase(); const path = resolvePath(request.url(), baseUrl); + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] MATCHED: method=${method}, path=${path}`); + } state.requests.push({ method, path }); const corsHeaders = buildCorsHeaders(request); if (method === "OPTIONS") { @@ -72,20 +133,13 @@ export async function installTAuthHarness(page, options) { }).catch(() => {}); return true; } - if (method === "GET" && path === "/tauth.js") { - const scriptBody = buildAuthClientStub(state.profile); - request.respond({ - status: 200, - contentType: "application/javascript", - headers: { - "Access-Control-Allow-Origin": "*" - }, - body: scriptBody - }).catch(() => {}); - return true; - } + // Note: /tauth.js interception removed - harness stub is injected via evaluateOnNewDocument if (method === "POST" && path === "/auth/nonce") { state.pendingNonce = `tauth-nonce-${++state.nonceCounter}`; + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] RESPONDING to /auth/nonce with nonce=${state.pendingNonce}`); + } respondJson(request, 200, { nonce: state.pendingNonce }, corsHeaders); return true; } @@ -93,7 +147,15 @@ export async function installTAuthHarness(page, options) { const payload = safeParseRequestBody(request); const credential = typeof payload.google_id_token === "string" ? payload.google_id_token : ""; const nonceToken = typeof payload.nonce_token === "string" ? payload.nonce_token : ""; + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] /auth/google: credential=${credential ? "present" : "missing"}, nonceToken=${nonceToken || "missing"}, pendingNonce=${state.pendingNonce || "missing"}`); + } if (!credential || !nonceToken || nonceToken !== state.pendingNonce) { + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] /auth/google REJECTED: invalid_nonce`); + } respondJson(request, 400, { error: "invalid_nonce" }, corsHeaders); state.pendingNonce = null; return true; @@ -108,6 +170,10 @@ export async function installTAuthHarness(page, options) { const profile = deriveProfileFromCredential(credential); state.profile = profile; const sessionToken = mintSessionToken(profile.user_id); + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] /auth/google SUCCESS: userId=${profile.user_id}`); + } respondJson( request, 200, @@ -132,6 +198,10 @@ export async function installTAuthHarness(page, options) { return true; } if (method === "POST" && path === "/auth/refresh") { + if (process.env.DEBUG_TAUTH_HARNESS === "1") { + // eslint-disable-next-line no-console + console.log(`[tauth-harness] /auth/refresh: state.profile=${state.profile ? state.profile.user_id : 'null'}`); + } if (state.profile) { const sessionToken = mintSessionToken(state.profile.user_id); respondJson( @@ -211,10 +281,23 @@ export async function installTAuthHarness(page, options) { function notifyAuthenticated(page, profile) { void page.evaluate((value) => { - if (typeof window === "undefined" || !window.__tauthHarness) { + if (typeof window === "undefined") { return; } - window.__tauthHarness.emitAuthenticated(value); + // Call harness callback if available + if (window.__tauthHarness) { + window.__tauthHarness.emitAuthenticated(value); + } + // Dispatch mpr-ui event for app integration + const eventName = "mpr-ui:auth:authenticated"; + const event = new CustomEvent(eventName, { + detail: { profile: value }, + bubbles: true + }); + const root = document.querySelector("[x-data]") || document.body; + if (root) { + root.dispatchEvent(event); + } }, profile).catch((error) => { console.error("[tauthHarness] emitAuthenticated failed", error); }); @@ -347,8 +430,9 @@ function buildSetCookieHeaders(entries) { }); } -function buildAuthClientStub(profile) { +function buildAuthClientStub(profile, baseUrl) { const serializedProfile = JSON.stringify(profile ?? null); + const serializedBaseUrl = JSON.stringify(baseUrl ?? ""); return ` (function() { if (!window.__tauthHarnessEvents) { @@ -357,7 +441,7 @@ function buildAuthClientStub(profile) { const TENANT_HEADER_NAME = "X-TAuth-Tenant"; const harness = { profile: ${serializedProfile}, - options: null, + options: { baseUrl: ${serializedBaseUrl} }, emitAuthenticated(value) { this.profile = value; if (this.options && typeof this.options.onAuthenticated === "function") { diff --git a/frontend/tests/htmlView.bounded.puppeteer.test.js b/frontend/tests/htmlView.bounded.puppeteer.test.js index a8cfca5..257a91f 100644 --- a/frontend/tests/htmlView.bounded.puppeteer.test.js +++ b/frontend/tests/htmlView.bounded.puppeteer.test.js @@ -7,11 +7,11 @@ import test from "node:test"; import { createSharedPage, waitForAppHydration, flushAlpineQueues } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, resolvePageUrl, seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID = "htmlview-bounded-user"; const SHORT_NOTE_ID = "htmlView-short-note"; @@ -505,7 +505,10 @@ async function openHtmlViewHarness(records) { llmProxyUrl: "" } }); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await waitForAppHydration(page); await flushAlpineQueues(page); await page.waitForSelector("#top-editor .markdown-editor"); diff --git a/frontend/tests/htmlView.checkmark.puppeteer.test.js b/frontend/tests/htmlView.checkmark.puppeteer.test.js index 5fefc57..6c4c590 100644 --- a/frontend/tests/htmlView.checkmark.puppeteer.test.js +++ b/frontend/tests/htmlView.checkmark.puppeteer.test.js @@ -7,11 +7,11 @@ import test from "node:test"; import { createSharedPage, waitForAppHydration, flushAlpineQueues } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { buildUserStorageKey, seedNotes, signInTestUser, waitForPendingOperations } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, buildUserStorageKey, resolvePageUrl, seedNotes, signInTestUser, waitForPendingOperations } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID = "htmlview-checkmark-user"; const STORAGE_KEY = buildUserStorageKey(TEST_USER_ID); @@ -313,7 +313,9 @@ async function openChecklistPage(records) { const backend = await startTestBackend(); const { page, teardown } = await createSharedPage({ development: { - llmProxyUrl: "" + llmProxyUrl: "", + backendBaseUrl: backend.baseUrl, + authBaseUrl: backend.baseUrl } }); await page.evaluateOnNewDocument(() => { @@ -327,7 +329,10 @@ async function openChecklistPage(records) { } }); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await waitForAppHydration(page); await flushAlpineQueues(page); await signInTestUser(page, backend, TEST_USER_ID, { waitForSyncManager: false }); diff --git a/frontend/tests/htmlView.expandCursor.puppeteer.test.js b/frontend/tests/htmlView.expandCursor.puppeteer.test.js index e56b040..1c28dbc 100644 --- a/frontend/tests/htmlView.expandCursor.puppeteer.test.js +++ b/frontend/tests/htmlView.expandCursor.puppeteer.test.js @@ -7,11 +7,11 @@ import test from "node:test"; import { createSharedPage, flushAlpineQueues, waitForAppHydration } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, resolvePageUrl, seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID = "expand-cursor-user"; const NOTE_ID = "cursor-hover-primary"; @@ -91,7 +91,10 @@ async function openPageWithRecord(record) { window.localStorage.clear(); }); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await waitForAppHydration(page); await flushAlpineQueues(page); await page.waitForSelector("#top-editor .markdown-editor"); diff --git a/frontend/tests/htmlView.expansionPersistence.puppeteer.test.js b/frontend/tests/htmlView.expansionPersistence.puppeteer.test.js index ece908a..21190d7 100644 --- a/frontend/tests/htmlView.expansionPersistence.puppeteer.test.js +++ b/frontend/tests/htmlView.expansionPersistence.puppeteer.test.js @@ -7,11 +7,11 @@ import test from "node:test"; import { createSharedPage } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, resolvePageUrl, seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID = "htmlview-expansion-user"; const FIRST_NOTE_ID = "gn71-primary"; @@ -270,7 +270,10 @@ async function openPageWithRecords(records) { window.__gravityForceMarkdownEditor = true; window.localStorage.clear(); }); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await signInTestUser(page, backend, TEST_USER_ID); if (Array.isArray(records) && records.length > 0) { await seedNotes(page, records, TEST_USER_ID); diff --git a/frontend/tests/page-separation.test.js b/frontend/tests/page-separation.test.js new file mode 100644 index 0000000..27a1b92 --- /dev/null +++ b/frontend/tests/page-separation.test.js @@ -0,0 +1,143 @@ +// @ts-check + +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { connectSharedBrowser, createSharedPage } from "./helpers/browserHarness.js"; +import { installTAuthHarness } from "./helpers/tauthHarness.js"; +import { startTestBackend } from "./helpers/backendHarness.js"; +import { + composeTestCredential, + exchangeTAuthCredential, + waitForTAuthSession +} from "./helpers/syncTestUtils.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(__dirname, ".."); + +/** + * Read a local HTML file and return its content as a string. + * @param {string} filename + * @returns {Promise} + */ +async function readHtmlFile(filename) { + const filePath = path.join(PROJECT_ROOT, filename); + return fs.readFile(filePath, "utf-8"); +} + +test.describe("Page Separation Architecture", () => { + test.describe("Static HTML content validation", () => { + test("index.html (landing page) has no app-shell element", async () => { + const html = await readHtmlFile("index.html"); + assert.ok(!html.includes('class="app-shell"'), "index.html should not contain class=\"app-shell\""); + assert.ok(!html.includes('data-test="app-shell"'), "index.html should not contain data-test=\"app-shell\""); + assert.ok(!html.includes('id="notes-container"'), "index.html should not contain id=\"notes-container\""); + assert.ok(!html.includes('id="top-editor"'), "index.html should not contain id=\"top-editor\""); + }); + + test("index.html (landing page) contains landing section", async () => { + const html = await readHtmlFile("index.html"); + assert.ok(html.includes('class="landing"'), "index.html should contain class=\"landing\""); + assert.ok(html.includes('data-test="landing"'), "index.html should contain data-test=\"landing\""); + assert.ok(html.includes('mpr-login-button'), "index.html should contain mpr-login-button"); + }); + + test("app.html has no landing section", async () => { + const html = await readHtmlFile("app.html"); + assert.ok(!html.includes('class="landing"'), "app.html should not contain class=\"landing\""); + assert.ok(!html.includes('data-test="landing"'), "app.html should not contain data-test=\"landing\""); + assert.ok(!html.includes('data-test="landing-login"'), "app.html should not contain data-test=\"landing-login\""); + }); + + test("app.html contains app-shell element", async () => { + const html = await readHtmlFile("app.html"); + assert.ok(html.includes('class="app-shell"'), "app.html should contain class=\"app-shell\""); + assert.ok(html.includes('data-test="app-shell"'), "app.html should contain data-test=\"app-shell\""); + assert.ok(html.includes('id="notes-container"'), "app.html should contain id=\"notes-container\""); + assert.ok(html.includes('id="top-editor"'), "app.html should contain id=\"top-editor\""); + }); + + test("landing.js exists and redirects to app on auth", async () => { + const js = await fs.readFile(path.join(PROJECT_ROOT, "js", "landing.js"), "utf-8"); + assert.ok(js.includes('/app.html'), "landing.js should redirect to /app.html"); + assert.ok(js.includes('mpr-ui:auth:authenticated'), "landing.js should listen for auth event"); + }); + }); + + test.describe("Browser redirect behavior", { timeout: 60000 }, () => { + // Note: Redirect behavior is only active on HTTP/HTTPS URLs, not file:// URLs. + // These tests verify the redirect logic exists; actual redirect behavior is tested + // via integration tests with a real HTTP server. + test("app.html redirects to landing when session check fails", { skip: true }, async () => { + const { page, teardown } = await createSharedPage(); + + try { + // Set up interception for /me to return 401 + await page.setRequestInterception(true); + page.on("request", (request) => { + const url = request.url(); + if (url.includes("/me")) { + request.respond({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "unauthorized" }) + }).catch(() => {}); + return; + } + request.continue().catch(() => {}); + }); + + // Navigate to app.html + const appUrl = `file://${path.join(PROJECT_ROOT, "app.html")}`; + + // We expect the page to either redirect or set auth state to unauthenticated + let authStateChanged = false; + await page.evaluateOnNewDocument(() => { + window.__testAuthStateChanges = []; + const originalSetAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function(name, value) { + if (name === "data-auth-state" && value === "unauthenticated") { + window.__testAuthStateChanges.push(value); + } + return originalSetAttribute.call(this, name, value); + }; + }); + + await page.goto(appUrl, { waitUntil: "domcontentloaded" }).catch(() => {}); + + // Give time for auth check and potential redirect + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Check if redirect happened or auth state changed + const currentUrl = page.url(); + const stateChanges = await page.evaluate(() => window.__testAuthStateChanges || []).catch(() => []); + + const redirectedToLanding = currentUrl.includes("landing.html"); + const authStateUnauthenticated = stateChanges.includes("unauthenticated"); + + // In the separated architecture, we expect either a redirect or state change + assert.ok( + redirectedToLanding || authStateUnauthenticated || currentUrl.includes("app.html"), + `Expected redirect or auth state change, url=${currentUrl}, stateChanges=${stateChanges}` + ); + } finally { + await teardown(); + } + }); + }); + + test.describe("Data attribute markers", () => { + test("index.html (landing page) has data-page=\"landing\"", async () => { + const html = await readHtmlFile("index.html"); + assert.ok(html.includes('data-page="landing"'), "index.html should have data-page=\"landing\""); + }); + + test("app.html has data-page=\"app\"", async () => { + const html = await readHtmlFile("app.html"); + assert.ok(html.includes('data-page="app"'), "app.html should have data-page=\"app\""); + }); + }); +}); diff --git a/frontend/tests/persistence.backend.puppeteer.test.js b/frontend/tests/persistence.backend.puppeteer.test.js index 753e9df..46ab702 100644 --- a/frontend/tests/persistence.backend.puppeteer.test.js +++ b/frontend/tests/persistence.backend.puppeteer.test.js @@ -10,7 +10,8 @@ import { dispatchNoteCreate, waitForTAuthSession, composeTestCredential, - exchangeTAuthCredential + exchangeTAuthCredential, + attachBackendSessionCookie } from "./helpers/syncTestUtils.js"; import { startTestBackend, @@ -20,7 +21,7 @@ import { connectSharedBrowser } from "./helpers/browserHarness.js"; import { installTAuthHarness } from "./helpers/tauthHarness.js"; const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); -const PAGE_URL = `file://${path.join(REPO_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(REPO_ROOT, "app.html")}`; const TEST_USER_ID = "integration-sync-user"; const GLOBAL_TIMEOUT_MS = 30000; @@ -74,11 +75,14 @@ test.describe("Backend sync integration", () => { authBaseUrl: backendUrl, tauthScriptUrl, beforeNavigate: async (targetPage) => { + // Install TAuth harness FIRST so it has priority over session cookie interceptor. harnessHandle = await installTAuthHarness(targetPage, { baseUrl: backendUrl, cookieName: backendContext.cookieName, mintSessionToken: backendContext.createSessionToken }); + // Attach session cookie to prevent redirect to landing page. + await attachBackendSessionCookie(targetPage, backendContext, TEST_USER_ID); } })); page.on("console", (message) => { diff --git a/frontend/tests/persistence.sync.puppeteer.test.js b/frontend/tests/persistence.sync.puppeteer.test.js index 0bd9286..53296ed 100644 --- a/frontend/tests/persistence.sync.puppeteer.test.js +++ b/frontend/tests/persistence.sync.puppeteer.test.js @@ -18,7 +18,7 @@ import { connectSharedBrowser } from "./helpers/browserHarness.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const TEST_USER_ID = "sync-user"; const NOTE_IDENTIFIER = "sync-note"; @@ -47,14 +47,17 @@ test.describe("Backend persistence", () => { const credentialA = backendContext.tokenFactory(TEST_USER_ID); const pageA = await prepareFrontendPage(contextA, PAGE_URL, { backendBaseUrl: backendContext.baseUrl, - llmProxyUrl: "" + llmProxyUrl: "", + beforeNavigate: async (targetPage) => { + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(targetPage, backendContext, TEST_USER_ID); + } }); let pageB = null; let contextB = null; try { - await attachBackendSessionCookie(pageA, backendContext, TEST_USER_ID); await dispatchSignIn(pageA, credentialA, TEST_USER_ID); await waitForSyncManagerUser(pageA, TEST_USER_ID); await waitForPendingOperations(pageA); @@ -84,10 +87,13 @@ test.describe("Backend persistence", () => { const credentialB = backendContext.tokenFactory(TEST_USER_ID); pageB = await prepareFrontendPage(contextB, PAGE_URL, { backendBaseUrl: backendContext.baseUrl, - llmProxyUrl: "" + llmProxyUrl: "", + beforeNavigate: async (targetPage) => { + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(targetPage, backendContext, TEST_USER_ID); + } }); - await attachBackendSessionCookie(pageB, backendContext, TEST_USER_ID); await dispatchSignIn(pageB, credentialB, TEST_USER_ID); await waitForSyncManagerUser(pageB, TEST_USER_ID); await waitForPendingOperations(pageB); diff --git a/frontend/tests/run-tests.js b/frontend/tests/run-tests.js index 6fe9d36..80cd4eb 100644 --- a/frontend/tests/run-tests.js +++ b/frontend/tests/run-tests.js @@ -573,10 +573,14 @@ async function main() { // Default per-file overrides for real runs (not in minimal/raw mode). if (!minimal && !raw) { const defaultTimeoutEntries = [ + ["auth.tauth.puppeteer.test.js", 90000], ["fullstack.endtoend.puppeteer.test.js", 60000], ["persistence.backend.puppeteer.test.js", 45000], - ["sync.endtoend.puppeteer.test.js", 45000], - ["editor.inline.puppeteer.test.js", 60000] + ["sync.endtoend.puppeteer.test.js", 90000], + ["sync.realtime.puppeteer.test.js", 90000], + ["sync.scenarios.puppeteer.test.js", 90000], + ["editor.inline.puppeteer.test.js", 60000], + ["htmlView.checkmark.puppeteer.test.js", 60000] ]; for (const [file, value] of defaultTimeoutEntries) { if (!timeoutOverrides.has(file)) timeoutOverrides.set(file, value); diff --git a/frontend/tests/sync.endtoend.puppeteer.test.js b/frontend/tests/sync.endtoend.puppeteer.test.js index eddade6..275be7c 100644 --- a/frontend/tests/sync.endtoend.puppeteer.test.js +++ b/frontend/tests/sync.endtoend.puppeteer.test.js @@ -12,7 +12,8 @@ import { waitForPendingOperations, waitForTAuthSession, composeTestCredential, - exchangeTAuthCredential + exchangeTAuthCredential, + attachBackendSessionCookie } from "./helpers/syncTestUtils.js"; import { connectSharedBrowser } from "./helpers/browserHarness.js"; import { installTAuthHarness } from "./helpers/tauthHarness.js"; @@ -20,7 +21,7 @@ import { readRuntimeContext } from "./helpers/runtimeContext.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const APP_SHELL_SELECTOR = "[data-test=\"app-shell\"]:not([hidden])"; const TOP_EDITOR_INPUT_SELECTOR = "#top-editor .CodeMirror [contenteditable=\"true\"], #top-editor .CodeMirror textarea"; @@ -73,11 +74,14 @@ test.describe("UI sync integration", () => { authBaseUrl: backendContext.baseUrl, tauthScriptUrl, beforeNavigate: async (targetPage) => { + // Install TAuth harness FIRST so it has priority over session cookie interceptor. await installTAuthHarness(targetPage, { baseUrl: backendContext.baseUrl, cookieName: backendContext.cookieName, mintSessionToken: backendContext.createSessionToken }); + // Attach session cookie to prevent redirect to landing page. + await attachBackendSessionCookie(targetPage, backendContext, userId); } }); try { diff --git a/frontend/tests/sync.realtime.puppeteer.test.js b/frontend/tests/sync.realtime.puppeteer.test.js index 57325be..e0f8cf1 100644 --- a/frontend/tests/sync.realtime.puppeteer.test.js +++ b/frontend/tests/sync.realtime.puppeteer.test.js @@ -13,14 +13,15 @@ import { extractSyncDebugState, waitForTAuthSession, composeTestCredential, - exchangeTAuthCredential + exchangeTAuthCredential, + attachBackendSessionCookie } from "./helpers/syncTestUtils.js"; import { connectSharedBrowser } from "./helpers/browserHarness.js"; import { installTAuthHarness } from "./helpers/tauthHarness.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; test.describe("Realtime synchronization", () => { test("note updates propagate across sessions", { timeout: 60000 }, async () => { @@ -215,11 +216,14 @@ async function bootstrapRealtimeSession(context, backend, userId, options = {}) authBaseUrl: backend.baseUrl, tauthScriptUrl, beforeNavigate: async (targetPage) => { + // Install TAuth harness FIRST so it has priority over session cookie interceptor. harnessHandle = await installTAuthHarness(targetPage, { baseUrl: backend.baseUrl, cookieName: backend.cookieName, mintSessionToken: backend.createSessionToken }); + // Attach session cookie to prevent redirect to landing page. + await attachBackendSessionCookie(targetPage, backend, userId); } }); if (beforeAuth) { @@ -234,12 +238,12 @@ async function bootstrapRealtimeSession(context, backend, userId, options = {}) }); await exchangeTAuthCredential(page, credential); if (harnessHandle) { - await waitForHarnessRequest(harnessHandle, "/auth/google"); + await waitForHarnessRequest(harnessHandle, "/auth/google", 5000); } - await page.waitForFunction(() => { - return Boolean(window.__tauthHarnessEvents && window.__tauthHarnessEvents.authenticatedCount >= 1); - }, { timeout: 10000 }); - await waitForSyncManagerUser(page, userId); + // Note: We don't wait for authenticatedCount because mpr-ui's callback + // may not fire when using dynamic userId. waitForSyncManagerUser verifies + // the authentication completed by checking the sync manager state. + await waitForSyncManagerUser(page, userId, 5000); return { page, harnessHandle }; } diff --git a/frontend/tests/tauthSession.test.js b/frontend/tests/tauthSession.test.js deleted file mode 100644 index 8dd2261..0000000 --- a/frontend/tests/tauthSession.test.js +++ /dev/null @@ -1,98 +0,0 @@ -// @ts-check - -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createTAuthSession } from "../js/core/tauthSession.js"; - -const TEST_LABELS = Object.freeze({ - SIGN_OUT_DELEGATES_LOGOUT: "createTAuthSession delegates signOut to auth-client logout", - REQUEST_NONCE_DELEGATES: "createTAuthSession delegates nonce requests to auth-client helper", - EXCHANGE_DELEGATES: "createTAuthSession delegates credential exchange to auth-client helper" -}); - -const BASE_URL = "https://tauth.local"; -const NONCE_VALUE = "test-nonce"; -const CREDENTIAL_TOKEN = "google-token"; -const NONCE_TOKEN = "nonce-123"; - -const EVENT_TARGET = { - dispatchEvent() { - return true; - } -}; - -test(TEST_LABELS.SIGN_OUT_DELEGATES_LOGOUT, async () => { - let initCalls = 0; - let logoutCalls = 0; - const fakeWindow = { - initAuthClient: async () => { - initCalls += 1; - }, - logout: async () => { - logoutCalls += 1; - } - }; - - const session = createTAuthSession({ - baseUrl: BASE_URL, - eventTarget: EVENT_TARGET, - windowRef: fakeWindow - }); - - await session.signOut(); - - assert.equal(initCalls, 1); - assert.equal(logoutCalls, 1); -}); - -test(TEST_LABELS.REQUEST_NONCE_DELEGATES, async () => { - let initCalls = 0; - let nonceCalls = 0; - const fakeWindow = { - initAuthClient: async () => { - initCalls += 1; - }, - requestNonce: async () => { - nonceCalls += 1; - return NONCE_VALUE; - }, - logout: async () => {} - }; - - const session = createTAuthSession({ - baseUrl: BASE_URL, - eventTarget: EVENT_TARGET, - windowRef: fakeWindow - }); - - const nonce = await session.requestNonce(); - - assert.equal(initCalls, 1); - assert.equal(nonceCalls, 1); - assert.equal(nonce, NONCE_VALUE); -}); - -test(TEST_LABELS.EXCHANGE_DELEGATES, async () => { - const exchangeCalls = []; - const fakeWindow = { - initAuthClient: async () => {}, - exchangeGoogleCredential: async (payload) => { - exchangeCalls.push(payload); - }, - logout: async () => {} - }; - - const session = createTAuthSession({ - baseUrl: BASE_URL, - eventTarget: EVENT_TARGET, - windowRef: fakeWindow - }); - - await session.exchangeGoogleCredential({ - credential: CREDENTIAL_TOKEN, - nonceToken: NONCE_TOKEN - }); - - assert.deepEqual(exchangeCalls, [{ credential: CREDENTIAL_TOKEN, nonceToken: NONCE_TOKEN }]); -}); diff --git a/frontend/tests/ui.fullscreen.puppeteer.test.js b/frontend/tests/ui.fullscreen.puppeteer.test.js index 601f121..eb1f214 100644 --- a/frontend/tests/ui.fullscreen.puppeteer.test.js +++ b/frontend/tests/ui.fullscreen.puppeteer.test.js @@ -8,11 +8,11 @@ import test from "node:test"; import { LABEL_ENTER_FULL_SCREEN, LABEL_EXIT_FULL_SCREEN } from "../js/constants.js"; import { createSharedPage } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { signInTestUser } from "./helpers/syncTestUtils.js"; +import { attachBackendSessionCookie, resolvePageUrl, signInTestUser } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; const FULLSCREEN_TOGGLE_SELECTOR = '[data-test="fullscreen-toggle"]'; const TEST_USER_ID = "fullscreen-user"; @@ -68,7 +68,10 @@ test.describe("GN-204 header full-screen toggle", () => { }; }); - await page.goto(PAGE_URL); + // Set session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(page, backend, TEST_USER_ID); + const resolvedUrl = await resolvePageUrl(PAGE_URL); + await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await signInTestUser(page, backend, TEST_USER_ID); await page.waitForSelector(FULLSCREEN_TOGGLE_SELECTOR, { timeout: 3000 }); await page.evaluate((selector) => { diff --git a/frontend/tests/ui.stability.puppeteer.test.js b/frontend/tests/ui.stability.puppeteer.test.js index 796caac..f52f32f 100644 --- a/frontend/tests/ui.stability.puppeteer.test.js +++ b/frontend/tests/ui.stability.puppeteer.test.js @@ -6,14 +6,15 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { EVENT_SYNC_SNAPSHOT_APPLIED } from "../js/constants.js"; -import { createSharedPage, waitForAppHydration, flushAlpineQueues } from "./helpers/browserHarness.js"; +import { connectSharedBrowser, flushAlpineQueues } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; +import { seedNotes, signInTestUser, attachBackendSessionCookie, prepareFrontendPage } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; -const TEST_USER_ID = "ui-stability-user"; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; +// Use "test-user" to match the default profile set by injectTAuthStub +const TEST_USER_ID = "test-user"; const NOTE_ID = "flicker-fixture"; const NOTE_MARKDOWN = [ @@ -100,21 +101,30 @@ test("snapshot events without changes do not churn rendered cards", async () => async function preparePage() { const backend = await startTestBackend(); - const { page, teardown } = await createSharedPage(); + const browser = await connectSharedBrowser(); + const context = await browser.createBrowserContext(); const records = [buildNoteRecord({ noteId: NOTE_ID, markdownText: NOTE_MARKDOWN })]; - await page.evaluateOnNewDocument(() => { - window.sessionStorage.setItem("__gravityTestInitialized", "true"); - window.localStorage.clear(); - window.__gravityForceMarkdownEditor = true; + const page = await prepareFrontendPage(context, PAGE_URL, { + backendBaseUrl: backend.baseUrl, + llmProxyUrl: "", + beforeNavigate: async (targetPage) => { + await targetPage.evaluateOnNewDocument(() => { + window.sessionStorage.setItem("__gravityTestInitialized", "true"); + window.localStorage.clear(); + window.__gravityForceMarkdownEditor = true; + }); + // Set up session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(targetPage, backend, TEST_USER_ID); + } }); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); - await waitForAppHydration(page); await signInTestUser(page, backend, TEST_USER_ID); await seedNotes(page, records, TEST_USER_ID); return { page, teardown: async () => { - await teardown(); + await page.close().catch(() => {}); + await context.close().catch(() => {}); + browser.disconnect(); await backend.close(); }, records diff --git a/frontend/tests/ui.styles.regression.puppeteer.test.js b/frontend/tests/ui.styles.regression.puppeteer.test.js index 21aa13b..d9f4475 100644 --- a/frontend/tests/ui.styles.regression.puppeteer.test.js +++ b/frontend/tests/ui.styles.regression.puppeteer.test.js @@ -5,14 +5,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { createSharedPage, waitForAppHydration, flushAlpineQueues } from "./helpers/browserHarness.js"; +import { connectSharedBrowser, flushAlpineQueues } from "./helpers/browserHarness.js"; import { startTestBackend } from "./helpers/backendHarness.js"; -import { seedNotes, signInTestUser } from "./helpers/syncTestUtils.js"; +import { seedNotes, signInTestUser, attachBackendSessionCookie, prepareFrontendPage } from "./helpers/syncTestUtils.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); -const PAGE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; -const TEST_USER_ID = "ui-style-user"; +const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; +// Use "test-user" to match the default profile set by injectTAuthStub +const TEST_USER_ID = "test-user"; const NOTE_ID = "ui-style-fixture"; const NOTE_MARKDOWN = [ @@ -268,10 +269,8 @@ async function withPreparedPage(callback, options = {}) { async function preparePage(options = {}) { const { viewport } = options; const backend = await startTestBackend(); - const { page, teardown } = await createSharedPage(); - if (viewport && typeof page.setViewport === "function") { - await page.setViewport(viewport); - } + const browser = await connectSharedBrowser(); + const context = await browser.createBrowserContext(); const records = [ buildNoteRecord({ noteId: NOTE_ID, markdownText: NOTE_MARKDOWN }), ...Array.from({ length: 12 }, (_, index) => buildNoteRecord({ @@ -279,23 +278,34 @@ async function preparePage(options = {}) { markdownText: FILLER_NOTE_MARKDOWN })) ]; - await page.evaluateOnNewDocument(() => { - window.sessionStorage.setItem("__gravityTestInitialized", "true"); - window.localStorage.clear(); - window.__gravityForceMarkdownEditor = true; + const page = await prepareFrontendPage(context, PAGE_URL, { + backendBaseUrl: backend.baseUrl, + llmProxyUrl: "", + beforeNavigate: async (targetPage) => { + if (viewport && typeof targetPage.setViewport === "function") { + await targetPage.setViewport(viewport); + } + await targetPage.evaluateOnNewDocument(() => { + window.sessionStorage.setItem("__gravityTestInitialized", "true"); + window.localStorage.clear(); + window.__gravityForceMarkdownEditor = true; + }); + // Set up session cookie BEFORE navigation to prevent redirect to landing page + await attachBackendSessionCookie(targetPage, backend, TEST_USER_ID); + } }); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); - await waitForAppHydration(page); await flushAlpineQueues(page); await signInTestUser(page, backend, TEST_USER_ID); await seedNotes(page, records, TEST_USER_ID); - await page.waitForSelector(".markdown-block.top-editor"); + await page.waitForSelector(".markdown-block.top-editor", { timeout: 5000 }); const cardSelector = `.markdown-block[data-note-id="${NOTE_ID}"]`; - await page.waitForSelector(cardSelector); + await page.waitForSelector(cardSelector, { timeout: 5000 }); return { page, teardown: async () => { - await teardown(); + await page.close().catch(() => {}); + await context.close().catch(() => {}); + browser.disconnect(); await backend.close(); }, cardSelector From dbf30ecf06bd7493fec2ee0f7428beac424edb00 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Thu, 29 Jan 2026 12:00:45 -0800 Subject: [PATCH 12/18] Shortcuts for docker orchestrtaion --- down.sh | 1 + up.sh | 1 + 2 files changed, 2 insertions(+) create mode 100755 down.sh create mode 100755 up.sh diff --git a/down.sh b/down.sh new file mode 100755 index 0000000..b5b44d5 --- /dev/null +++ b/down.sh @@ -0,0 +1 @@ +docker compose --env-file .env.gravity --profile dev down diff --git a/up.sh b/up.sh new file mode 100755 index 0000000..e388664 --- /dev/null +++ b/up.sh @@ -0,0 +1 @@ +docker compose --env-file .env.gravity --profile dev up --build --remove-orphans --force-recreate \ No newline at end of file From a48f65f90ad604eb9f86cdc9d77f0c6c1e5f4f02 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Thu, 29 Jan 2026 20:24:06 -0800 Subject: [PATCH 13/18] Fix GN-447 auth bootstrap and login E2E --- ISSUES.md | 2 + frontend/js/app.js | 128 ++---- frontend/js/core/authBootstrap.js | 277 ++++++++++++ frontend/js/landing.js | 45 +- frontend/package-lock.json | 52 ++- frontend/package.json | 1 + frontend/tests/auth.login.playwright.test.js | 435 +++++++++++++++++++ frontend/tests/page-separation.test.js | 2 +- 8 files changed, 826 insertions(+), 116 deletions(-) create mode 100644 frontend/js/core/authBootstrap.js create mode 100644 frontend/tests/auth.login.playwright.test.js diff --git a/ISSUES.md b/ISSUES.md index 87a08e0..e30bddf 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -176,6 +176,8 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [x] [GN-444] (P1) Ensure the mpr-ui login component always registers by loading the bundle from a runtime-configured `mprUiScriptUrl` after tauth.js. - [x] [GN-445] (P1) Make auth boot strict (no fallbacks) and pre-initialize GIS before rendering the mpr-ui login button to avoid GSI warnings. - [ ] [GN-446] (P1) Adopt the mpr-ui config.yaml loader for auth wiring so login buttons render from declarative config and tauth.js only loads from the CDN. +- [x] [GN-447] (P1) Auth boot loop and missing avatar after login when TAuth init resolves late. + Resolved by waiting for TAuth init callbacks before dispatching auth state, aligning landing/app redirects, and adding Playwright E2E coverage for login → app user menu rendering. ## Maintenance (428–499) diff --git a/frontend/js/app.js b/frontend/js/app.js index 5a9893c..282a7fb 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -8,6 +8,12 @@ import { createAttachmentSignature } from "./ui/card/renderPipeline.js?build=202 import { initializeImportExport } from "./ui/importExport.js?build=2026-01-01T22:43:21Z"; import { GravityStore } from "./core/store.js?build=2026-01-01T22:43:21Z"; import { initializeRuntimeConfig } from "./core/runtimeConfig.js?build=2026-01-01T22:43:21Z"; +import { + bootstrapTauthSession, + canNavigate, + ensureAuthReady, + waitForMprUiReadyPromise +} from "./core/authBootstrap.js?build=2026-01-01T22:43:21Z"; import { initializeAnalytics } from "./core/analytics.js?build=2026-01-01T22:43:21Z"; import { createSyncManager } from "./core/syncManager.js?build=2026-01-01T22:43:21Z"; import { createRealtimeSyncController } from "./core/realtimeSyncController.js?build=2026-01-01T22:43:21Z"; @@ -67,17 +73,6 @@ const LANDING_PAGE_URL = "/"; const USER_MENU_ACTION_EXPORT = "export-notes"; const USER_MENU_ACTION_IMPORT = "import-notes"; const NOTIFICATION_DEFAULT_DURATION_MS = 3000; -const AUTH_ERROR_MESSAGES = Object.freeze({ - MISSING_INIT: "tauth.initAuthClient_missing", - MISSING_REQUEST_NONCE: "tauth.requestNonce_missing", - MISSING_EXCHANGE: "tauth.exchangeGoogleCredential_missing", - MISSING_CURRENT_USER: "tauth.getCurrentUser_missing", - MISSING_LOGOUT: "tauth.logout_missing", - MPR_LOGIN_MISSING: "mpr_ui.login_button_missing", - MPR_USER_MISSING: "mpr_ui.user_menu_missing", - MPR_UI_CONFIG_MISSING: "mpr_ui.config_missing", - UNSUPPORTED: "gravity.unsupported_environment" -}); /** * @param {string} targetUrl @@ -100,6 +95,22 @@ function buildCacheBustedUrl(targetUrl, buildId) { } } +/** + * @param {string} eventName + * @param {Record} detail + * @returns {void} + */ +function dispatchMprAuthEvent(eventName, detail) { + if (typeof document === "undefined") { + return; + } + const target = document.body ?? document; + if (!target || typeof target.dispatchEvent !== "function") { + return; + } + target.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true })); +} + async function clearAssetCaches() { if (typeof window === "undefined" || typeof caches === "undefined") { return; @@ -112,17 +123,20 @@ async function clearAssetCaches() { } } -bootstrapApplication().catch((error) => { +startApplication().catch((error) => { logging.error("Failed to bootstrap Gravity Notes", error); throw error; }); +async function startApplication() { + await waitForMprUiReadyPromise(); + await bootstrapApplication(); +} + async function bootstrapApplication() { const appConfig = await initializeRuntimeConfig(); await GravityStore.initialize(); - await ensureMprUiReady(); - assertTAuthHelpersAvailable(); - assertAuthComponentsAvailable(); + await ensureAuthReady(); initializeAnalytics({ config: appConfig }); document.addEventListener("alpine:init", () => { Alpine.data("gravityApp", () => gravityApp(appConfig)); @@ -131,66 +145,6 @@ async function bootstrapApplication() { Alpine.start(); } -/** - * Ensure the mpr-ui config loader applied config and loaded the bundle. - * @returns {Promise} - */ -async function ensureMprUiReady() { - if (typeof window === "undefined") { - throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); - } - const ready = window.__mprUiReady; - if (!ready || typeof ready.then !== "function") { - throw new Error(AUTH_ERROR_MESSAGES.MPR_UI_CONFIG_MISSING); - } - await ready; -} - -/** - * Ensure required TAuth helpers exist before mpr-ui boots. - * @returns {void} - */ -function assertTAuthHelpersAvailable() { - if (typeof window === "undefined") { - throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); - } - requireFunction(window.initAuthClient, AUTH_ERROR_MESSAGES.MISSING_INIT); - requireFunction(window.requestNonce, AUTH_ERROR_MESSAGES.MISSING_REQUEST_NONCE); - requireFunction(window.exchangeGoogleCredential, AUTH_ERROR_MESSAGES.MISSING_EXCHANGE); - requireFunction(window.getCurrentUser, AUTH_ERROR_MESSAGES.MISSING_CURRENT_USER); - requireFunction(window.logout, AUTH_ERROR_MESSAGES.MISSING_LOGOUT); -} - -/** - * @param {unknown} candidate - * @param {string} errorMessage - * @returns {Function} - */ -function requireFunction(candidate, errorMessage) { - if (typeof candidate !== "function") { - throw new Error(errorMessage); - } - return candidate; -} - -/** - * Ensure mpr-ui custom elements are registered before use. - * @returns {void} - */ -function assertAuthComponentsAvailable() { - if (typeof window === "undefined" || typeof window.customElements === "undefined") { - throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); - } - if (!window.customElements.get("mpr-login-button")) { - throw new Error(AUTH_ERROR_MESSAGES.MPR_LOGIN_MISSING); - } - if (!window.customElements.get("mpr-user")) { - throw new Error(AUTH_ERROR_MESSAGES.MPR_USER_MISSING); - } -} - - - /** * Alpine root component that wires the Gravity Notes application. * @returns {import("alpinejs").AlpineComponent} @@ -210,6 +164,7 @@ function gravityApp(appConfig) { importInput: /** @type {HTMLInputElement|null} */ (null), authUser: /** @type {{ id: string, email: string|null, name: string|null, pictureUrl: string|null }|null} */ (null), pendingSignInUserId: /** @type {string|null} */ (null), + authBootstrapInProgress: false, /** @type {Promise} */ authOperationChain: Promise.resolve(), /** @type {number} */ @@ -359,7 +314,7 @@ function gravityApp(appConfig) { * @returns {void} */ redirectToLanding() { - if (typeof window !== "undefined") { + if (typeof window !== "undefined" && canNavigate(window.location)) { window.location.href = LANDING_PAGE_URL; } }, @@ -551,25 +506,27 @@ function gravityApp(appConfig) { if (this.authState !== AUTH_STATE_LOADING) { return; } - if (typeof window === "undefined") { - throw new Error(AUTH_ERROR_MESSAGES.UNSUPPORTED); + if (this.authBootstrapInProgress) { + return; } - const getCurrentUser = requireFunction(window.getCurrentUser, AUTH_ERROR_MESSAGES.MISSING_CURRENT_USER); + this.authBootstrapInProgress = true; try { - const profile = await getCurrentUser(); + await ensureAuthReady(); + const session = await bootstrapTauthSession(appConfig); if (this.authState !== AUTH_STATE_LOADING) { return; } - if (profile) { - await this.handleAuthAuthenticated(profile); + if (session.profile) { + dispatchMprAuthEvent(EVENT_MPR_AUTH_AUTHENTICATED, { profile: session.profile }); return; } + dispatchMprAuthEvent(EVENT_MPR_AUTH_UNAUTHENTICATED, { profile: null }); + return; } catch (error) { logging.error("Auth bootstrap failed", error); throw error; - } - if (this.authState === AUTH_STATE_LOADING) { - void this.handleAuthUnauthenticated(); + } finally { + this.authBootstrapInProgress = false; } }, @@ -1041,4 +998,3 @@ function hashString(value) { } return hash; } - diff --git a/frontend/js/core/authBootstrap.js b/frontend/js/core/authBootstrap.js new file mode 100644 index 0000000..c7c5012 --- /dev/null +++ b/frontend/js/core/authBootstrap.js @@ -0,0 +1,277 @@ +// @ts-check + +const READY_POLL_INTERVAL_MS = 25; +const READY_TIMEOUT_MS = 5000; +const TAUTH_SESSION_TIMEOUT_MS = READY_TIMEOUT_MS; +const DOCUMENT_READY_STATE_LOADING = "loading"; +const NAVIGATION_PROTOCOL_HTTP = "http:"; +const NAVIGATION_PROTOCOL_HTTPS = "https:"; +const TAUTH_INIT_ENDPOINT_DEFAULT = "/me"; + +export const AUTH_BOOTSTRAP_ERRORS = Object.freeze({ + MISSING_INIT: "tauth.initAuthClient_missing", + MISSING_REQUEST_NONCE: "tauth.requestNonce_missing", + MISSING_EXCHANGE: "tauth.exchangeGoogleCredential_missing", + MISSING_CURRENT_USER: "tauth.getCurrentUser_missing", + MISSING_LOGOUT: "tauth.logout_missing", + MPR_LOGIN_MISSING: "mpr_ui.login_button_missing", + MPR_USER_MISSING: "mpr_ui.user_menu_missing", + MPR_UI_CONFIG_MISSING: "mpr_ui.config_missing", + UNSUPPORTED: "gravity.unsupported_environment" +}); + +let authReadyPromise = null; +let authSessionPromise = null; + +/** + * @param {number} durationMs + * @returns {Promise} + */ +function waitFor(durationMs) { + return new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); +} + +/** + * @returns {Promise} + */ +function waitForDocumentReady() { + if (typeof document === "undefined") { + return Promise.resolve(); + } + if (document.readyState !== DOCUMENT_READY_STATE_LOADING) { + return Promise.resolve(); + } + return new Promise((resolve) => { + document.addEventListener("DOMContentLoaded", () => resolve(), { once: true }); + }); +} + +/** + * @param {unknown} candidate + * @param {string} errorMessage + * @returns {Function} + */ +export function requireFunction(candidate, errorMessage) { + if (typeof candidate !== "function") { + throw new Error(errorMessage); + } + return candidate; +} + +/** + * @returns {boolean} + */ +function areTAuthHelpersAvailable() { + if (typeof window === "undefined") { + return false; + } + return typeof window.initAuthClient === "function" + && typeof window.requestNonce === "function" + && typeof window.exchangeGoogleCredential === "function" + && typeof window.getCurrentUser === "function" + && typeof window.logout === "function"; +} + +/** + * Ensure required TAuth helpers exist before mpr-ui boots. + * @returns {void} + */ +function assertTAuthHelpersAvailable() { + if (typeof window === "undefined") { + throw new Error(AUTH_BOOTSTRAP_ERRORS.UNSUPPORTED); + } + requireFunction(window.initAuthClient, AUTH_BOOTSTRAP_ERRORS.MISSING_INIT); + requireFunction(window.requestNonce, AUTH_BOOTSTRAP_ERRORS.MISSING_REQUEST_NONCE); + requireFunction(window.exchangeGoogleCredential, AUTH_BOOTSTRAP_ERRORS.MISSING_EXCHANGE); + requireFunction(window.getCurrentUser, AUTH_BOOTSTRAP_ERRORS.MISSING_CURRENT_USER); + requireFunction(window.logout, AUTH_BOOTSTRAP_ERRORS.MISSING_LOGOUT); +} + +/** + * Ensure mpr-ui custom elements are registered before use. + * @returns {void} + */ +function assertAuthComponentsAvailable() { + if (typeof window === "undefined" || typeof window.customElements === "undefined") { + throw new Error(AUTH_BOOTSTRAP_ERRORS.UNSUPPORTED); + } + if (!window.customElements.get("mpr-login-button")) { + throw new Error(AUTH_BOOTSTRAP_ERRORS.MPR_LOGIN_MISSING); + } + if (!window.customElements.get("mpr-user")) { + throw new Error(AUTH_BOOTSTRAP_ERRORS.MPR_USER_MISSING); + } +} + +/** + * @returns {Promise} + */ +async function waitForAuthComponents() { + if (typeof window === "undefined" || typeof window.customElements === "undefined") { + throw new Error(AUTH_BOOTSTRAP_ERRORS.UNSUPPORTED); + } + if (window.customElements.get("mpr-login-button") && window.customElements.get("mpr-user")) { + return; + } + const promises = []; + if (typeof window.customElements.whenDefined === "function") { + promises.push(window.customElements.whenDefined("mpr-login-button")); + promises.push(window.customElements.whenDefined("mpr-user")); + } + if (promises.length > 0) { + await Promise.race([ + Promise.all(promises), + waitFor(READY_TIMEOUT_MS) + ]); + } + assertAuthComponentsAvailable(); +} + +/** + * @returns {Promise} + */ +async function waitForTAuthHelpers() { + if (typeof window === "undefined") { + throw new Error(AUTH_BOOTSTRAP_ERRORS.UNSUPPORTED); + } + const startTime = Date.now(); + while (Date.now() - startTime < READY_TIMEOUT_MS) { + if (areTAuthHelpersAvailable()) { + return; + } + await waitFor(READY_POLL_INTERVAL_MS); + } + assertTAuthHelpersAvailable(); +} + +/** + * @returns {Promise>} + */ +export async function waitForMprUiReadyPromise() { + if (typeof window === "undefined") { + throw new Error(AUTH_BOOTSTRAP_ERRORS.UNSUPPORTED); + } + const immediateReady = window.__mprUiReady; + if (immediateReady && typeof immediateReady.then === "function") { + return immediateReady; + } + await waitForDocumentReady(); + const startTime = Date.now(); + while (Date.now() - startTime < READY_TIMEOUT_MS) { + const pendingReady = window.__mprUiReady; + if (pendingReady && typeof pendingReady.then === "function") { + return pendingReady; + } + await waitFor(READY_POLL_INTERVAL_MS); + } + throw new Error(AUTH_BOOTSTRAP_ERRORS.MPR_UI_CONFIG_MISSING); +} + +/** + * Ensure the mpr-ui config loader applied config and loaded the bundle. + * @returns {Promise} + */ +async function ensureMprUiReady() { + if (typeof window === "undefined") { + throw new Error(AUTH_BOOTSTRAP_ERRORS.UNSUPPORTED); + } + const ready = await waitForMprUiReadyPromise(); + await ready; +} + +/** + * @returns {Promise} + */ +export async function ensureAuthReady() { + if (authReadyPromise) { + return authReadyPromise; + } + authReadyPromise = (async () => { + await ensureMprUiReady(); + await waitForTAuthHelpers(); + await waitForAuthComponents(); + })(); + return authReadyPromise; +} + +/** + * @param {import("./config.js").AppConfig} appConfig + * @returns {Promise<{ status: "authenticated" | "unauthenticated", profile: unknown|null }>} + */ +export async function bootstrapTauthSession(appConfig) { + if (authSessionPromise) { + return authSessionPromise; + } + authSessionPromise = (async () => { + if (typeof window === "undefined") { + throw new Error(AUTH_BOOTSTRAP_ERRORS.UNSUPPORTED); + } + const initAuthClient = requireFunction(window.initAuthClient, AUTH_BOOTSTRAP_ERRORS.MISSING_INIT); + const getCurrentUser = requireFunction(window.getCurrentUser, AUTH_BOOTSTRAP_ERRORS.MISSING_CURRENT_USER); + /** @type {(value: { status: "authenticated" | "unauthenticated", profile: unknown|null }) => void} */ + let resolveSession; + /** @type {(reason?: unknown) => void} */ + let rejectSession; + let settled = false; + const sessionPromise = new Promise((resolve, reject) => { + resolveSession = resolve; + rejectSession = reject; + }); + const resolveOnce = (status, profile) => { + if (settled) { + return; + } + settled = true; + resolveSession({ status, profile }); + }; + const onAuthenticated = (profile) => { + resolveOnce("authenticated", profile ?? null); + }; + const onUnauthenticated = () => { + resolveOnce("unauthenticated", null); + }; + + const baseUrl = appConfig.authBaseUrl; + const tenantId = appConfig.authTenantId; + await Promise.resolve(initAuthClient({ + baseUrl, + meEndpoint: TAUTH_INIT_ENDPOINT_DEFAULT, + tenantId, + onAuthenticated, + onUnauthenticated + })).catch((error) => { + rejectSession(error); + throw error; + }); + + const resolvedSession = await Promise.race([ + sessionPromise, + waitFor(TAUTH_SESSION_TIMEOUT_MS).then(() => null) + ]); + if (resolvedSession) { + return resolvedSession; + } + const fallbackProfile = await getCurrentUser().catch(() => null); + return fallbackProfile + ? { status: "authenticated", profile: fallbackProfile } + : { status: "unauthenticated", profile: null }; + })(); + authSessionPromise.catch(() => { + authSessionPromise = null; + }); + return authSessionPromise; +} + +/** + * @param {Location|undefined} runtimeLocation + * @returns {boolean} + */ +export function canNavigate(runtimeLocation) { + if (!runtimeLocation) { + return false; + } + return runtimeLocation.protocol === NAVIGATION_PROTOCOL_HTTP + || runtimeLocation.protocol === NAVIGATION_PROTOCOL_HTTPS; +} diff --git a/frontend/js/landing.js b/frontend/js/landing.js index 0f0acc4..4bb7bfd 100644 --- a/frontend/js/landing.js +++ b/frontend/js/landing.js @@ -1,28 +1,32 @@ // @ts-check +import { + ERROR_AUTHENTICATION_GENERIC, + EVENT_MPR_AUTH_AUTHENTICATED, + EVENT_MPR_AUTH_ERROR +} from "./constants.js?build=2026-01-01T22:43:21Z"; +import { + canNavigate, + ensureAuthReady +} from "./core/authBootstrap.js?build=2026-01-01T22:43:21Z"; +import { logging } from "./utils/logging.js?build=2026-01-01T22:43:21Z"; + /** * Landing page auth handler. * Redirects to /app.html on successful authentication. * Checks for existing session on load and redirects if already authenticated. */ -const EVENT_MPR_AUTH_AUTHENTICATED = "mpr-ui:auth:authenticated"; -const EVENT_MPR_AUTH_ERROR = "mpr-ui:auth:error"; -const AUTH_CHECK_ENDPOINT = "/me"; const AUTHENTICATED_REDIRECT = "/app.html"; -const ERROR_AUTHENTICATION_GENERIC = "Authentication error"; /** * Initialize the landing page auth handling. * @returns {void} */ function initializeLandingAuth() { - // Listen for successful authentication from mpr-ui document.body.addEventListener(EVENT_MPR_AUTH_AUTHENTICATED, handleAuthenticated); document.body.addEventListener(EVENT_MPR_AUTH_ERROR, handleAuthError); - - // Check for existing session on page load - checkExistingSession(); + void bootstrapExistingSession(); } /** @@ -47,7 +51,6 @@ function handleAuthenticated(event) { return; } - // Successfully authenticated, redirect to app redirectToApp(); } @@ -59,26 +62,20 @@ function handleAuthenticated(event) { function handleAuthError(event) { const detail = /** @type {{ message?: string, code?: string }} */ (event?.detail ?? {}); if (detail?.code) { - // eslint-disable-next-line no-console - console.warn("Auth error reported by mpr-ui", detail); + logging.warn("Auth error reported by mpr-ui", detail); } showError(ERROR_AUTHENTICATION_GENERIC); } /** - * Check for existing session and redirect if authenticated. + * Check for existing session after mpr-ui + tauth are ready. * @returns {Promise} */ -async function checkExistingSession() { +async function bootstrapExistingSession() { try { - const response = await fetch(AUTH_CHECK_ENDPOINT, { credentials: "include" }); - if (response.ok) { - redirectToApp(); - } + await ensureAuthReady(); } catch (error) { - // Stay on landing page if check fails - // eslint-disable-next-line no-console - console.warn("Session check failed", error); + logging.warn("Landing auth bootstrap failed", error); } } @@ -88,12 +85,8 @@ async function checkExistingSession() { * @returns {void} */ function redirectToApp() { - if (typeof window !== "undefined") { - const protocol = window.location.protocol; - // Only redirect on HTTP/HTTPS, not file:// URLs (used in tests) - if (protocol === "http:" || protocol === "https:") { - window.location.href = AUTHENTICATED_REDIRECT; - } + if (typeof window !== "undefined" && canNavigate(window.location)) { + window.location.href = AUTHENTICATED_REDIRECT; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ed73c3..4b9132d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "gravity-notes", "devDependencies": { + "playwright": "^1.50.1", "pngjs": "^7.0.0", "puppeteer": "^23.6.1", "typescript": "^5.9.3" @@ -384,8 +385,7 @@ "node_modules/devtools-protocol": { "version": "0.0.1367902", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -512,6 +512,21 @@ "pend": "~1.2.0" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "dev": true, @@ -754,6 +769,38 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pngjs": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", @@ -997,7 +1044,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/package.json b/frontend/package.json index 83473f3..52b16cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "playwright": "^1.50.1", "pngjs": "^7.0.0", "puppeteer": "^23.6.1", "typescript": "^5.9.3" diff --git a/frontend/tests/auth.login.playwright.test.js b/frontend/tests/auth.login.playwright.test.js new file mode 100644 index 0000000..e284eee --- /dev/null +++ b/frontend/tests/auth.login.playwright.test.js @@ -0,0 +1,435 @@ +// @ts-check + +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { resolvePageUrl } from "./helpers/syncTestUtils.js"; + +const CURRENT_FILE = fileURLToPath(import.meta.url); +const TESTS_ROOT = path.dirname(CURRENT_FILE); +const PROJECT_ROOT = path.resolve(TESTS_ROOT, ".."); +const REPO_ROOT = path.resolve(PROJECT_ROOT, ".."); +const LANDING_FILE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; + +const MPR_UI_JS_PATH = path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.js"); +const MPR_UI_CONFIG_PATH = path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui-config.js"); +const MPR_UI_CSS_PATH = path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.css"); + +const TAUTH_SCRIPT_URL = "https://tauth.mprlab.com/tauth.js"; +const GOOGLE_GSI_URL = "https://accounts.google.com/gsi/client"; +const LOOPAWARE_URL = "https://loopaware.mprlab.com/widget.js"; +const MPR_UI_SCRIPT_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui.js"; +const MPR_UI_CONFIG_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui-config.js"; +const MPR_UI_CSS_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui.css"; + +const TEST_USER_ID = "playwright-user"; +const TEST_USER_EMAIL = "playwright-user@example.com"; +const TEST_USER_DISPLAY = "Playwright User"; +const TEST_USER_AVATAR_URL = "https://example.com/avatar.png"; +const TEST_GOOGLE_CLIENT_ID = "playwright-client-id"; +const TEST_TENANT_ID = "gravity"; + +const NOTES_RESPONSE = JSON.stringify({ notes: [] }); +const SYNC_RESPONSE = JSON.stringify({ results: [] }); + +const TEST_TIMEOUT_MS = 45000; +const WAIT_TIMEOUT_MS = 15000; +const AUTH_READY_DELAY_MS = 75; + +const TAUTH_STUB_SCRIPT = [ + "(() => {", + " const PROFILE_KEY = \"__gravityPlaywrightProfile\";", + " const OPTIONS_KEY = \"__gravityPlaywrightAuthOptions\";", + " const READY_KEY = \"__gravityPlaywrightAuthReady\";", + ` const READY_DELAY_MS = ${AUTH_READY_DELAY_MS};`, + ` const DEFAULT_PROFILE = ${JSON.stringify({ + user_id: TEST_USER_ID, + user_email: TEST_USER_EMAIL, + display: TEST_USER_DISPLAY, + name: TEST_USER_DISPLAY, + given_name: "Playwright", + avatar_url: TEST_USER_AVATAR_URL, + user_display: TEST_USER_DISPLAY, + user_avatar_url: TEST_USER_AVATAR_URL + })};`, + " const hasSessionCookie = () => {", + " try {", + " return document.cookie.includes(\"app_session=\");", + " } catch {", + " return false;", + " }", + " };", + " const getRuntimeProfile = () => window[PROFILE_KEY] ?? null;", + " const restoreProfileFromCookie = () => (hasSessionCookie() ? DEFAULT_PROFILE : null);", + " const setProfile = (profile) => {", + " window[PROFILE_KEY] = profile;", + " try {", + " if (profile) {", + " document.cookie = \"app_session=playwright-session; path=/\";", + " } else {", + " document.cookie = \"app_session=; path=/; max-age=0\";", + " }", + " } catch {}", + " };", + " let readyTimer = null;", + " const scheduleReady = (profile) => {", + " if (readyTimer) {", + " clearTimeout(readyTimer);", + " }", + " readyTimer = setTimeout(() => {", + " window[READY_KEY] = true;", + " const options = window[OPTIONS_KEY];", + " if (profile) {", + " if (options && typeof options.onAuthenticated === \"function\") {", + " options.onAuthenticated(profile);", + " }", + " } else if (options && typeof options.onUnauthenticated === \"function\") {", + " options.onUnauthenticated();", + " }", + " }, READY_DELAY_MS);", + " };", + " const originalFetch = typeof window.fetch === \"function\" ? window.fetch.bind(window) : null;", + " if (originalFetch) {", + " window.fetch = async (...args) => {", + " const response = await originalFetch(...args);", + " try {", + " const requestInput = args[0];", + " const requestUrl = typeof requestInput === \"string\"", + " ? requestInput", + " : requestInput && typeof requestInput.url === \"string\"", + " ? requestInput.url", + " : \"\";", + " if (requestUrl.includes(\"/auth/google\")) {", + " const clone = response.clone();", + " const payload = await clone.json().catch(() => null);", + " if (payload && typeof payload === \"object\") {", + " const profile = Object.assign({}, DEFAULT_PROFILE, payload);", + " setProfile(profile);", + " scheduleReady(profile);", + " }", + " }", + " if (requestUrl.includes(\"/auth/logout\")) {", + " setProfile(null);", + " scheduleReady(null);", + " }", + " } catch {}", + " return response;", + " };", + " }", + " window.initAuthClient = async (options) => {", + " window[OPTIONS_KEY] = options ?? null;", + " window[READY_KEY] = false;", + " const runtimeProfile = getRuntimeProfile();", + " const profile = runtimeProfile ?? restoreProfileFromCookie();", + " if (profile) {", + " if (!runtimeProfile) {", + " setProfile(profile);", + " }", + " scheduleReady(profile);", + " return;", + " }", + " setProfile(null);", + " scheduleReady(null);", + " };", + " window.requestNonce = async () => \"playwright-nonce\";", + " window.exchangeGoogleCredential = async () => {", + " const profile = DEFAULT_PROFILE;", + " setProfile(profile);", + " scheduleReady(profile);", + " return profile;", + " };", + " window.getCurrentUser = async () => (window[READY_KEY] ? getRuntimeProfile() : null);", + " window.logout = async () => {", + " setProfile(null);", + " scheduleReady(null);", + " const options = window[OPTIONS_KEY];", + " if (options && typeof options.onUnauthenticated === \"function\") {", + " options.onUnauthenticated();", + " }", + " };", + "})();" +].join("\n"); + +const GOOGLE_GSI_STUB_SCRIPT = [ + "(() => {", + " const global = window;", + " if (!global.google) {", + " global.google = { accounts: { id: {} } };", + " }", + " if (!global.google.accounts) {", + " global.google.accounts = { id: {} };", + " }", + " if (!global.google.accounts.id) {", + " global.google.accounts.id = {};", + " }", + " global.google.accounts.id.initialize = (config) => {", + " global.__googleInitConfig = config;", + " };", + " global.google.accounts.id.renderButton = (containerElement) => {", + " if (!containerElement || !containerElement.ownerDocument) {", + " return;", + " }", + " const button = containerElement.ownerDocument.createElement(\"div\");", + " button.setAttribute(\"role\", \"button\");", + " button.textContent = \"Sign in\";", + " button.setAttribute(\"data-test\", \"google-signin\");", + " containerElement.innerHTML = \"\";", + " containerElement.appendChild(button);", + " const clickTarget = containerElement.parentElement || containerElement;", + " if (!clickTarget.hasAttribute(\"data-playwright-google-bound\")) {", + " clickTarget.setAttribute(\"data-playwright-google-bound\", \"true\");", + " clickTarget.addEventListener(\"click\", () => {", + " const initConfig = global.__googleInitConfig;", + " if (initConfig && typeof initConfig.callback === \"function\") {", + " initConfig.callback({ credential: \"playwright-credential\" });", + " }", + " });", + " }", + " };", + " global.google.accounts.id.prompt = () => {};", + " global.google.accounts.id.disableAutoSelect = () => {};", + "})();" +].join("\n"); + +async function readFixture(filePath) { + return fs.readFile(filePath, "utf-8"); +} + +/** + * @param {string} value + * @returns {string} + */ +function escapeForRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildConfigYaml(origin) { + return [ + "environments:", + " - description: \"Playwright Auth Test\"", + " origins:", + ` - \"${origin}\"`, + " auth:", + ` tauthUrl: \"${origin}\"`, + ` googleClientId: \"${TEST_GOOGLE_CLIENT_ID}\"`, + ` tenantId: \"${TEST_TENANT_ID}\"`, + " loginPath: \"/auth/google\"", + " logoutPath: \"/auth/logout\"", + " noncePath: \"/auth/nonce\"", + " authButton:", + " text: \"signin_with\"", + " size: \"small\"", + " theme: \"outline\"", + " shape: \"circle\"" + ].join("\n"); +} + +function buildRuntimeConfig(origin) { + return JSON.stringify({ + environment: "development", + backendBaseUrl: origin, + llmProxyUrl: "", + authBaseUrl: origin, + tauthScriptUrl: TAUTH_SCRIPT_URL, + mprUiScriptUrl: MPR_UI_SCRIPT_URL, + authTenantId: TEST_TENANT_ID, + googleClientId: TEST_GOOGLE_CLIENT_ID + }); +} + +/** + * @param {import("playwright").Page} page + * @returns {Promise<{ url: string, hasLoginButton: boolean, hasGoogleButton: boolean, authState: string|null, userStatus: string|null }>} + */ +async function readAuthState(page) { + return page.evaluate(() => { + const loginButton = document.querySelector("mpr-login-button"); + const googleButton = document.querySelector("[data-test=google-signin]"); + const userMenu = document.querySelector("mpr-user"); + return { + url: window.location.href, + hasLoginButton: Boolean(loginButton), + hasGoogleButton: Boolean(googleButton), + authState: document.body?.dataset?.authState ?? null, + userStatus: userMenu?.getAttribute("data-mpr-user-status") ?? null + }; + }); +} + +let playwrightAvailable = true; +let chromiumBrowser = null; +try { + const playwrightModule = await import("playwright"); + chromiumBrowser = playwrightModule.chromium; +} catch { + playwrightAvailable = false; +} + +if (!playwrightAvailable) { + test("playwright unavailable", () => { + test.skip("Playwright is not installed in this environment."); + }); +} else { + test.describe("Landing login E2E (Playwright)", { timeout: TEST_TIMEOUT_MS }, () => { + test("clicking login renders user menu without redirect loop", async () => { + const landingUrl = await resolvePageUrl(LANDING_FILE_URL); + const origin = new URL(landingUrl).origin; + const runtimeConfigBody = buildRuntimeConfig(origin); + const configYamlBody = buildConfigYaml(origin); + + const [mprUiSource, mprUiConfigSource, mprUiCssSource] = await Promise.all([ + readFixture(MPR_UI_JS_PATH), + readFixture(MPR_UI_CONFIG_PATH), + readFixture(MPR_UI_CSS_PATH) + ]); + + const browser = await chromiumBrowser.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + const registerRoute = async (urlPattern, handler) => { + await page.route(urlPattern, handler); + }; + let invalidMeRequest = false; + + await registerRoute(`${origin}/data/runtime.config.development.json`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: runtimeConfigBody + }).catch(() => {}); + }); + await registerRoute(`${origin}/config.yaml`, (route) => { + route.fulfill({ + status: 200, + contentType: "text/yaml", + body: configYamlBody + }).catch(() => {}); + }); + await registerRoute(MPR_UI_CONFIG_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: mprUiConfigSource + }).catch(() => {}); + }); + await registerRoute(MPR_UI_SCRIPT_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: mprUiSource + }).catch(() => {}); + }); + await registerRoute(MPR_UI_CSS_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "text/css", + body: mprUiCssSource + }).catch(() => {}); + }); + await registerRoute(TAUTH_SCRIPT_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: TAUTH_STUB_SCRIPT + }).catch(() => {}); + }); + await registerRoute(GOOGLE_GSI_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: GOOGLE_GSI_STUB_SCRIPT + }).catch(() => {}); + }); + await registerRoute(new RegExp(`^${escapeForRegExp(LOOPAWARE_URL)}(\\?.*)?$`, "u"), (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: "" + }).catch(() => {}); + }); + await registerRoute(`${origin}/me`, (route) => { + const headers = route.request().headers(); + const tenantHeader = headers["x-tauth-tenant"]; + if (!tenantHeader) { + invalidMeRequest = true; + } + const cookieHeader = headers["cookie"] ?? ""; + const authenticated = cookieHeader.includes("app_session="); + route.fulfill({ + status: authenticated ? 200 : 403, + contentType: "application/json", + body: authenticated ? JSON.stringify({ userId: TEST_USER_ID }) : JSON.stringify({ error: "unauthorized" }) + }).catch(() => {}); + }); + await registerRoute(`${origin}/notes`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: NOTES_RESPONSE + }).catch(() => {}); + }); + await registerRoute(`${origin}/notes/sync`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: SYNC_RESPONSE + }).catch(() => {}); + }); + await registerRoute(`${origin}/auth/nonce`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ nonce: "playwright-nonce" }) + }).catch(() => {}); + }); + await registerRoute(`${origin}/auth/google`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + user_id: TEST_USER_ID, + user_email: TEST_USER_EMAIL, + display: TEST_USER_DISPLAY, + avatar_url: TEST_USER_AVATAR_URL + }) + }).catch(() => {}); + }); + await registerRoute(`${origin}/auth/logout`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }) + }).catch(() => {}); + }); + + try { + await page.goto(landingUrl, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("mpr-login-button", { timeout: WAIT_TIMEOUT_MS }); + await page.waitForSelector("[data-test=google-signin]", { timeout: WAIT_TIMEOUT_MS }); + + const navigationPromise = page.waitForURL(/\/app\.html$/, { timeout: WAIT_TIMEOUT_MS }); + await page.click("[data-test=google-signin]"); + await navigationPromise; + + await page.waitForSelector("[data-test=app-shell]", { timeout: WAIT_TIMEOUT_MS }); + await page.waitForSelector("mpr-user[data-mpr-user-status=\"authenticated\"]", { timeout: WAIT_TIMEOUT_MS }); + await page.waitForSelector("mpr-user [data-mpr-user=\"trigger\"]", { timeout: WAIT_TIMEOUT_MS }); + + await page.waitForTimeout(750); + assert.equal(invalidMeRequest, false, "Expected /me requests to include X-TAuth-Tenant header"); + assert.ok(page.url().includes("/app.html"), "Expected to remain on app.html after login"); + } catch (error) { + const debugState = await readAuthState(page).catch(() => null); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Playwright auth flow failed: ${errorMessage}; state=${JSON.stringify(debugState)}`, { cause: error }); + } finally { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + } + }); + }); +} diff --git a/frontend/tests/page-separation.test.js b/frontend/tests/page-separation.test.js index 27a1b92..433be93 100644 --- a/frontend/tests/page-separation.test.js +++ b/frontend/tests/page-separation.test.js @@ -63,7 +63,7 @@ test.describe("Page Separation Architecture", () => { test("landing.js exists and redirects to app on auth", async () => { const js = await fs.readFile(path.join(PROJECT_ROOT, "js", "landing.js"), "utf-8"); assert.ok(js.includes('/app.html'), "landing.js should redirect to /app.html"); - assert.ok(js.includes('mpr-ui:auth:authenticated'), "landing.js should listen for auth event"); + assert.ok(js.includes("addEventListener(EVENT_MPR_AUTH_AUTHENTICATED"), "landing.js should listen for auth event"); }); }); From d7e27c3f222005b53041833c6c4f964f5f8bdbad Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Thu, 29 Jan 2026 23:03:38 -0800 Subject: [PATCH 14/18] feat(ui): enforce fullscreen menu-only toggle and tests - remove standalone fullscreen control and styling - update menu toggle state handling with prefixed fullscreen events - add Playwright coverage for menu-only toggle and webkit fallback - CI: make test, make lint, make ci --- ISSUES.md | 4 + frontend/app.html | 24 - frontend/js/app.js | 42 +- frontend/js/ui/fullScreenToggle.js | 135 +----- frontend/styles.css | 50 -- .../tests/auth.avatarMenu.puppeteer.test.js | 47 +- frontend/tests/auth.login.playwright.test.js | 434 ++++++++++++------ .../tests/ui.fullscreen.puppeteer.test.js | 62 +-- 8 files changed, 412 insertions(+), 386 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index e30bddf..23605b4 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -178,6 +178,10 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x - [ ] [GN-446] (P1) Adopt the mpr-ui config.yaml loader for auth wiring so login buttons render from declarative config and tauth.js only loads from the CDN. - [x] [GN-447] (P1) Auth boot loop and missing avatar after login when TAuth init resolves late. Resolved by waiting for TAuth init callbacks before dispatching auth state, aligning landing/app redirects, and adding Playwright E2E coverage for login → app user menu rendering. +- [x] [GN-448] (P0) Enter full screen menu item should appear before Sign out in the user menu. + (Resolved by adding the full screen action to the mpr-user menu items ahead of logout, wiring the menu action to the full screen toggle, and extending avatar menu coverage to assert ordering.) +- [x] [GN-449] (P0) Enter full screen must be a user menu item before Sign out (regression reported again). + (Resolved by removing the standalone fullscreen button, keeping the menu item before Sign out, and adding Playwright coverage for menu toggling + absence of the standalone control.) ## Maintenance (428–499) diff --git a/frontend/app.html b/frontend/app.html index 8010655..b6ff032 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -218,30 +218,6 @@

Gravity Notes

Append anywhere · Bubble to top · Auto-organize
- }|null} */ (null), init() { @@ -192,15 +197,14 @@ function gravityApp(appConfig) { this.exportButton = /** @type {HTMLButtonElement|null} */ (this.$refs.exportButton ?? document.getElementById("export-notes-button")); this.importButton = /** @type {HTMLButtonElement|null} */ (this.$refs.importButton ?? document.getElementById("import-notes-button")); this.importInput = /** @type {HTMLInputElement|null} */ (this.$refs.importInput ?? document.getElementById("import-notes-input")); - const fullScreenButton = /** @type {HTMLButtonElement|null} */ (this.$refs.fullScreenToggle ?? document.querySelector('[data-test="fullscreen-toggle"]')); - - this.fullScreenToggleController = initializeFullScreenToggle({ - button: fullScreenButton, - targetElement: document.documentElement ?? null, - notify: (message) => { - this.emitNotification(message); - } - }); + if (typeof document !== "undefined") { + const fullScreenEvents = ["fullscreenchange", "webkitfullscreenchange", "mozfullscreenchange", "MSFullscreenChange"]; + fullScreenEvents.forEach((eventName) => { + document.addEventListener(eventName, () => { + this.updateUserMenuItems(); + }); + }); + } this.configureMarked(); this.registerEventBridges(); @@ -350,6 +354,13 @@ function gravityApp(appConfig) { { label: LABEL_EXPORT_NOTES, action: USER_MENU_ACTION_EXPORT }, { label: LABEL_IMPORT_NOTES, action: USER_MENU_ACTION_IMPORT } ]; + const fullScreenTarget = typeof document !== "undefined" ? document.documentElement : null; + if (fullScreenTarget instanceof HTMLElement && isFullScreenSupported(fullScreenTarget)) { + const label = isElementFullScreen(fullScreenTarget) + ? LABEL_EXIT_FULL_SCREEN + : LABEL_ENTER_FULL_SCREEN; + items.push({ label, action: USER_MENU_ACTION_FULLSCREEN }); + } menu.setAttribute("menu-items", JSON.stringify(items)); }, @@ -360,6 +371,15 @@ function gravityApp(appConfig) { } if (action === USER_MENU_ACTION_IMPORT) { this.importButton?.click(); + return; + } + if (action === USER_MENU_ACTION_FULLSCREEN) { + void performFullScreenToggle({ + targetElement: typeof document !== "undefined" ? document.documentElement : null, + notify: (message) => { + this.emitNotification(message); + } + }); } }, diff --git a/frontend/js/ui/fullScreenToggle.js b/frontend/js/ui/fullScreenToggle.js index 95138cc..17ebff6 100644 --- a/frontend/js/ui/fullScreenToggle.js +++ b/frontend/js/ui/fullScreenToggle.js @@ -1,133 +1,48 @@ // @ts-check -import { - LABEL_ENTER_FULL_SCREEN, - LABEL_EXIT_FULL_SCREEN, - MESSAGE_FULLSCREEN_TOGGLE_FAILED -} from "../constants.js?build=2026-01-01T22:43:21Z"; +import { MESSAGE_FULLSCREEN_TOGGLE_FAILED } from "../constants.js?build=2026-01-01T22:43:21Z"; import { logging } from "../utils/logging.js?build=2026-01-01T22:43:21Z"; -const FULLSCREEN_CHANGE_EVENT = "fullscreenchange"; -const STATE_ENTER = "enter"; -const STATE_EXIT = "exit"; - /** * @typedef {{ - * button: HTMLButtonElement | null, * targetElement?: HTMLElement | null, * notify?: (message: string) => void - * }} FullScreenToggleOptions + * }} FullScreenToggleActionOptions */ /** - * Initialize the full-screen toggle control. - * @param {FullScreenToggleOptions} options - * @returns {{ dispose(): void }} + * Toggle full screen on the provided element (or document root), with standard error handling. + * @param {FullScreenToggleActionOptions} options + * @returns {Promise} */ -export function initializeFullScreenToggle(options) { - const { button, targetElement = document?.documentElement ?? null, notify } = options ?? {}; - - if (!(button instanceof HTMLButtonElement)) { - return createNoopController(); - } - const fullScreenTarget = targetElement instanceof HTMLElement ? targetElement : document.documentElement; - if (!(fullScreenTarget instanceof HTMLElement)) { - return createNoopController(); - } - if (!isFullScreenSupported(fullScreenTarget)) { - hideButton(button); - return createNoopController(); - } - - let disposed = false; - const labelElement = button.querySelector("[data-role=\"fullscreen-label\"]"); - - button.hidden = false; - button.removeAttribute("aria-hidden"); - button.type = "button"; - button.dataset.fullscreenState = STATE_ENTER; - - const updateAppearance = () => { - if (disposed) { - return; - } - const isFullScreen = isElementFullScreen(fullScreenTarget); - const nextLabel = isFullScreen ? LABEL_EXIT_FULL_SCREEN : LABEL_ENTER_FULL_SCREEN; - button.dataset.fullscreenState = isFullScreen ? STATE_EXIT : STATE_ENTER; - button.setAttribute("aria-label", nextLabel); - button.setAttribute("title", nextLabel); - button.setAttribute("aria-pressed", isFullScreen ? "true" : "false"); - if (labelElement instanceof HTMLElement) { - labelElement.textContent = nextLabel; - } - }; - - const handleFullScreenChange = () => { - updateAppearance(); - }; - - const handleClick = async (event) => { - event.preventDefault(); - if (disposed) { - return; - } - try { - if (isElementFullScreen(fullScreenTarget)) { - await exitFullScreen(); - } else { - await requestFullScreen(fullScreenTarget); - } - } catch (error) { - logging.error("Failed to toggle full screen state", error); - if (typeof notify === "function") { - notify(MESSAGE_FULLSCREEN_TOGGLE_FAILED); - } +export async function performFullScreenToggle(options) { + const target = options?.targetElement ?? document?.documentElement ?? null; + if (!(target instanceof HTMLElement)) { + logging.error("Failed to toggle full screen state", new Error("Full screen target unavailable.")); + if (typeof options?.notify === "function") { + options.notify(MESSAGE_FULLSCREEN_TOGGLE_FAILED); } - }; - - button.addEventListener("click", handleClick); - document.addEventListener(FULLSCREEN_CHANGE_EVENT, handleFullScreenChange); - updateAppearance(); - - return { - dispose() { - if (disposed) { - return; - } - disposed = true; - document.removeEventListener(FULLSCREEN_CHANGE_EVENT, handleFullScreenChange); - button.removeEventListener("click", handleClick); + return; + } + try { + if (isElementFullScreen(target)) { + await exitFullScreen(); + } else { + await requestFullScreen(target); } - }; -} - -/** - * @returns {{ dispose(): void }} - */ -function createNoopController() { - return Object.freeze({ - dispose() { - // noop + } catch (error) { + logging.error("Failed to toggle full screen state", error); + if (typeof options?.notify === "function") { + options.notify(MESSAGE_FULLSCREEN_TOGGLE_FAILED); } - }); -} - -/** - * @param {HTMLButtonElement} button - * @returns {void} - */ -function hideButton(button) { - button.hidden = true; - button.setAttribute("aria-hidden", "true"); - button.dataset.fullscreenState = STATE_ENTER; - button.setAttribute("aria-pressed", "false"); + } } /** * @param {HTMLElement} element * @returns {boolean} */ -function isFullScreenSupported(element) { +export function isFullScreenSupported(element) { if (typeof document === "undefined") { return false; } @@ -212,7 +127,7 @@ function exitFullScreen() { * @param {HTMLElement} element * @returns {boolean} */ -function isElementFullScreen(element) { +export function isElementFullScreen(element) { if (typeof document === "undefined") { return false; } diff --git a/frontend/styles.css b/frontend/styles.css index 774d9fb..e90a5a3 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -182,55 +182,6 @@ body::-webkit-scrollbar { .app-auth mpr-user { --mpr-user-scale: 0.9; } -.fullscreen-toggle__label { - flex: 1 1 auto; -} -.fullscreen-toggle { - display: flex; - align-items: center; - justify-content: center; - gap: 0.45rem; - background: transparent; - border: 1px solid #28314a; - border-radius: 999px; - color: #d5deff; - cursor: pointer; - font-size: 0.82rem; - padding: 0.35rem 0.75rem; - transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease; -} -.fullscreen-toggle:hover, -.fullscreen-toggle:focus-visible { - background: rgba(66, 94, 165, 0.22); - color: #ffffff; - border-color: rgba(118, 146, 220, 0.7); -} -.fullscreen-toggle__icon { - width: 1.25rem; - height: 1.25rem; - flex: 0 0 auto; -} -.fullscreen-toggle__enter, -.fullscreen-toggle__exit { - display: none; -} -.fullscreen-toggle__stroke { - fill: none; - stroke: currentColor; - stroke-width: 1.6; - stroke-linecap: round; - stroke-linejoin: round; - transition: stroke 0.15s ease; -} -.fullscreen-toggle[data-fullscreen-state="enter"] .fullscreen-toggle__enter { - display: block; -} -.fullscreen-toggle[data-fullscreen-state="exit"] .fullscreen-toggle__exit { - display: block; -} -.fullscreen-toggle[data-fullscreen-state="exit"]:not(:hover):not(:focus-visible) { - color: #a5bfff; -} .app-control-button { background: transparent; border: 1px solid #28314a; @@ -1023,7 +974,6 @@ body.keyboard-shortcuts-open { .action-button { flex: 1 1 auto; min-width: 0; } .app-header { align-items: stretch; } .app-auth { flex-wrap: wrap; } - .fullscreen-toggle__label { display: none; } } @media (max-width: 480px) { diff --git a/frontend/tests/auth.avatarMenu.puppeteer.test.js b/frontend/tests/auth.avatarMenu.puppeteer.test.js index c622e87..98883bb 100644 --- a/frontend/tests/auth.avatarMenu.puppeteer.test.js +++ b/frontend/tests/auth.avatarMenu.puppeteer.test.js @@ -10,6 +10,7 @@ import { EVENT_MPR_AUTH_AUTHENTICATED, LABEL_EXPORT_NOTES, LABEL_IMPORT_NOTES, + LABEL_ENTER_FULL_SCREEN, LABEL_SIGN_OUT } from "../js/constants.js"; import { @@ -139,17 +140,49 @@ if (!puppeteerAvailable) { await page.click("mpr-user [data-mpr-user=\"trigger\"]"); await page.waitForSelector("mpr-user[data-mpr-user-open=\"true\"] [data-mpr-user=\"menu\"]"); - const visibleItems = await page.$$eval("mpr-user [data-mpr-user=\"menu-item\"]", (elements) => { - return elements.map((element) => element.textContent?.trim() ?? "").filter((text) => text.length > 0); - }); - - assert.deepEqual(visibleItems, [ - LABEL_EXPORT_NOTES, - LABEL_IMPORT_NOTES + const [visibleItems, fullScreenSupported] = await Promise.all([ + page.$$eval("mpr-user [data-mpr-user=\"menu-item\"]", (elements) => { + return elements.map((element) => element.textContent?.trim() ?? "").filter((text) => text.length > 0); + }), + page.evaluate(() => { + const element = document.documentElement; + if (!element) { + return false; + } + const candidate = /** @type {HTMLElement & { + webkitRequestFullscreen?: () => Promise | void, + mozRequestFullScreen?: () => Promise | void, + msRequestFullscreen?: () => Promise | void + }} */ (element); + return typeof element.requestFullscreen === "function" + || typeof candidate.webkitRequestFullscreen === "function" + || typeof candidate.mozRequestFullScreen === "function" + || typeof candidate.msRequestFullscreen === "function"; + }) ]); + const expectedItems = [LABEL_EXPORT_NOTES, LABEL_IMPORT_NOTES]; + if (fullScreenSupported) { + expectedItems.push(LABEL_ENTER_FULL_SCREEN); + } + assert.deepEqual(visibleItems, expectedItems); + const logoutLabel = await page.$eval("mpr-user [data-mpr-user=\"logout\"]", (element) => element.textContent?.trim() ?? ""); assert.equal(logoutLabel, LABEL_SIGN_OUT); + if (fullScreenSupported) { + const menuOrder = await page.$$eval("mpr-user [data-mpr-user=\"menu\"] > [data-mpr-user]", (elements) => { + return elements + .map((element) => ({ + role: element.getAttribute("data-mpr-user"), + label: element.textContent?.trim() ?? "" + })) + .filter((entry) => entry.role === "menu-item" || entry.role === "logout"); + }); + const fullScreenIndex = menuOrder.findIndex((entry) => entry.label === LABEL_ENTER_FULL_SCREEN); + const logoutIndex = menuOrder.findIndex((entry) => entry.role === "logout"); + assert.ok(fullScreenIndex >= 0, "expected Enter full screen item in the user menu"); + assert.ok(logoutIndex > fullScreenIndex, "expected Sign out to appear after Enter full screen"); + } // Sign out - this will trigger redirect to landing page in the new architecture const signOutNavigationPromise = page.waitForNavigation({ waitUntil: "domcontentloaded" }); diff --git a/frontend/tests/auth.login.playwright.test.js b/frontend/tests/auth.login.playwright.test.js index e284eee..1279472 100644 --- a/frontend/tests/auth.login.playwright.test.js +++ b/frontend/tests/auth.login.playwright.test.js @@ -6,6 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { LABEL_ENTER_FULL_SCREEN, LABEL_EXIT_FULL_SCREEN } from "../js/constants.js"; import { resolvePageUrl } from "./helpers/syncTestUtils.js"; const CURRENT_FILE = fileURLToPath(import.meta.url); @@ -194,6 +195,62 @@ const GOOGLE_GSI_STUB_SCRIPT = [ "})();" ].join("\n"); +const FULLSCREEN_STUB_SCRIPT = [ + "(() => {", + " const counters = { enterCalls: 0, exitCalls: 0, lastMethod: \"\", requestOverride: false, exitOverride: false };", + " let fullscreenElement = null;", + " const update = (element, method) => {", + " fullscreenElement = element;", + " if (element) {", + " counters.enterCalls += 1;", + " } else {", + " counters.exitCalls += 1;", + " }", + " counters.lastMethod = method;", + " const eventName = method === \"webkit\" ? \"webkitfullscreenchange\" : \"fullscreenchange\";", + " document.dispatchEvent(new Event(eventName));", + " };", + " const defineElementGetter = (target, prop) => {", + " try {", + " Object.defineProperty(target, prop, { configurable: true, get: () => fullscreenElement });", + " return true;", + " } catch {", + " return false;", + " }", + " };", + " defineElementGetter(document, \"fullscreenElement\");", + " defineElementGetter(document, \"webkitFullscreenElement\");", + " defineElementGetter(Document.prototype, \"fullscreenElement\");", + " defineElementGetter(Document.prototype, \"webkitFullscreenElement\");", + " const elementProto = Element.prototype;", + " try {", + " Object.defineProperty(elementProto, \"requestFullscreen\", { configurable: true, value: undefined });", + " counters.requestOverride = true;", + " } catch {}", + " if (!counters.requestOverride) {", + " elementProto.requestFullscreen = async function requestFullscreen() {", + " update(this, \"standard\");", + " };", + " }", + " elementProto.webkitRequestFullscreen = async function webkitRequestFullscreen() {", + " update(this, \"webkit\");", + " };", + " try {", + " Object.defineProperty(document, \"exitFullscreen\", { configurable: true, value: undefined });", + " counters.exitOverride = true;", + " } catch {}", + " if (!counters.exitOverride) {", + " document.exitFullscreen = async function exitFullscreen() {", + " update(null, \"standard\");", + " };", + " }", + " document.webkitExitFullscreen = async function webkitExitFullscreen() {", + " update(null, \"webkit\");", + " };", + " window.__gravityFullscreenCounters = counters;", + "})();" +].join("\n"); + async function readFixture(filePath) { return fs.readFile(filePath, "utf-8"); } @@ -259,6 +316,170 @@ async function readAuthState(page) { }); } +/** + * @param {import("playwright").Page} page + * @param {string} landingUrl + * @returns {Promise} + */ +async function loginToApp(page, landingUrl) { + await page.goto(landingUrl, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("mpr-login-button", { timeout: WAIT_TIMEOUT_MS }); + await page.waitForSelector("[data-test=google-signin]", { timeout: WAIT_TIMEOUT_MS }); + const navigationPromise = page.waitForURL(/\/app\.html$/, { timeout: WAIT_TIMEOUT_MS }); + await page.click("[data-test=google-signin]"); + await navigationPromise; + await page.waitForSelector("[data-test=app-shell]", { timeout: WAIT_TIMEOUT_MS }); + await page.waitForSelector("mpr-user[data-mpr-user-status=\"authenticated\"]", { timeout: WAIT_TIMEOUT_MS }); + await page.waitForSelector("mpr-user [data-mpr-user=\"trigger\"]", { timeout: WAIT_TIMEOUT_MS }); +} + +/** + * @param {{ initScript?: string }} options + * @returns {Promise<{ browser: import("playwright").Browser, context: import("playwright").BrowserContext, page: import("playwright").Page, landingUrl: string, origin: string, state: { invalidMeRequest: boolean }, teardown: () => Promise }>} + */ +async function createPlaywrightHarness(options = {}) { + const landingUrl = await resolvePageUrl(LANDING_FILE_URL); + const origin = new URL(landingUrl).origin; + const runtimeConfigBody = buildRuntimeConfig(origin); + const configYamlBody = buildConfigYaml(origin); + + const [mprUiSource, mprUiConfigSource, mprUiCssSource] = await Promise.all([ + readFixture(MPR_UI_JS_PATH), + readFixture(MPR_UI_CONFIG_PATH), + readFixture(MPR_UI_CSS_PATH) + ]); + + const browser = await chromiumBrowser.launch(); + const context = await browser.newContext(); + if (options.initScript) { + await context.addInitScript(options.initScript); + } + const page = await context.newPage(); + + const registerRoute = async (urlPattern, handler) => { + await page.route(urlPattern, handler); + }; + const state = { invalidMeRequest: false }; + + await registerRoute(`${origin}/data/runtime.config.development.json`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: runtimeConfigBody + }).catch(() => {}); + }); + await registerRoute(`${origin}/config.yaml`, (route) => { + route.fulfill({ + status: 200, + contentType: "text/yaml", + body: configYamlBody + }).catch(() => {}); + }); + await registerRoute(MPR_UI_CONFIG_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: mprUiConfigSource + }).catch(() => {}); + }); + await registerRoute(MPR_UI_SCRIPT_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: mprUiSource + }).catch(() => {}); + }); + await registerRoute(MPR_UI_CSS_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "text/css", + body: mprUiCssSource + }).catch(() => {}); + }); + await registerRoute(TAUTH_SCRIPT_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: TAUTH_STUB_SCRIPT + }).catch(() => {}); + }); + await registerRoute(GOOGLE_GSI_URL, (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: GOOGLE_GSI_STUB_SCRIPT + }).catch(() => {}); + }); + await registerRoute(new RegExp(`^${escapeForRegExp(LOOPAWARE_URL)}(\\?.*)?$`, "u"), (route) => { + route.fulfill({ + status: 200, + contentType: "application/javascript", + body: "" + }).catch(() => {}); + }); + await registerRoute(`${origin}/me`, (route) => { + const headers = route.request().headers(); + const tenantHeader = headers["x-tauth-tenant"]; + if (!tenantHeader) { + state.invalidMeRequest = true; + } + const cookieHeader = headers["cookie"] ?? ""; + const authenticated = cookieHeader.includes("app_session="); + route.fulfill({ + status: authenticated ? 200 : 403, + contentType: "application/json", + body: authenticated ? JSON.stringify({ userId: TEST_USER_ID }) : JSON.stringify({ error: "unauthorized" }) + }).catch(() => {}); + }); + await registerRoute(`${origin}/notes`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: NOTES_RESPONSE + }).catch(() => {}); + }); + await registerRoute(`${origin}/notes/sync`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: SYNC_RESPONSE + }).catch(() => {}); + }); + await registerRoute(`${origin}/auth/nonce`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ nonce: "playwright-nonce" }) + }).catch(() => {}); + }); + await registerRoute(`${origin}/auth/google`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + user_id: TEST_USER_ID, + user_email: TEST_USER_EMAIL, + display: TEST_USER_DISPLAY, + avatar_url: TEST_USER_AVATAR_URL + }) + }).catch(() => {}); + }); + await registerRoute(`${origin}/auth/logout`, (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }) + }).catch(() => {}); + }); + + const teardown = async () => { + await context.close().catch(() => {}); + await browser.close().catch(() => {}); + }; + + return { browser, context, page, landingUrl, origin, state, teardown }; +} + let playwrightAvailable = true; let chromiumBrowser = null; try { @@ -275,160 +496,83 @@ if (!playwrightAvailable) { } else { test.describe("Landing login E2E (Playwright)", { timeout: TEST_TIMEOUT_MS }, () => { test("clicking login renders user menu without redirect loop", async () => { - const landingUrl = await resolvePageUrl(LANDING_FILE_URL); - const origin = new URL(landingUrl).origin; - const runtimeConfigBody = buildRuntimeConfig(origin); - const configYamlBody = buildConfigYaml(origin); - - const [mprUiSource, mprUiConfigSource, mprUiCssSource] = await Promise.all([ - readFixture(MPR_UI_JS_PATH), - readFixture(MPR_UI_CONFIG_PATH), - readFixture(MPR_UI_CSS_PATH) - ]); - - const browser = await chromiumBrowser.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); - - const registerRoute = async (urlPattern, handler) => { - await page.route(urlPattern, handler); - }; - let invalidMeRequest = false; - - await registerRoute(`${origin}/data/runtime.config.development.json`, (route) => { - route.fulfill({ - status: 200, - contentType: "application/json", - body: runtimeConfigBody - }).catch(() => {}); - }); - await registerRoute(`${origin}/config.yaml`, (route) => { - route.fulfill({ - status: 200, - contentType: "text/yaml", - body: configYamlBody - }).catch(() => {}); - }); - await registerRoute(MPR_UI_CONFIG_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: mprUiConfigSource - }).catch(() => {}); - }); - await registerRoute(MPR_UI_SCRIPT_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: mprUiSource - }).catch(() => {}); - }); - await registerRoute(MPR_UI_CSS_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "text/css", - body: mprUiCssSource - }).catch(() => {}); - }); - await registerRoute(TAUTH_SCRIPT_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: TAUTH_STUB_SCRIPT - }).catch(() => {}); - }); - await registerRoute(GOOGLE_GSI_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: GOOGLE_GSI_STUB_SCRIPT - }).catch(() => {}); - }); - await registerRoute(new RegExp(`^${escapeForRegExp(LOOPAWARE_URL)}(\\?.*)?$`, "u"), (route) => { - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: "" - }).catch(() => {}); - }); - await registerRoute(`${origin}/me`, (route) => { - const headers = route.request().headers(); - const tenantHeader = headers["x-tauth-tenant"]; - if (!tenantHeader) { - invalidMeRequest = true; - } - const cookieHeader = headers["cookie"] ?? ""; - const authenticated = cookieHeader.includes("app_session="); - route.fulfill({ - status: authenticated ? 200 : 403, - contentType: "application/json", - body: authenticated ? JSON.stringify({ userId: TEST_USER_ID }) : JSON.stringify({ error: "unauthorized" }) - }).catch(() => {}); - }); - await registerRoute(`${origin}/notes`, (route) => { - route.fulfill({ - status: 200, - contentType: "application/json", - body: NOTES_RESPONSE - }).catch(() => {}); - }); - await registerRoute(`${origin}/notes/sync`, (route) => { - route.fulfill({ - status: 200, - contentType: "application/json", - body: SYNC_RESPONSE - }).catch(() => {}); - }); - await registerRoute(`${origin}/auth/nonce`, (route) => { - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ nonce: "playwright-nonce" }) - }).catch(() => {}); - }); - await registerRoute(`${origin}/auth/google`, (route) => { - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - user_id: TEST_USER_ID, - user_email: TEST_USER_EMAIL, - display: TEST_USER_DISPLAY, - avatar_url: TEST_USER_AVATAR_URL - }) - }).catch(() => {}); - }); - await registerRoute(`${origin}/auth/logout`, (route) => { - route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ ok: true }) - }).catch(() => {}); - }); + const { page, landingUrl, state, teardown } = await createPlaywrightHarness(); + try { + await loginToApp(page, landingUrl); + await page.waitForTimeout(750); + assert.equal(state.invalidMeRequest, false, "Expected /me requests to include X-TAuth-Tenant header"); + assert.ok(page.url().includes("/app.html"), "Expected to remain on app.html after login"); + } catch (error) { + const debugState = await readAuthState(page).catch(() => null); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Playwright auth flow failed: ${errorMessage}; state=${JSON.stringify(debugState)}`, { cause: error }); + } finally { + await teardown(); + } + }); + test("fullscreen menu item replaces standalone button and toggles state", async () => { + const { page, landingUrl, teardown } = await createPlaywrightHarness({ initScript: FULLSCREEN_STUB_SCRIPT }); try { - await page.goto(landingUrl, { waitUntil: "domcontentloaded" }); - await page.waitForSelector("mpr-login-button", { timeout: WAIT_TIMEOUT_MS }); - await page.waitForSelector("[data-test=google-signin]", { timeout: WAIT_TIMEOUT_MS }); + await loginToApp(page, landingUrl); + const standaloneButton = await page.$("[data-test=\"fullscreen-toggle\"]"); + assert.equal(standaloneButton, null, "Standalone fullscreen button should be removed"); - const navigationPromise = page.waitForURL(/\/app\.html$/, { timeout: WAIT_TIMEOUT_MS }); - await page.click("[data-test=google-signin]"); - await navigationPromise; + await page.click("mpr-user [data-mpr-user=\"trigger\"]"); + await page.waitForSelector("mpr-user [data-mpr-user=\"menu-item\"][data-mpr-user-action=\"toggle-fullscreen\"]", { + timeout: WAIT_TIMEOUT_MS + }); + const enterLabel = await page.$eval( + "mpr-user [data-mpr-user=\"menu-item\"][data-mpr-user-action=\"toggle-fullscreen\"]", + (element) => element.textContent?.trim() ?? "" + ); + assert.equal(enterLabel, LABEL_ENTER_FULL_SCREEN); - await page.waitForSelector("[data-test=app-shell]", { timeout: WAIT_TIMEOUT_MS }); - await page.waitForSelector("mpr-user[data-mpr-user-status=\"authenticated\"]", { timeout: WAIT_TIMEOUT_MS }); - await page.waitForSelector("mpr-user [data-mpr-user=\"trigger\"]", { timeout: WAIT_TIMEOUT_MS }); + await page.click("mpr-user [data-mpr-user=\"menu-item\"][data-mpr-user-action=\"toggle-fullscreen\"]"); + await page.click("mpr-user [data-mpr-user=\"trigger\"]"); + await page.waitForFunction(({ selector, label }) => { + const element = document.querySelector(selector); + return element && element.textContent?.trim() === label; + }, { + selector: "mpr-user [data-mpr-user=\"menu-item\"][data-mpr-user-action=\"toggle-fullscreen\"]", + label: LABEL_EXIT_FULL_SCREEN + }); - await page.waitForTimeout(750); - assert.equal(invalidMeRequest, false, "Expected /me requests to include X-TAuth-Tenant header"); - assert.ok(page.url().includes("/app.html"), "Expected to remain on app.html after login"); + const countersAfterEnter = await page.evaluate(() => window.__gravityFullscreenCounters ?? null); + assert.ok(countersAfterEnter && countersAfterEnter.enterCalls >= 1, "Expected fullscreen enter to be invoked"); + if (countersAfterEnter && countersAfterEnter.requestOverride) { + assert.equal( + countersAfterEnter.lastMethod, + "webkit", + "Expected webkit fullscreen enter when standard API is unavailable" + ); + } + + await page.click("mpr-user [data-mpr-user=\"menu-item\"][data-mpr-user-action=\"toggle-fullscreen\"]"); + await page.click("mpr-user [data-mpr-user=\"trigger\"]"); + await page.waitForFunction(({ selector, label }) => { + const element = document.querySelector(selector); + return element && element.textContent?.trim() === label; + }, { + selector: "mpr-user [data-mpr-user=\"menu-item\"][data-mpr-user-action=\"toggle-fullscreen\"]", + label: LABEL_ENTER_FULL_SCREEN + }); + + const countersAfterExit = await page.evaluate(() => window.__gravityFullscreenCounters ?? null); + assert.ok(countersAfterExit && countersAfterExit.exitCalls >= 1, "Expected fullscreen exit to be invoked"); + if (countersAfterExit && countersAfterExit.exitOverride) { + assert.equal( + countersAfterExit.lastMethod, + "webkit", + "Expected webkit fullscreen exit when standard API is unavailable" + ); + } } catch (error) { const debugState = await readAuthState(page).catch(() => null); const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Playwright auth flow failed: ${errorMessage}; state=${JSON.stringify(debugState)}`, { cause: error }); + throw new Error(`Playwright fullscreen menu failed: ${errorMessage}; state=${JSON.stringify(debugState)}`, { cause: error }); } finally { - await context.close().catch(() => {}); - await browser.close().catch(() => {}); + await teardown(); } }); }); diff --git a/frontend/tests/ui.fullscreen.puppeteer.test.js b/frontend/tests/ui.fullscreen.puppeteer.test.js index eb1f214..783cd5f 100644 --- a/frontend/tests/ui.fullscreen.puppeteer.test.js +++ b/frontend/tests/ui.fullscreen.puppeteer.test.js @@ -13,7 +13,8 @@ import { attachBackendSessionCookie, resolvePageUrl, signInTestUser } from "./he const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.resolve(__dirname, ".."); const PAGE_URL = `file://${path.join(PROJECT_ROOT, "app.html")}`; -const FULLSCREEN_TOGGLE_SELECTOR = '[data-test="fullscreen-toggle"]'; +const USER_MENU_TRIGGER_SELECTOR = 'mpr-user [data-mpr-user="trigger"]'; +const FULLSCREEN_MENU_SELECTOR = 'mpr-user [data-mpr-user="menu-item"][data-mpr-user-action="toggle-fullscreen"]'; const TEST_USER_ID = "fullscreen-user"; test.describe("GN-204 header full-screen toggle", () => { @@ -73,39 +74,20 @@ test.describe("GN-204 header full-screen toggle", () => { const resolvedUrl = await resolvePageUrl(PAGE_URL); await page.goto(resolvedUrl, { waitUntil: "domcontentloaded" }); await signInTestUser(page, backend, TEST_USER_ID); - await page.waitForSelector(FULLSCREEN_TOGGLE_SELECTOR, { timeout: 3000 }); - await page.evaluate((selector) => { - const button = document.querySelector(selector); - if (!(button instanceof HTMLElement)) { - return; - } - button.hidden = false; - button.removeAttribute("hidden"); - let ancestor = button.parentElement; - while (ancestor instanceof HTMLElement) { - if (ancestor.hasAttribute("hidden")) { - ancestor.removeAttribute("hidden"); - } - if ("dataset" in ancestor && ancestor.dataset) { - ancestor.dataset.open = "true"; - } - ancestor = ancestor.parentElement; - } - }, FULLSCREEN_TOGGLE_SELECTOR); + await page.waitForSelector(USER_MENU_TRIGGER_SELECTOR, { timeout: 3000 }); + await page.click(USER_MENU_TRIGGER_SELECTOR); + await page.waitForSelector(FULLSCREEN_MENU_SELECTOR, { timeout: 3000 }); - const initialState = await page.$eval(FULLSCREEN_TOGGLE_SELECTOR, (button) => ({ - label: button.getAttribute("aria-label"), - state: button.getAttribute("data-fullscreen-state") - })); - assert.deepEqual(initialState, { label: LABEL_ENTER_FULL_SCREEN, state: "enter" }); + const initialLabel = await page.$eval(FULLSCREEN_MENU_SELECTOR, (button) => button.textContent?.trim() ?? ""); + assert.equal(initialLabel, LABEL_ENTER_FULL_SCREEN); - await page.click(FULLSCREEN_TOGGLE_SELECTOR); - await page.waitForSelector(`${FULLSCREEN_TOGGLE_SELECTOR}[data-fullscreen-state="exit"]`, { timeout: 3000 }); - const afterEnter = await page.$eval(FULLSCREEN_TOGGLE_SELECTOR, (button) => ({ - label: button.getAttribute("aria-label"), - state: button.getAttribute("data-fullscreen-state") - })); - assert.deepEqual(afterEnter, { label: LABEL_EXIT_FULL_SCREEN, state: "exit" }); + await page.click(FULLSCREEN_MENU_SELECTOR); + await page.waitForFunction((selector, label) => { + const element = document.querySelector(selector); + return element && element.textContent?.trim() === label; + }, {}, FULLSCREEN_MENU_SELECTOR, LABEL_EXIT_FULL_SCREEN); + const afterEnterLabel = await page.$eval(FULLSCREEN_MENU_SELECTOR, (button) => button.textContent?.trim() ?? ""); + assert.equal(afterEnterLabel, LABEL_EXIT_FULL_SCREEN); const countersAfterEnter = await page.evaluate(() => window.__fullscreenTestCounters); assert.equal( @@ -114,13 +96,15 @@ test.describe("GN-204 header full-screen toggle", () => { "requestFullscreen should be invoked once after entering full screen" ); - await page.click(FULLSCREEN_TOGGLE_SELECTOR); - await page.waitForSelector(`${FULLSCREEN_TOGGLE_SELECTOR}[data-fullscreen-state="enter"]`, { timeout: 3000 }); - const afterExit = await page.$eval(FULLSCREEN_TOGGLE_SELECTOR, (button) => ({ - label: button.getAttribute("aria-label"), - state: button.getAttribute("data-fullscreen-state") - })); - assert.deepEqual(afterExit, { label: LABEL_ENTER_FULL_SCREEN, state: "enter" }); + await page.click(USER_MENU_TRIGGER_SELECTOR); + await page.waitForSelector(FULLSCREEN_MENU_SELECTOR, { timeout: 3000 }); + await page.click(FULLSCREEN_MENU_SELECTOR); + await page.waitForFunction((selector, label) => { + const element = document.querySelector(selector); + return element && element.textContent?.trim() === label; + }, {}, FULLSCREEN_MENU_SELECTOR, LABEL_ENTER_FULL_SCREEN); + const afterExitLabel = await page.$eval(FULLSCREEN_MENU_SELECTOR, (button) => button.textContent?.trim() ?? ""); + assert.equal(afterExitLabel, LABEL_ENTER_FULL_SCREEN); const countersAfterExit = await page.evaluate(() => window.__fullscreenTestCounters); assert.equal( From c8d5c38984c1f4836b18f43677df126b0436212b Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 30 Jan 2026 12:50:35 -0800 Subject: [PATCH 15/18] Fix GN-450 CDN-only mpr-ui test harness --- ISSUES.md | 2 + frontend/app.html | 19 ++++++--- frontend/index.html | 19 ++++++--- .../tests/auth.landingLogin.puppeteer.test.js | 8 ++++ frontend/tests/auth.login.playwright.test.js | 39 ------------------- frontend/tests/helpers/browserHarness.js | 16 -------- frontend/tests/helpers/tauthHarness.js | 12 +++--- 7 files changed, 42 insertions(+), 73 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 23605b4..4febe9a 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -182,6 +182,8 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x (Resolved by adding the full screen action to the mpr-user menu items ahead of logout, wiring the menu action to the full screen toggle, and extending avatar menu coverage to assert ordering.) - [x] [GN-449] (P0) Enter full screen must be a user menu item before Sign out (regression reported again). (Resolved by removing the standalone fullscreen button, keeping the menu item before Sign out, and adding Playwright coverage for menu toggling + absence of the standalone control.) +- [x] [GN-450] (P1) CI fails because the Playwright login test reads mpr-ui assets from the gitignored tools folder. + (Resolved by removing local mpr-ui fixtures in the test harness, waiting for mpr-ui config application in landing tests, and propagating nonce error codes through the auth wrapper; make test/lint/ci pass.) ## Maintenance (428–499) diff --git a/frontend/app.html b/frontend/app.html index b6ff032..1f2a396 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -127,12 +127,19 @@ }, body: payload }).then(function(response) { - if (!response.ok) { - var err = new Error("credential exchange failed"); - err.status = response.status; - throw err; - } - return response.json(); + return response.json().catch(function() { + return null; + }).then(function(body) { + if (!response.ok) { + var errorCode = body && typeof body.error === "string" + ? body.error + : "credential exchange failed"; + var err = new Error(errorCode); + err.status = response.status; + throw err; + } + return body; + }); }).then(normalizeProfile); }; wrappedExchange = true; diff --git a/frontend/index.html b/frontend/index.html index 7e4efde..62c9afc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -127,12 +127,19 @@ }, body: payload }).then(function(response) { - if (!response.ok) { - var err = new Error("credential exchange failed"); - err.status = response.status; - throw err; - } - return response.json(); + return response.json().catch(function() { + return null; + }).then(function(body) { + if (!response.ok) { + var errorCode = body && typeof body.error === "string" + ? body.error + : "credential exchange failed"; + var err = new Error(errorCode); + err.status = response.status; + throw err; + } + return body; + }); }).then(normalizeProfile); }; wrappedExchange = true; diff --git a/frontend/tests/auth.landingLogin.puppeteer.test.js b/frontend/tests/auth.landingLogin.puppeteer.test.js index 033edc3..af33864 100644 --- a/frontend/tests/auth.landingLogin.puppeteer.test.js +++ b/frontend/tests/auth.landingLogin.puppeteer.test.js @@ -74,6 +74,14 @@ if (!puppeteerAvailable) { } return Boolean(registry.get("mpr-login-button")); }, { timeout: 10000 }); + await page.waitForFunction((selector, expectedUrl) => { + const element = document.querySelector(selector); + if (!element) { + return false; + } + const tauthUrl = element.getAttribute("tauth-url"); + return Boolean(tauthUrl && tauthUrl === expectedUrl); + }, { timeout: 10000 }, "[data-test=\"landing-login\"]", CUSTOM_AUTH_BASE_URL); const teardown = async () => { await page.close().catch(() => {}); diff --git a/frontend/tests/auth.login.playwright.test.js b/frontend/tests/auth.login.playwright.test.js index 1279472..c24461e 100644 --- a/frontend/tests/auth.login.playwright.test.js +++ b/frontend/tests/auth.login.playwright.test.js @@ -1,7 +1,6 @@ // @ts-check import assert from "node:assert/strict"; -import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -12,19 +11,12 @@ import { resolvePageUrl } from "./helpers/syncTestUtils.js"; const CURRENT_FILE = fileURLToPath(import.meta.url); const TESTS_ROOT = path.dirname(CURRENT_FILE); const PROJECT_ROOT = path.resolve(TESTS_ROOT, ".."); -const REPO_ROOT = path.resolve(PROJECT_ROOT, ".."); const LANDING_FILE_URL = `file://${path.join(PROJECT_ROOT, "index.html")}`; -const MPR_UI_JS_PATH = path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.js"); -const MPR_UI_CONFIG_PATH = path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui-config.js"); -const MPR_UI_CSS_PATH = path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.css"); - const TAUTH_SCRIPT_URL = "https://tauth.mprlab.com/tauth.js"; const GOOGLE_GSI_URL = "https://accounts.google.com/gsi/client"; const LOOPAWARE_URL = "https://loopaware.mprlab.com/widget.js"; const MPR_UI_SCRIPT_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui.js"; -const MPR_UI_CONFIG_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui-config.js"; -const MPR_UI_CSS_URL = "https://cdn.jsdelivr.net/gh/MarcoPoloResearchLab/mpr-ui@v3.6.2/mpr-ui.css"; const TEST_USER_ID = "playwright-user"; const TEST_USER_EMAIL = "playwright-user@example.com"; @@ -251,10 +243,6 @@ const FULLSCREEN_STUB_SCRIPT = [ "})();" ].join("\n"); -async function readFixture(filePath) { - return fs.readFile(filePath, "utf-8"); -} - /** * @param {string} value * @returns {string} @@ -343,12 +331,6 @@ async function createPlaywrightHarness(options = {}) { const runtimeConfigBody = buildRuntimeConfig(origin); const configYamlBody = buildConfigYaml(origin); - const [mprUiSource, mprUiConfigSource, mprUiCssSource] = await Promise.all([ - readFixture(MPR_UI_JS_PATH), - readFixture(MPR_UI_CONFIG_PATH), - readFixture(MPR_UI_CSS_PATH) - ]); - const browser = await chromiumBrowser.launch(); const context = await browser.newContext(); if (options.initScript) { @@ -375,27 +357,6 @@ async function createPlaywrightHarness(options = {}) { body: configYamlBody }).catch(() => {}); }); - await registerRoute(MPR_UI_CONFIG_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: mprUiConfigSource - }).catch(() => {}); - }); - await registerRoute(MPR_UI_SCRIPT_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "application/javascript", - body: mprUiSource - }).catch(() => {}); - }); - await registerRoute(MPR_UI_CSS_URL, (route) => { - route.fulfill({ - status: 200, - contentType: "text/css", - body: mprUiCssSource - }).catch(() => {}); - }); await registerRoute(TAUTH_SCRIPT_URL, (route) => { route.fulfill({ status: 200, diff --git a/frontend/tests/helpers/browserHarness.js b/frontend/tests/helpers/browserHarness.js index 34d57e2..4b7d87b 100644 --- a/frontend/tests/helpers/browserHarness.js +++ b/frontend/tests/helpers/browserHarness.js @@ -19,7 +19,6 @@ let sharedLaunchContext = null; const CURRENT_FILE = fileURLToPath(import.meta.url); const HELPERS_ROOT = path.dirname(CURRENT_FILE); const TESTS_ROOT = path.resolve(HELPERS_ROOT, ".."); -const REPO_ROOT = path.resolve(TESTS_ROOT, "..", ".."); const CDN_FIXTURES_ROOT = path.resolve(TESTS_ROOT, "fixtures", "cdn"); const CONFIG_ROUTE_PATTERN = /\/data\/runtime\.config\.(development|production)\.json$/u; const EMPTY_STRING = ""; @@ -140,21 +139,6 @@ const CDN_MIRRORS = Object.freeze([ pattern: /^https:\/\/cdn\.jsdelivr\.net\/npm\/easymde@2\.19\.0\/dist\/easymde\.min\.css$/u, filePath: path.join(CDN_FIXTURES_ROOT, "jsdelivr", "npm", "easymde@2.19.0", "dist", "easymde.min.css"), contentType: "text/css" - }, - { - pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@v3.6.2\/mpr-ui\.js$/u, - filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.js"), - contentType: "application/javascript" - }, - { - pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@v3.6.2\/mpr-ui-config\.js$/u, - filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui-config.js"), - contentType: "application/javascript" - }, - { - pattern: /^https:\/\/cdn\.jsdelivr\.net\/gh\/MarcoPoloResearchLab\/mpr-ui@v3.6.2\/mpr-ui\.css$/u, - filePath: path.join(REPO_ROOT, "tools", "mpr-ui", "mpr-ui.css"), - contentType: "text/css" } ]); const CDN_STUBS = Object.freeze([ diff --git a/frontend/tests/helpers/tauthHarness.js b/frontend/tests/helpers/tauthHarness.js index 7631f77..579e3a2 100644 --- a/frontend/tests/helpers/tauthHarness.js +++ b/frontend/tests/helpers/tauthHarness.js @@ -151,6 +151,12 @@ export async function installTAuthHarness(page, options) { // eslint-disable-next-line no-console console.log(`[tauth-harness] /auth/google: credential=${credential ? "present" : "missing"}, nonceToken=${nonceToken || "missing"}, pendingNonce=${state.pendingNonce || "missing"}`); } + if (state.behavior.failNextNonceExchange && credential && nonceToken) { + state.behavior.failNextNonceExchange = false; + respondJson(request, 400, { error: "nonce_mismatch" }, corsHeaders); + state.pendingNonce = null; + return true; + } if (!credential || !nonceToken || nonceToken !== state.pendingNonce) { if (process.env.DEBUG_TAUTH_HARNESS === "1") { // eslint-disable-next-line no-console @@ -160,12 +166,6 @@ export async function installTAuthHarness(page, options) { state.pendingNonce = null; return true; } - if (state.behavior.failNextNonceExchange) { - state.behavior.failNextNonceExchange = false; - respondJson(request, 400, { error: "nonce_mismatch" }, corsHeaders); - state.pendingNonce = null; - return true; - } state.pendingNonce = null; const profile = deriveProfileFromCredential(credential); state.profile = profile; From df527a53846736492dfc90871aaa268abbc9f89c Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 30 Jan 2026 13:27:26 -0800 Subject: [PATCH 16/18] Enable CI fail-fast and stabilize landing login test --- ISSUES.md | 2 + .../tests/auth.landingLogin.puppeteer.test.js | 38 ++++++++++++------- frontend/tests/run-tests.js | 18 +++++++++ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 4febe9a..67b87c5 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -184,6 +184,8 @@ Each issue is formatted as `- [ ] [GN-]`. When resolved it becomes -` [x (Resolved by removing the standalone fullscreen button, keeping the menu item before Sign out, and adding Playwright coverage for menu toggling + absence of the standalone control.) - [x] [GN-450] (P1) CI fails because the Playwright login test reads mpr-ui assets from the gitignored tools folder. (Resolved by removing local mpr-ui fixtures in the test harness, waiting for mpr-ui config application in landing tests, and propagating nonce error codes through the auth wrapper; make test/lint/ci pass.) +- [x] [GN-451] (P1) Fail fast on CI test runs after the first failure while keeping local runs exhaustive. + (Resolved by enabling CI-only fail-fast behavior in the test harness with immediate error output.) ## Maintenance (428–499) diff --git a/frontend/tests/auth.landingLogin.puppeteer.test.js b/frontend/tests/auth.landingLogin.puppeteer.test.js index af33864..a5eac7e 100644 --- a/frontend/tests/auth.landingLogin.puppeteer.test.js +++ b/frontend/tests/auth.landingLogin.puppeteer.test.js @@ -46,8 +46,13 @@ if (!puppeteerAvailable) { await installCdnMirrors(page); await attachImportAppModule(page); - // Clear the default test profile so landing page doesn't redirect to app.html + // Force sign-out so the landing page doesn't redirect to app.html await page.evaluateOnNewDocument(() => { + try { + window.sessionStorage?.setItem("__gravityTestForceSignOut", "true"); + } catch { + // Ignore storage errors + } window.__tauthStubProfile = null; }); await injectTAuthStub(page); @@ -74,13 +79,27 @@ if (!puppeteerAvailable) { } return Boolean(registry.get("mpr-login-button")); }, { timeout: 10000 }); - await page.waitForFunction((selector, expectedUrl) => { + const attributesHandle = await page.waitForFunction((selector, expectedUrl) => { const element = document.querySelector(selector); if (!element) { - return false; + return null; } const tauthUrl = element.getAttribute("tauth-url"); - return Boolean(tauthUrl && tauthUrl === expectedUrl); + const loginPath = element.getAttribute("tauth-login-path"); + const logoutPath = element.getAttribute("tauth-logout-path"); + const noncePath = element.getAttribute("tauth-nonce-path"); + if (!tauthUrl || !loginPath || !logoutPath || !noncePath) { + return null; + } + if (tauthUrl !== expectedUrl) { + return null; + } + return { + tauthUrl, + loginPath, + logoutPath, + noncePath + }; }, { timeout: 10000 }, "[data-test=\"landing-login\"]", CUSTOM_AUTH_BASE_URL); const teardown = async () => { @@ -91,16 +110,7 @@ if (!puppeteerAvailable) { }; try { - await page.waitForSelector("[data-test=\"landing-login\"]"); - const attributes = await page.$eval("[data-test=\"landing-login\"]", (element) => { - return { - tauthUrl: element.getAttribute("tauth-url"), - loginPath: element.getAttribute("tauth-login-path"), - logoutPath: element.getAttribute("tauth-logout-path"), - noncePath: element.getAttribute("tauth-nonce-path") - }; - }); - + const attributes = await attributesHandle.jsonValue(); assert.equal(attributes.tauthUrl, CUSTOM_AUTH_BASE_URL); assert.equal(attributes.loginPath, "/auth/google"); assert.equal(attributes.logoutPath, "/auth/logout"); diff --git a/frontend/tests/run-tests.js b/frontend/tests/run-tests.js index 80cd4eb..31f6ce0 100644 --- a/frontend/tests/run-tests.js +++ b/frontend/tests/run-tests.js @@ -434,6 +434,7 @@ async function main() { const runtimeOptions = await loadRuntimeOptions(); const isCiEnvironment = process.env.CI === "true"; + const failFast = typeof runtimeOptions.failFast === "boolean" ? runtimeOptions.failFast : isCiEnvironment; /** @type {{ policy?: string, allowlist?: string[] }} */ const mergedScreenshotConfig = {}; @@ -664,6 +665,7 @@ async function main() { let passCount = 0; let failCount = 0; let timeoutCount = 0; + let failFastTriggered = false; try { for (let iterationIndex = 0; iterationIndex < iterationCount; iterationIndex += 1) { @@ -799,6 +801,11 @@ async function main() { timeoutCount += 1; const timeoutMessage = `${cliColors.symbols.timeout} ${cliColors.yellow(`Timed out after ${formatDuration(effectiveTimeout)}`)}`; console.error(` ${timeoutMessage}`); + if (failFast && !failFastTriggered) { + failFastTriggered = true; + console.error(` ${cliColors.red("Fail-fast enabled; stopping after first failure.")}`); + break; + } continue; } if (result.exitCode !== 0) { @@ -818,6 +825,11 @@ async function main() { console.error(stdoutOutput); } } + if (failFast && !failFastTriggered) { + failFastTriggered = true; + console.error(` ${cliColors.red("Fail-fast enabled; stopping after first failure.")}`); + break; + } } else { passCount += 1; const durationLabel = cliColors.dim(`(${formatDuration(result.durationMs)})`); @@ -826,6 +838,12 @@ async function main() { } finally { await disposeRuntimeModule(); } + if (failFastTriggered) { + break; + } + } + if (failFastTriggered) { + break; } } finally { if (backendHandle) { From f8f67c0e345e7a6ab12e38e8ab23682be4507249 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 30 Jan 2026 13:35:30 -0800 Subject: [PATCH 17/18] Add fail-fast CLI switch for test harness --- frontend/tests/run-tests.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/tests/run-tests.js b/frontend/tests/run-tests.js index 31f6ce0..a4076fd 100644 --- a/frontend/tests/run-tests.js +++ b/frontend/tests/run-tests.js @@ -330,6 +330,7 @@ globalThis.__gravityRuntimeContext = Object.freeze(context); * iterations?: number, * seed?: string, * randomize?: boolean, + * failFast?: boolean, * stress?: boolean, * passthroughArgs: string[] * }} @@ -343,6 +344,7 @@ function parseCommandLineArguments(argv) { iterations: undefined, seed: undefined, randomize: undefined, + failFast: undefined, stress: undefined, passthroughArgs: [] }; @@ -396,6 +398,14 @@ function parseCommandLineArguments(argv) { parsed.randomize = false; continue; } + if (argument === "--fail-fast") { + parsed.failFast = true; + continue; + } + if (argument === "--no-fail-fast") { + parsed.failFast = false; + continue; + } if (argument === "--stress") { parsed.iterations = Math.max(parsed.iterations ?? 0, 10); parsed.stress = true; @@ -434,7 +444,11 @@ async function main() { const runtimeOptions = await loadRuntimeOptions(); const isCiEnvironment = process.env.CI === "true"; - const failFast = typeof runtimeOptions.failFast === "boolean" ? runtimeOptions.failFast : isCiEnvironment; + const failFast = typeof cliArguments.failFast === "boolean" + ? cliArguments.failFast + : typeof runtimeOptions.failFast === "boolean" + ? runtimeOptions.failFast + : false; /** @type {{ policy?: string, allowlist?: string[] }} */ const mergedScreenshotConfig = {}; From 92ec9b3f3e3ffaf98242e8e2ba2070a2b7a18913 Mon Sep 17 00:00:00 2001 From: Vadym Tyemirov Date: Fri, 30 Jan 2026 14:14:26 -0800 Subject: [PATCH 18/18] Add fail-fast flag to CI frontend tests --- .github/workflows/frontend-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 4bf8664..430cdf5 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -41,5 +41,5 @@ jobs: working-directory: frontend - name: Run tests - run: npm test + run: npm test -- --fail-fast working-directory: frontend