From f0a7c5ae27b2b427065bc61f70253ceb76b36a8d Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Tue, 20 Oct 2026 16:15:36 +1100 Subject: [PATCH 001/105] test(e2e): add Workplace App settings coverage (36 settings, 71 scenarios) Add permanent Playwright specs under apps/workplace/e2e/settings for 36 Workplace App settings (parking booking, parking requests, lockers, explore legend, directory and app shell), one setting per spec, 71 scenarios. Each scenario applies the setting through the existing browser override (PLACEOS.setting_overrides) and asserts its effect in two or more states. A shared read-only fixture blocks every non-GET API request, verifies the staff-only identity, intercepts WebSockets and attaches a per-test safety audit. Backend lookups that the app would otherwise answer with a create are served by GET-only browser fixtures. The local project now also discovers settings/**; the mock project ignores it. Workers and CI configuration are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../wpa-settings-readonly-legacy.fixture.ts | 63 +++ .../e2e/settings/chat/chat.enabled.spec.ts | 262 ++++++++++++ .../directory.min-search-length.spec.ts | 187 +++++++++ .../directory/directory.show-avatars.spec.ts | 179 ++++++++ .../settings/explore/explore.legend.spec.ts | 224 ++++++++++ .../explore/explore.show-legend.spec.ts | 224 ++++++++++ .../general/general.allow-dark-mode.spec.ts | 260 ++++++++++++ .../general/general.environment-bar.spec.ts | 231 +++++++++++ .../general/general.global-search.spec.ts | 224 ++++++++++ .../general/general.logo-dark.spec.ts | 264 ++++++++++++ .../general/general.logo-light.spec.ts | 229 +++++++++++ .../general/general.show-changelog.spec.ts | 242 +++++++++++ .../lockers/lockers.allow-all-day.spec.ts | 345 ++++++++++++++++ .../lockers.allow-time-changes.spec.ts | 337 +++++++++++++++ .../lockers.disabled-date-select.spec.ts | 337 +++++++++++++++ .../lockers.disabled-start-time.spec.ts | 340 ++++++++++++++++ .../lockers/lockers.hide-end-time.spec.ts | 335 +++++++++++++++ .../lockers/lockers.max-duration.spec.ts | 347 ++++++++++++++++ .../lockers.show-calendar-links.spec.ts | 360 ++++++++++++++++ .../lockers.use-building-timezone.spec.ts | 337 +++++++++++++++ .../parking-booking.allow-all-day.spec.ts | 385 ++++++++++++++++++ .../parking-booking.available-period.spec.ts | 375 +++++++++++++++++ .../parking-booking.bookable-hours.spec.ts | 382 +++++++++++++++++ ...arking-booking.can-book-for-others.spec.ts | 363 +++++++++++++++++ ...ng-booking.custom-duration-options.spec.ts | 373 +++++++++++++++++ .../parking-booking.max-duration.spec.ts | 364 +++++++++++++++++ .../parking-booking.min-duration.spec.ts | 367 +++++++++++++++++ ...king-booking.use-building-timezone.spec.ts | 367 +++++++++++++++++ .../parking-request.allow-recurrence.spec.ts | 267 ++++++++++++ ...-request.hide-availability-counter.spec.ts | 263 ++++++++++++ .../parking-request.hide-custom-shift.spec.ts | 271 ++++++++++++ ...king-request.request-shift-options.spec.ts | 274 +++++++++++++ ...request.request-space-restrictions.spec.ts | 267 ++++++++++++ .../parking-request.request-types.spec.ts | 277 +++++++++++++ ...rking-request.require-plate-number.spec.ts | 261 ++++++++++++ ...-request.require-space-restriction.spec.ts | 269 ++++++++++++ .../parking-request.vehicle-types.spec.ts | 269 ++++++++++++ apps/workplace/playwright.config.ts | 8 +- 38 files changed, 10727 insertions(+), 2 deletions(-) create mode 100644 apps/workplace/e2e/local/wpa-settings-readonly-legacy.fixture.ts create mode 100644 apps/workplace/e2e/settings/chat/chat.enabled.spec.ts create mode 100644 apps/workplace/e2e/settings/directory/directory.min-search-length.spec.ts create mode 100644 apps/workplace/e2e/settings/directory/directory.show-avatars.spec.ts create mode 100644 apps/workplace/e2e/settings/explore/explore.legend.spec.ts create mode 100644 apps/workplace/e2e/settings/explore/explore.show-legend.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.allow-dark-mode.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.environment-bar.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.global-search.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.logo-dark.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.logo-light.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.show-changelog.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.allow-all-day.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.allow-time-changes.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.disabled-date-select.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.disabled-start-time.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.hide-end-time.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.max-duration.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.show-calendar-links.spec.ts create mode 100644 apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.allow-all-day.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.available-period.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.bookable-hours.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.can-book-for-others.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.custom-duration-options.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.max-duration.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.min-duration.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.use-building-timezone.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.allow-recurrence.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.hide-availability-counter.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.hide-custom-shift.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.request-shift-options.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.request-space-restrictions.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.request-types.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.require-plate-number.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.require-space-restriction.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-request/parking-request.vehicle-types.spec.ts diff --git a/apps/workplace/e2e/local/wpa-settings-readonly-legacy.fixture.ts b/apps/workplace/e2e/local/wpa-settings-readonly-legacy.fixture.ts new file mode 100644 index 00000000000..d240dc294a3 --- /dev/null +++ b/apps/workplace/e2e/local/wpa-settings-readonly-legacy.fixture.ts @@ -0,0 +1,63 @@ +import { APP_URL, BACKEND_URL, staffEmail } from '../../../../e2e/support/env'; +import { expect, test } from '../../../../e2e/support/fixtures'; + +export const READ_ONLY_SETTINGS: Record = { + 'app.allow_dark_mode': false, + 'app.features': ['desks'], + 'app.desks.can_book_lockers': false, + 'app.desks.has_assets': false, + 'app.bookings.has_assets': false, + 'app.desks.use_assets': false, +}; + +// This fixture applies only to the explicitly selected read-only tests below. +// Keep the imported team test and all non-selected tests unchanged. +export const readOnlyTest = test.extend<{ readOnlyTimezone: string | undefined }>({ + readOnlyTimezone: [undefined, { option: true }], + storageState: async ({}, use) => use(undefined), + staffPage: async ({ browser, staffPage, readOnlyTimezone }, use, info) => { + const context = await browser.newContext({ + storageState: await staffPage.context().storageState(), + baseURL: APP_URL, + ignoreHTTPSErrors: true, + serviceWorkers: 'block', + timezoneId: readOnlyTimezone, + }); + const blocked: string[] = []; + type StaffIdentity = { email?: string; sys_admin?: boolean; support?: boolean }; + const identities: Promise[] = []; + try { + // Register before creating the page; never allow an API mutation. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) || + ![new URL(APP_URL).origin, new URL(BACKEND_URL).origin].includes(url.origin)) { + blocked.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + await context.close(); + return; + } + await route.continue(); + }); + const page = await context.newPage(); + // Observe the app's own identity GET; do not issue another request. + page.on('response', (response) => { + if (response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') { + identities.push(response.json().then((user: StaffIdentity) => user).catch(() => null)); + } + }); + await use(page); + } finally { + await context.close(); + expect(blocked, 'read-only scenario must perform zero API mutations').toEqual([]); + const observed = await Promise.all(identities); + expect(observed.length, 'the app must establish staff identity').toBeGreaterThan(0); + for (const user of observed) { + expect(user?.email?.toLowerCase()).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(user?.sys_admin || user?.support, 'staff-only scenario').toBeFalsy(); + } + } + }, +}); diff --git a/apps/workplace/e2e/settings/chat/chat.enabled.spec.ts b/apps/workplace/e2e/settings/chat/chat.enabled.spec.ts new file mode 100644 index 00000000000..50131d81dc0 --- /dev/null +++ b/apps/workplace/e2e/settings/chat/chat.enabled.spec.ts @@ -0,0 +1,262 @@ +import type { ConsoleMessage, Page, Route } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. SettingsService._applyTheme and topbar.logo select +// light/dark branding. app.component's @defer and ChatComponent.can_show gate +// the chat launcher. No theme control or chat control is clicked. +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const image = (colour: string) => `data:image/svg+xml,${encodeURIComponent( + ``, +)}`; +const LIGHT = image('white'); +const DARK_A = image('navy'); +const DARK_B = image('purple'); + +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + initialization: { url: string; parent_ids: string[]; fixture: string }[]; +}; + +/** Query values (tokens in ts-client's URL-authentication mode) never reach artifacts. */ +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** + * The Angular dev server's Vite client socket, and nothing else. + * + * @angular/build:dev-server keeps liveReload on by default, so the injected + * Vite client opens `ws(s):///?token=` (subprotocol vite-hmr) on + * every load (vite/dist/client/client.mjs:856-865). Closing it makes the client + * poll and then location.reload() (:993-998), so it is held open as a mock: it + * is never connected to the dev server and never closed. PlaceOS sockets use + * /api/engine/v2/... or /control/... paths and can never match. + */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +/** + * Exceptions Angular catches never reach `pageerror`: the app's + * LazySentryErrorHandler falls back to ErrorHandler, which logs + * console.error('ERROR', error). Angular runtime errors log as NG0xxx. + */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +// No PlaceOS WebSocket URL is exempted. In particular, the shared +// systems/control endpoint can carry BOTH the optional LLM hint bind and driver +// executions; allowing that URL would not distinguish a safe bind from an +// unsafe command. +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { + websocket_attempts: [], tooling_websockets: [], page_errors: [], app_errors: [], + monitoring_errors: [], initialization: [], + }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => { + audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack }); + }); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + const url = new URL(socket.url()); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: redact(url), + classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control (including LLM hint)' : 'other'; + audit.websocket_attempts.push({ url: redact(url), classification: `UNEXPECTED ${kind}; branding uses HTTP-only initialization` }); + // No connectToServer: the page side is closed before any server connection. + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + await use(audit); + } finally { + // Runs after the test body whether it passed or failed. Listeners + // and interception stay active until the context is closed. Parent + // readOnlyTest still runs its unchanged mutation/identity + // assertions after this auto fixture's teardown. + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('batch-1-branding-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + // One hard assertion, so every category is reported together. + expect({ + websocket_attempts: audit.websocket_attempts, + page_errors: audit.page_errors, + app_errors: audit.app_errors, + monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +async function isolateChatHint(page: Page, audit: SafetyAudit) { + // ChatService._chat_system reads OrganisationService.binding('chat_room'), + // then _bindHint calls getModule(id, 'LLM').variable('user_hint').bind(). + // The launcher (ChatComponent.can_show) reads only chat.enabled and does + // not need that optional hint. Null ONLY this binding in browser GET + // responses for organization/building initialization; preserve all other + // metadata. No backend setting/binding is saved. + // OrganisationService._bulkMetadataDetails uses bindings/bulk?parent_ids; + // _individualMetadata is its source-supported 404 fallback. + await page.addInitScript(() => { + // Cached copies (copied in with the staff storage state) would skip the request. + for (const key of Object.keys(localStorage)) { + if (key.startsWith('PLACEOS.org.metadata.') && key.includes('.bindings.')) localStorage.removeItem(key); + } + }); + const rewrite = (item: { details?: unknown }) => { + const details = typeof item.details === 'string' ? JSON.parse(item.details) : item.details ?? {}; + if (typeof details !== 'object' || details === null || Array.isArray(details)) { + throw new Error('bindings details must be a map'); + } + return { ...item, details: { ...details, chat_room: null } }; + }; + const handle = async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const url = new URL(route.request().url()); + try { + const bulk = url.pathname === '/api/engine/v2/metadata/bindings/bulk'; + const parent_ids = bulk ? url.searchParams.get('parent_ids')!.split(',') : + [decodeURIComponent(url.pathname.split('/')[5])]; + if (!parent_ids.every(Boolean)) throw new Error('binding fixture must identify its requested parents'); + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + // Preserve 404 so the client's existing individual-metadata fallback + // still runs. _individualMetadata maps a missing record to null; that + // is source-supported absence of a hint, not an application exception. + if (response.status() === 404) { + if (!bulk) audit.initialization.push({ url: redact(url), parent_ids, + fixture: 'HTTP GET only: missing bindings metadata (404), no optional chat_room hint' }); + return await route.fulfill({ response }); + } + if (!response.ok()) throw new Error(`bindings initialization GET returned ${response.status()}`); + const body = await response.json(); + if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new Error('bindings response must be a map'); + // showMetadata(id, name) reads data[name] from GET /metadata/id?name=...; + // bulkMetadata instead reads a map of parent IDs to metadata records. + // Only records the server returned are rewritten; none are invented. + const json = { ...body }; + if (bulk) { + for (const id of parent_ids) if (body[id]) json[id] = rewrite(body[id]); + } else if (body.bindings) json.bindings = rewrite(body.bindings); + audit.initialization.push({ url: redact(url), parent_ids, fixture: 'HTTP GET only: optional chat_room hint binding set to null in browser response' }); + await route.fulfill({ json }); + } catch (error) { + // Never hide a fixture problem: record it (fails in teardown) and + // let the unmodified GET continue through the read-only guard. + audit.monitoring_errors.push(`chat hint fixture: ${String(error)} (${redact(url)})`); + await route.fallback().catch(() => undefined); + } + }; + await page.route((url) => ORIGINS.has(url.origin) && + ((url.pathname === '/api/engine/v2/metadata/bindings/bulk' && + [...url.searchParams.keys()].length === 1 && !!url.searchParams.get('parent_ids')) || + (/^\/api\/engine\/v2\/metadata\/[^/]+$/.test(url.pathname) && + [...url.searchParams.keys()].length === 1 && url.searchParams.get('name') === 'bindings')), handle); +} + +async function openBranding(page: Page, overrides: Record, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1000 }); + if (overrides['app.chat.enabled'] === true) await isolateChatHint(page, audit); + let identity: Promise<{ id: string }> | undefined; + page.on('response', (response) => { + if (response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') identity = response.json(); + }); + let theme_reads = 0; + await page.route((url) => ORIGINS.has(url.origin) && + /^\/api\/engine\/v2\/metadata\/[^/]+$/.test(url.pathname) && + [...url.searchParams.keys()].length === 1 && url.searchParams.get('name') === 'settings', async (route) => { + if (route.request().method() !== 'GET' || !identity) return route.fallback(); + try { + const user = await identity; + const url = new URL(route.request().url()); + if (decodeURIComponent(url.pathname.split('/')[5]) !== user.id) return await route.fallback(); + theme_reads++; + // SettingsService.init: showMetadata(user.id, 'settings') -> + // GET /metadata/?name=settings, read as data['settings']. + // A saved theme makes _initDarkMode return before saveUserSetting. + // This is a browser response only; no user setting is persisted. + await route.fulfill({ json: { settings: { name: 'settings', details: { theme: 'dark' } } } }); + } catch (error) { + audit.monitoring_errors.push(`user settings fixture: ${String(error)}`); + await route.fallback().catch(() => undefined); + } + }); + const settings = { ...READ_ONLY_SETTINGS, 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.logo_light': LIGHT, 'app.logo_dark': DARK_A, ...overrides }; + await useSettings(page, settings); + await page.goto('/#/directory'); + await expect(page.locator('[a-directory-user-list]')).toBeVisible(); + // GlobalLoading renders [loader] until initialisationComplete(), and keeps + // it (with [initialisation-error]) when initialization fails internally. + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + await expect.poll(() => theme_reads, { message: 'the saved-theme user settings fixture must be used' }).toBeGreaterThan(0); + if (overrides['app.chat.enabled'] === true) { + await expect.poll(() => audit.initialization.length, { message: 'chat launcher must use its HTTP-only hint initialization fixture' }).toBeGreaterThan(0); + // OrganisationService exposes app.org in debug mode. Read its PUBLIC + // state only, after all org/building metadata has finished loading. + await expect.poll(() => page.evaluate(() => { + const org = (window as Window & { app?: { org?: { + initialised(): boolean; binding(name: string): unknown; + } } }).app?.org; + return { initialised: org?.initialised() === true, hint: org?.binding('chat_room') ?? null }; + })).toEqual({ initialised: true, hint: null }); + } + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => + (window as Window & { setting?: (key: string) => unknown }).setting?.(key), key)).toEqual(value); + } + return page.locator('topbar a[name="nav-logo"] img'); +} + +for (const enabled of [false, true]) { + test(`WPA draft: chat.enabled ${enabled} gates the chat launcher`, async ({ staffPage, safetyAudit }) => { + await openBranding(staffPage, { 'app.chat.enabled': enabled }, safetyAudit); + const launcher = staffPage.locator('global-chat button').filter({ + has: staffPage.locator('icon', { hasText: /^\s*chat\s*$/ }), + }); + if (enabled) await expect(launcher).toBeVisible(); + else await expect(launcher).toHaveCount(0); + await expect(staffPage.locator('global-chat textarea')).toHaveCount(0); + // No chat is opened; any PlaceOS WebSocket is closed before a server connection. + }); +} diff --git a/apps/workplace/e2e/settings/directory/directory.min-search-length.spec.ts b/apps/workplace/e2e/settings/directory/directory.min-search-length.spec.ts new file mode 100644 index 00000000000..e9d15c3af1a --- /dev/null +++ b/apps/workplace/e2e/settings/directory/directory.min-search-length.spec.ts @@ -0,0 +1,187 @@ +import type { ConsoleMessage, Page } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. Consumers: directory/user-list.component.ts: +// min_search_length gates its 400ms search (`query.length < min` -> no +// search); show_avatars (show_image) gates a-user-avatar. +// searchStaff (users/staff.fn.ts) GETs people?q=...&fields=... and maps a raw array. +const QUERY = 'DraftDirectory'; +const PERSON = { id: 'e2e-draft-directory-person', name: 'Draft Directory Person', email: 'draft-directory@example.invalid' }; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; +}; + +/** Query values (tokens in ts-client's URL-authentication mode) never reach artifacts. */ +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** + * The Angular dev server's Vite client socket, and nothing else. + * + * @angular/build:dev-server keeps liveReload on by default, so the injected + * Vite client opens `ws(s):///?token=` (subprotocol vite-hmr) on + * every load (vite/dist/client/client.mjs:856-865). Closing it makes the client + * poll and then location.reload() (:993-998), so it is held open as a mock: it + * is never connected to the dev server and never closed. PlaceOS sockets use + * /api/engine/v2/... or /control/... paths and can never match. + */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +/** + * Exceptions Angular catches never reach `pageerror`: the app's + * LazySentryErrorHandler falls back to ErrorHandler, which logs + * console.error('ERROR', error). Angular runtime errors log as NG0xxx. + */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +// DirectoryUserList, Topbar and FooterMenu require HTTP only. Chat is disabled +// and user controls are never opened, so there are NO expected PlaceOS +// WebSockets. Keep this fixture local: the parent readOnlyTest retains its API +// mutation guard, staff-identity checks and serviceWorkers: 'block' context. +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { + websocket_attempts: [], tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], + }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => { + audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack }); + }); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + try { + // Context-wide interception also covers any newly opened page. + // Never call connectToServer: even unexpected sockets stay local. + await context.routeWebSocket('**/*', (socket) => { + const url = new URL(socket.url()); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: redact(url), + classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control' : 'other'; + audit.websocket_attempts.push({ url: redact(url), classification: `UNEXPECTED ${kind}; directory initialization is HTTP-only` }); + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + await use(audit); + } finally { + // Runs after the test body whether it passed or failed. Close with + // listeners still installed, then inspect all observations. The + // parent's context.close() is idempotent; its guard assertions + // still run afterwards, including when the test body has failed. + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('batch-1-directory-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + // One hard assertion, so every category is reported together. + expect({ + websocket_attempts: audit.websocket_attempts, + page_errors: audit.page_errors, + app_errors: audit.app_errors, + monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +async function openDirectory(page: Page, overrides: Record, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1000 }); + const requests: string[] = []; + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/staff/v1/people' && + !!url.searchParams.get('q') && (QUERY.startsWith(url.searchParams.get('q')!) || + url.searchParams.get('q')!.startsWith(QUERY)), async (route) => { + // Non-GET methods continue to the read-only guard, which blocks them. + if (route.request().method() !== 'GET') return route.fallback(); + const url = new URL(route.request().url()); + // Recorded before any check, so an unexpected lookup is never hidden. + requests.push(url.searchParams.get('q')!); + if (url.searchParams.get('fields') !== 'id,name,email,username,organisation,department') { + audit.monitoring_errors.push(`people fixture: unexpected fields ${url.searchParams.get('fields')}`); + return route.fallback(); + } + await route.fulfill({ json: [PERSON] }); + }); + const settings = { ...READ_ONLY_SETTINGS, 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.users.min_search_length': 3, 'app.users.show_avatars': false, ...overrides }; + await useSettings(page, settings); + await page.goto('/#/directory'); + const root = page.locator('[a-directory-user-list]'); + await expect(root).toBeVisible(); + // GlobalLoading uses [loader] until initialisationComplete(), including + // initialization failures caught internally rather than raised as pageerror. + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + const input = root.locator('input[placeholder="Search for a person..."]'); + await expect(input).toBeVisible(); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => + (window as Window & { setting?: (key: string) => unknown }).setting?.(key), key)).toEqual(value); + } + return { root, input, requests }; +} + +for (const minimum of [QUERY.length, QUERY.length + 1]) { + test(`WPA draft: users.min_search_length ${minimum} gates directory lookup at its boundary`, async ({ staffPage, safetyAudit }) => { + await staffPage.clock.install(); + const { root, input, requests } = await openDirectory(staffPage, { 'app.users.min_search_length': minimum }, safetyAudit); + await staffPage.clock.pauseAt(new Date(await staffPage.evaluate(() => Date.now()) + 1000)); + const short = QUERY.slice(0, minimum - 1); + await input.fill(short); + // Advance past the 400ms debounce so the below-minimum decision has + // actually run; absence is then not a transient check. + await staffPage.clock.runFor(500); + await expect(root.locator('div[user]')).toHaveCount(0); + await expect(root).toContainText('Type above to search for users'); + expect(requests).toEqual([]); + // Zoneless change detection is timer-scheduled, so a paused clock would + // freeze rendering once the lookup resolves. Resume real time first. + await staffPage.clock.resume(); + const full = QUERY.padEnd(minimum, 'X'); + await input.fill(full); + await expect(root.locator('div[user]')).toHaveCount(1); + await expect(root.locator('div[user] .name')).toHaveText(PERSON.name); + // Exactly one lookup, for the at-minimum query: any below-minimum + // lookup would have been recorded first. + expect(requests).toEqual([full]); + }); +} + diff --git a/apps/workplace/e2e/settings/directory/directory.show-avatars.spec.ts b/apps/workplace/e2e/settings/directory/directory.show-avatars.spec.ts new file mode 100644 index 00000000000..afce6116d65 --- /dev/null +++ b/apps/workplace/e2e/settings/directory/directory.show-avatars.spec.ts @@ -0,0 +1,179 @@ +import type { ConsoleMessage, Page } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. Consumers: directory/user-list.component.ts: +// min_search_length gates its 400ms search (`query.length < min` -> no +// search); show_avatars (show_image) gates a-user-avatar. +// searchStaff (users/staff.fn.ts) GETs people?q=...&fields=... and maps a raw array. +const QUERY = 'DraftDirectory'; +const PERSON = { id: 'e2e-draft-directory-person', name: 'Draft Directory Person', email: 'draft-directory@example.invalid' }; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; +}; + +/** Query values (tokens in ts-client's URL-authentication mode) never reach artifacts. */ +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** + * The Angular dev server's Vite client socket, and nothing else. + * + * @angular/build:dev-server keeps liveReload on by default, so the injected + * Vite client opens `ws(s):///?token=` (subprotocol vite-hmr) on + * every load (vite/dist/client/client.mjs:856-865). Closing it makes the client + * poll and then location.reload() (:993-998), so it is held open as a mock: it + * is never connected to the dev server and never closed. PlaceOS sockets use + * /api/engine/v2/... or /control/... paths and can never match. + */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +/** + * Exceptions Angular catches never reach `pageerror`: the app's + * LazySentryErrorHandler falls back to ErrorHandler, which logs + * console.error('ERROR', error). Angular runtime errors log as NG0xxx. + */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +// DirectoryUserList, Topbar and FooterMenu require HTTP only. Chat is disabled +// and user controls are never opened, so there are NO expected PlaceOS +// WebSockets. Keep this fixture local: the parent readOnlyTest retains its API +// mutation guard, staff-identity checks and serviceWorkers: 'block' context. +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { + websocket_attempts: [], tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], + }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => { + audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack }); + }); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + try { + // Context-wide interception also covers any newly opened page. + // Never call connectToServer: even unexpected sockets stay local. + await context.routeWebSocket('**/*', (socket) => { + const url = new URL(socket.url()); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: redact(url), + classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control' : 'other'; + audit.websocket_attempts.push({ url: redact(url), classification: `UNEXPECTED ${kind}; directory initialization is HTTP-only` }); + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + await use(audit); + } finally { + // Runs after the test body whether it passed or failed. Close with + // listeners still installed, then inspect all observations. The + // parent's context.close() is idempotent; its guard assertions + // still run afterwards, including when the test body has failed. + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('batch-1-directory-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + // One hard assertion, so every category is reported together. + expect({ + websocket_attempts: audit.websocket_attempts, + page_errors: audit.page_errors, + app_errors: audit.app_errors, + monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +async function openDirectory(page: Page, overrides: Record, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1000 }); + const requests: string[] = []; + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/staff/v1/people' && + !!url.searchParams.get('q') && (QUERY.startsWith(url.searchParams.get('q')!) || + url.searchParams.get('q')!.startsWith(QUERY)), async (route) => { + // Non-GET methods continue to the read-only guard, which blocks them. + if (route.request().method() !== 'GET') return route.fallback(); + const url = new URL(route.request().url()); + // Recorded before any check, so an unexpected lookup is never hidden. + requests.push(url.searchParams.get('q')!); + if (url.searchParams.get('fields') !== 'id,name,email,username,organisation,department') { + audit.monitoring_errors.push(`people fixture: unexpected fields ${url.searchParams.get('fields')}`); + return route.fallback(); + } + await route.fulfill({ json: [PERSON] }); + }); + const settings = { ...READ_ONLY_SETTINGS, 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.users.min_search_length': 3, 'app.users.show_avatars': false, ...overrides }; + await useSettings(page, settings); + await page.goto('/#/directory'); + const root = page.locator('[a-directory-user-list]'); + await expect(root).toBeVisible(); + // GlobalLoading uses [loader] until initialisationComplete(), including + // initialization failures caught internally rather than raised as pageerror. + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + const input = root.locator('input[placeholder="Search for a person..."]'); + await expect(input).toBeVisible(); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => + (window as Window & { setting?: (key: string) => unknown }).setting?.(key), key)).toEqual(value); + } + return { root, input, requests }; +} + +for (const enabled of [false, true]) { + test(`WPA draft: users.show_avatars ${enabled} changes directory results without losing identity`, async ({ staffPage, safetyAudit }) => { + const { root, input } = await openDirectory(staffPage, { 'app.users.show_avatars': enabled }, safetyAudit); + await input.fill(QUERY); + const row = root.locator('div[user]'); + await expect(row).toHaveCount(1); + await expect(row.locator('.name')).toHaveText(PERSON.name); + await expect(row.locator('a[name="email"]')).toHaveAttribute('href', `mailto:${PERSON.email}`); + await expect(row.locator('a-user-avatar')).toHaveCount(enabled ? 1 : 0); + if (enabled) { + await expect(row).toHaveClass(/\bwith-image\b/); + // sets no host display style; its inner circle + // (attr user-id) has a real box and names this person. + await expect(row.locator(`a-user-avatar [user-id="${PERSON.id}"]`)).toBeVisible(); + } else await expect(row).not.toHaveClass(/\bwith-image\b/); + // No mailto, Locate or Call link is opened. + }); +} diff --git a/apps/workplace/e2e/settings/explore/explore.legend.spec.ts b/apps/workplace/e2e/settings/explore/explore.legend.spec.ts new file mode 100644 index 00000000000..c234fd16cca --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.legend.spec.ts @@ -0,0 +1,224 @@ +import type { ConsoleMessage, Page, Route, WebSocketRoute } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/room/room.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. explore.show_legend and explore.legend +// (libs/explore/src/lib/explore-map-view.component.ts:106-144, 237, 249-252): +// `@if (show_legend() && legend().length)` renders a [legend] panel; more than +// three [label, colour] pairs give a collapsed toggle button, three or fewer an +//

. It depends on no map, level or WebSocket data. Nothing is clicked except +// the legend's own collapse toggle (UI state only). +// +// Opening /#/explore otherwise has side effects that are answered in the browser: +// - ExploreSpacesService binds every room the level lists (systems?zone_id&limit=50) +// -> that list is served empty, so no room binding is attempted. +// - ExploreLockersService and ParkingService (via ExploreParkingService) resolve +// hidden asset categories/types and POST/PUT when a GET finds none or a type +// sits in another category (locker-assets.fn.ts, parking-assets.fn.ts). +// Every module shares GET asset_categories?hidden=true&limit=500, so one +// response carries all hidden categories, and each types GET returns +// matching types with the right category_id. Their asset lists are empty. +// - EventFormService.newForm reads the calendar provider -> served empty. +// - Topbar global search is switched off (app.global_search false). +type Settings = Record; +type DebugWindow = Window & { setting?: (key: string) => unknown }; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const CATEGORIES = [ + { id: 'e2e-draft-cat-lockers', name: '_LOCKERS_', hidden: true }, + { id: 'e2e-draft-cat-parking', name: '_PARKING_', hidden: true }, + { id: 'e2e-draft-cat-desks', name: '_DESKS_', hidden: true }, + { id: 'e2e-draft-cat-catering', name: '_CATERING_', hidden: true }, +]; +const TYPES: Record = { + 'e2e-draft-cat-lockers': [ + { id: 'e2e-draft-type-locker-banks', name: '_LOCKER_BANKS_' }, + { id: 'e2e-draft-type-lockers', name: '_LOCKERS_' }, + ], + 'e2e-draft-cat-parking': [ + { id: 'e2e-draft-type-parking-spaces', name: '_PARKING_SPACES_' }, + { id: 'e2e-draft-type-parking-users', name: '_PARKING_USERS_' }, + { id: 'e2e-draft-type-parking-fleet', name: '_PARKING_FLEET_VEHICLES_' }, + ], + 'e2e-draft-cat-desks': [{ id: 'e2e-draft-type-desks', name: '_DESKS_' }], + // No CATERING: type, so no menu. + 'e2e-draft-cat-catering': [], +}; +const TYPE_IDS = Object.values(TYPES).flat().map((type) => type.id); +const BASE: Settings = { + ...READ_ONLY_SETTINGS, + 'app.features': ['explore'], 'app.chat.enabled': false, 'app.global_search': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.use_region': false, 'app.desks.use_assets': false, +}; + +// ---------------------------------------------------------------- safety audit +// Same policy as the corrected Batch 2/4/5 drafts. Any binding the page might +// still attempt travels over the shell's control socket, which is intercepted +// and closed before it connects; any other socket fails the test. +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; + +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** Only the Angular dev server's Vite client socket (vite client.mjs:856-865, reload on close :993-998). */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +function isLocalControlSocket(url: URL) { + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.protocol === protocol && url.host === app.host && + url.pathname === '/api/engine/v2/systems/control'; +} + +/** ErrorHandler logs console.error('ERROR', e); Angular runtime errors log NG0xxx. */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], page_errors: [], + app_errors: [], monitoring_errors: [], fixtures: {} }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + const closeSocket = async (socket: WebSocketRoute, url: string) => { + try { + await socket.close(); + } catch (error) { + audit.monitoring_errors.push(`WebSocket close failed for ${url}: ${String(error)}`); + } + }; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = redact(url); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: socket_url, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (isLocalControlSocket(url)) { + audit.blocked_control_attempts.push({ + url: socket_url, + classification: 'known Workplace shell initialization; intercepted and closed before server connection', + }); + } else { + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control' : 'other'; + audit.websocket_attempts.push({ url: socket_url, classification: `UNEXPECTED ${kind}; legend scenario expects no other socket` }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed for ${socket_url}: ${String(error)}`); + } + closing.push(closeSocket(socket, socket_url)); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('explore-legend-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +/** A named, counted, GET-only browser response. Every other method reaches the read-only guard. */ +async function stubGet(page: Page, audit: SafetyAudit, name: string, + match: (url: URL) => boolean, body: (url: URL) => unknown) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json: body(new URL(route.request().url())) }); + }); +} + +async function openExplore(page: Page, overrides: Settings, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1000 }); + await stubGet(page, audit, 'hidden asset categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await stubGet(page, audit, 'hidden asset types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && (url.searchParams.get('category_id') ?? '') in TYPES, + (url) => { + const category_id = url.searchParams.get('category_id')!; + // category_id must match, or move_type_to_category would PUT. + return TYPES[category_id].map((type) => ({ ...type, brand: 'PlaceOS', category_id })); + }); + await stubGet(page, audit, 'hidden asset lists', (url) => url.pathname === '/api/engine/v2/assets' && + TYPE_IDS.includes(url.searchParams.get('type_id') ?? ''), () => []); + // ExploreStateService: querySystems({ zone_id, limit: 50 }) -> no rooms, so no room bindings. + await stubGet(page, audit, 'explore level rooms', (url) => url.pathname === '/api/engine/v2/systems' && + [...url.searchParams.keys()].sort().join(',') === 'limit,zone_id' && url.searchParams.get('limit') === '50', () => []); + await stubGet(page, audit, 'calendar provider list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + const settings = { ...BASE, ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-US')); + await page.goto('/#/explore'); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('explore-map-view')).toHaveCount(1); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => (window as DebugWindow).setting?.(key), key)).toEqual(value); + } + await expect.poll(() => audit.fixtures['hidden asset categories'] ?? 0, + { message: 'hidden-category bootstraps must read the browser fixture instead of creating categories' }).toBeGreaterThan(0); + return page.locator('explore-map-view [legend]'); +} + +const TWO: [string, string][] = [['E2E Draft Free', '#123456'], ['E2E Draft Busy', '#654321']]; +const FOUR: [string, string][] = [...TWO, ['E2E Draft Pending', '#112233'], ['E2E Draft Closed', '#445566']]; + +test('WPA draft: explore.legend of four pairs collapses behind a toggle', async ({ staffPage, safetyAudit }) => { + const legend = await openExplore(staffPage, { 'app.explore.show_legend': true, 'app.explore.legend': FOUR }, safetyAudit); + const toggle = legend.locator('button[aria-controls="explore-map-legend-items"]'); + const items = legend.locator('#explore-map-legend-items'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(items).toHaveClass(/\bhidden\b/); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(items).toBeVisible(); + await expect(items.locator(':scope > div div.text-sm')).toHaveText(FOUR.map(([label]) => label)); +}); + +test('WPA draft: explore.legend empty renders no legend even when shown', async ({ staffPage, safetyAudit }) => { + const legend = await openExplore(staffPage, { 'app.explore.show_legend': true, 'app.explore.legend': [] }, safetyAudit); + await expect(legend).toHaveCount(0); +}); diff --git a/apps/workplace/e2e/settings/explore/explore.show-legend.spec.ts b/apps/workplace/e2e/settings/explore/explore.show-legend.spec.ts new file mode 100644 index 00000000000..f00a308bf0f --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.show-legend.spec.ts @@ -0,0 +1,224 @@ +import type { ConsoleMessage, Page, Route, WebSocketRoute } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/room/room.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. explore.show_legend and explore.legend +// (libs/explore/src/lib/explore-map-view.component.ts:106-144, 237, 249-252): +// `@if (show_legend() && legend().length)` renders a [legend] panel; more than +// three [label, colour] pairs give a collapsed toggle button, three or fewer an +//

. It depends on no map, level or WebSocket data. Nothing is clicked except +// the legend's own collapse toggle (UI state only). +// +// Opening /#/explore otherwise has side effects that are answered in the browser: +// - ExploreSpacesService binds every room the level lists (systems?zone_id&limit=50) +// -> that list is served empty, so no room binding is attempted. +// - ExploreLockersService and ParkingService (via ExploreParkingService) resolve +// hidden asset categories/types and POST/PUT when a GET finds none or a type +// sits in another category (locker-assets.fn.ts, parking-assets.fn.ts). +// Every module shares GET asset_categories?hidden=true&limit=500, so one +// response carries all hidden categories, and each types GET returns +// matching types with the right category_id. Their asset lists are empty. +// - EventFormService.newForm reads the calendar provider -> served empty. +// - Topbar global search is switched off (app.global_search false). +type Settings = Record; +type DebugWindow = Window & { setting?: (key: string) => unknown }; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const CATEGORIES = [ + { id: 'e2e-draft-cat-lockers', name: '_LOCKERS_', hidden: true }, + { id: 'e2e-draft-cat-parking', name: '_PARKING_', hidden: true }, + { id: 'e2e-draft-cat-desks', name: '_DESKS_', hidden: true }, + { id: 'e2e-draft-cat-catering', name: '_CATERING_', hidden: true }, +]; +const TYPES: Record = { + 'e2e-draft-cat-lockers': [ + { id: 'e2e-draft-type-locker-banks', name: '_LOCKER_BANKS_' }, + { id: 'e2e-draft-type-lockers', name: '_LOCKERS_' }, + ], + 'e2e-draft-cat-parking': [ + { id: 'e2e-draft-type-parking-spaces', name: '_PARKING_SPACES_' }, + { id: 'e2e-draft-type-parking-users', name: '_PARKING_USERS_' }, + { id: 'e2e-draft-type-parking-fleet', name: '_PARKING_FLEET_VEHICLES_' }, + ], + 'e2e-draft-cat-desks': [{ id: 'e2e-draft-type-desks', name: '_DESKS_' }], + // No CATERING: type, so no menu. + 'e2e-draft-cat-catering': [], +}; +const TYPE_IDS = Object.values(TYPES).flat().map((type) => type.id); +const BASE: Settings = { + ...READ_ONLY_SETTINGS, + 'app.features': ['explore'], 'app.chat.enabled': false, 'app.global_search': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.use_region': false, 'app.desks.use_assets': false, +}; + +// ---------------------------------------------------------------- safety audit +// Same policy as the corrected Batch 2/4/5 drafts. Any binding the page might +// still attempt travels over the shell's control socket, which is intercepted +// and closed before it connects; any other socket fails the test. +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; + +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** Only the Angular dev server's Vite client socket (vite client.mjs:856-865, reload on close :993-998). */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +function isLocalControlSocket(url: URL) { + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.protocol === protocol && url.host === app.host && + url.pathname === '/api/engine/v2/systems/control'; +} + +/** ErrorHandler logs console.error('ERROR', e); Angular runtime errors log NG0xxx. */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], page_errors: [], + app_errors: [], monitoring_errors: [], fixtures: {} }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + const closeSocket = async (socket: WebSocketRoute, url: string) => { + try { + await socket.close(); + } catch (error) { + audit.monitoring_errors.push(`WebSocket close failed for ${url}: ${String(error)}`); + } + }; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = redact(url); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: socket_url, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (isLocalControlSocket(url)) { + audit.blocked_control_attempts.push({ + url: socket_url, + classification: 'known Workplace shell initialization; intercepted and closed before server connection', + }); + } else { + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control' : 'other'; + audit.websocket_attempts.push({ url: socket_url, classification: `UNEXPECTED ${kind}; legend scenario expects no other socket` }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed for ${socket_url}: ${String(error)}`); + } + closing.push(closeSocket(socket, socket_url)); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('explore-legend-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +/** A named, counted, GET-only browser response. Every other method reaches the read-only guard. */ +async function stubGet(page: Page, audit: SafetyAudit, name: string, + match: (url: URL) => boolean, body: (url: URL) => unknown) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json: body(new URL(route.request().url())) }); + }); +} + +async function openExplore(page: Page, overrides: Settings, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1000 }); + await stubGet(page, audit, 'hidden asset categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await stubGet(page, audit, 'hidden asset types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && (url.searchParams.get('category_id') ?? '') in TYPES, + (url) => { + const category_id = url.searchParams.get('category_id')!; + // category_id must match, or move_type_to_category would PUT. + return TYPES[category_id].map((type) => ({ ...type, brand: 'PlaceOS', category_id })); + }); + await stubGet(page, audit, 'hidden asset lists', (url) => url.pathname === '/api/engine/v2/assets' && + TYPE_IDS.includes(url.searchParams.get('type_id') ?? ''), () => []); + // ExploreStateService: querySystems({ zone_id, limit: 50 }) -> no rooms, so no room bindings. + await stubGet(page, audit, 'explore level rooms', (url) => url.pathname === '/api/engine/v2/systems' && + [...url.searchParams.keys()].sort().join(',') === 'limit,zone_id' && url.searchParams.get('limit') === '50', () => []); + await stubGet(page, audit, 'calendar provider list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + const settings = { ...BASE, ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-US')); + await page.goto('/#/explore'); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('explore-map-view')).toHaveCount(1); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => (window as DebugWindow).setting?.(key), key)).toEqual(value); + } + await expect.poll(() => audit.fixtures['hidden asset categories'] ?? 0, + { message: 'hidden-category bootstraps must read the browser fixture instead of creating categories' }).toBeGreaterThan(0); + return page.locator('explore-map-view [legend]'); +} + +const TWO: [string, string][] = [['E2E Draft Free', '#123456'], ['E2E Draft Busy', '#654321']]; +const FOUR: [string, string][] = [...TWO, ['E2E Draft Pending', '#112233'], ['E2E Draft Closed', '#445566']]; + +test('WPA draft: explore.show_legend false hides a configured legend', async ({ staffPage, safetyAudit }) => { + const legend = await openExplore(staffPage, { 'app.explore.show_legend': false, 'app.explore.legend': TWO }, safetyAudit); + await expect(legend).toHaveCount(0); +}); + +test('WPA draft: explore.show_legend true with explore.legend of two pairs renders labels and colours', async ({ staffPage, safetyAudit }) => { + const legend = await openExplore(staffPage, { 'app.explore.show_legend': true, 'app.explore.legend': TWO }, safetyAudit); + await expect(legend).toBeVisible(); + await expect(legend.locator('h3')).toHaveText('Legend'); + await expect(legend.locator('button[aria-controls="explore-map-legend-items"]')).toHaveCount(0); + const items = legend.locator('#explore-map-legend-items > div'); + await expect(items).toHaveCount(2); + await expect(items.locator('div.text-sm')).toHaveText(['E2E Draft Free', 'E2E Draft Busy']); + await expect(items.nth(0).locator('div').first()).toHaveCSS('background-color', 'rgb(18, 52, 86)'); + await expect(items.nth(1).locator('div').first()).toHaveCSS('background-color', 'rgb(101, 67, 33)'); +}); diff --git a/apps/workplace/e2e/settings/general/general.allow-dark-mode.spec.ts b/apps/workplace/e2e/settings/general/general.allow-dark-mode.spec.ts new file mode 100644 index 00000000000..86a6030b019 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.allow-dark-mode.spec.ts @@ -0,0 +1,260 @@ +import type { ConsoleMessage, Page, Route } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. SettingsService._applyTheme and topbar.logo select +// light/dark branding. app.component's @defer and ChatComponent.can_show gate +// the chat launcher. No theme control or chat control is clicked. +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const image = (colour: string) => `data:image/svg+xml,${encodeURIComponent( + ``, +)}`; +const LIGHT = image('white'); +const DARK_A = image('navy'); +const DARK_B = image('purple'); + +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + initialization: { url: string; parent_ids: string[]; fixture: string }[]; +}; + +/** Query values (tokens in ts-client's URL-authentication mode) never reach artifacts. */ +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** + * The Angular dev server's Vite client socket, and nothing else. + * + * @angular/build:dev-server keeps liveReload on by default, so the injected + * Vite client opens `ws(s):///?token=` (subprotocol vite-hmr) on + * every load (vite/dist/client/client.mjs:856-865). Closing it makes the client + * poll and then location.reload() (:993-998), so it is held open as a mock: it + * is never connected to the dev server and never closed. PlaceOS sockets use + * /api/engine/v2/... or /control/... paths and can never match. + */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +/** + * Exceptions Angular catches never reach `pageerror`: the app's + * LazySentryErrorHandler falls back to ErrorHandler, which logs + * console.error('ERROR', error). Angular runtime errors log as NG0xxx. + */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +// No PlaceOS WebSocket URL is exempted. In particular, the shared +// systems/control endpoint can carry BOTH the optional LLM hint bind and driver +// executions; allowing that URL would not distinguish a safe bind from an +// unsafe command. +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { + websocket_attempts: [], tooling_websockets: [], page_errors: [], app_errors: [], + monitoring_errors: [], initialization: [], + }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => { + audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack }); + }); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + const url = new URL(socket.url()); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: redact(url), + classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control (including LLM hint)' : 'other'; + audit.websocket_attempts.push({ url: redact(url), classification: `UNEXPECTED ${kind}; branding uses HTTP-only initialization` }); + // No connectToServer: the page side is closed before any server connection. + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + await use(audit); + } finally { + // Runs after the test body whether it passed or failed. Listeners + // and interception stay active until the context is closed. Parent + // readOnlyTest still runs its unchanged mutation/identity + // assertions after this auto fixture's teardown. + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('batch-1-branding-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + // One hard assertion, so every category is reported together. + expect({ + websocket_attempts: audit.websocket_attempts, + page_errors: audit.page_errors, + app_errors: audit.app_errors, + monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +async function isolateChatHint(page: Page, audit: SafetyAudit) { + // ChatService._chat_system reads OrganisationService.binding('chat_room'), + // then _bindHint calls getModule(id, 'LLM').variable('user_hint').bind(). + // The launcher (ChatComponent.can_show) reads only chat.enabled and does + // not need that optional hint. Null ONLY this binding in browser GET + // responses for organization/building initialization; preserve all other + // metadata. No backend setting/binding is saved. + // OrganisationService._bulkMetadataDetails uses bindings/bulk?parent_ids; + // _individualMetadata is its source-supported 404 fallback. + await page.addInitScript(() => { + // Cached copies (copied in with the staff storage state) would skip the request. + for (const key of Object.keys(localStorage)) { + if (key.startsWith('PLACEOS.org.metadata.') && key.includes('.bindings.')) localStorage.removeItem(key); + } + }); + const rewrite = (item: { details?: unknown }) => { + const details = typeof item.details === 'string' ? JSON.parse(item.details) : item.details ?? {}; + if (typeof details !== 'object' || details === null || Array.isArray(details)) { + throw new Error('bindings details must be a map'); + } + return { ...item, details: { ...details, chat_room: null } }; + }; + const handle = async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const url = new URL(route.request().url()); + try { + const bulk = url.pathname === '/api/engine/v2/metadata/bindings/bulk'; + const parent_ids = bulk ? url.searchParams.get('parent_ids')!.split(',') : + [decodeURIComponent(url.pathname.split('/')[5])]; + if (!parent_ids.every(Boolean)) throw new Error('binding fixture must identify its requested parents'); + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + // Preserve 404 so the client's existing individual-metadata fallback + // still runs. _individualMetadata maps a missing record to null; that + // is source-supported absence of a hint, not an application exception. + if (response.status() === 404) { + if (!bulk) audit.initialization.push({ url: redact(url), parent_ids, + fixture: 'HTTP GET only: missing bindings metadata (404), no optional chat_room hint' }); + return await route.fulfill({ response }); + } + if (!response.ok()) throw new Error(`bindings initialization GET returned ${response.status()}`); + const body = await response.json(); + if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new Error('bindings response must be a map'); + // showMetadata(id, name) reads data[name] from GET /metadata/id?name=...; + // bulkMetadata instead reads a map of parent IDs to metadata records. + // Only records the server returned are rewritten; none are invented. + const json = { ...body }; + if (bulk) { + for (const id of parent_ids) if (body[id]) json[id] = rewrite(body[id]); + } else if (body.bindings) json.bindings = rewrite(body.bindings); + audit.initialization.push({ url: redact(url), parent_ids, fixture: 'HTTP GET only: optional chat_room hint binding set to null in browser response' }); + await route.fulfill({ json }); + } catch (error) { + // Never hide a fixture problem: record it (fails in teardown) and + // let the unmodified GET continue through the read-only guard. + audit.monitoring_errors.push(`chat hint fixture: ${String(error)} (${redact(url)})`); + await route.fallback().catch(() => undefined); + } + }; + await page.route((url) => ORIGINS.has(url.origin) && + ((url.pathname === '/api/engine/v2/metadata/bindings/bulk' && + [...url.searchParams.keys()].length === 1 && !!url.searchParams.get('parent_ids')) || + (/^\/api\/engine\/v2\/metadata\/[^/]+$/.test(url.pathname) && + [...url.searchParams.keys()].length === 1 && url.searchParams.get('name') === 'bindings')), handle); +} + +async function openBranding(page: Page, overrides: Record, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1000 }); + if (overrides['app.chat.enabled'] === true) await isolateChatHint(page, audit); + let identity: Promise<{ id: string }> | undefined; + page.on('response', (response) => { + if (response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') identity = response.json(); + }); + let theme_reads = 0; + await page.route((url) => ORIGINS.has(url.origin) && + /^\/api\/engine\/v2\/metadata\/[^/]+$/.test(url.pathname) && + [...url.searchParams.keys()].length === 1 && url.searchParams.get('name') === 'settings', async (route) => { + if (route.request().method() !== 'GET' || !identity) return route.fallback(); + try { + const user = await identity; + const url = new URL(route.request().url()); + if (decodeURIComponent(url.pathname.split('/')[5]) !== user.id) return await route.fallback(); + theme_reads++; + // SettingsService.init: showMetadata(user.id, 'settings') -> + // GET /metadata/?name=settings, read as data['settings']. + // A saved theme makes _initDarkMode return before saveUserSetting. + // This is a browser response only; no user setting is persisted. + await route.fulfill({ json: { settings: { name: 'settings', details: { theme: 'dark' } } } }); + } catch (error) { + audit.monitoring_errors.push(`user settings fixture: ${String(error)}`); + await route.fallback().catch(() => undefined); + } + }); + const settings = { ...READ_ONLY_SETTINGS, 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.logo_light': LIGHT, 'app.logo_dark': DARK_A, ...overrides }; + await useSettings(page, settings); + await page.goto('/#/directory'); + await expect(page.locator('[a-directory-user-list]')).toBeVisible(); + // GlobalLoading renders [loader] until initialisationComplete(), and keeps + // it (with [initialisation-error]) when initialization fails internally. + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + await expect.poll(() => theme_reads, { message: 'the saved-theme user settings fixture must be used' }).toBeGreaterThan(0); + if (overrides['app.chat.enabled'] === true) { + await expect.poll(() => audit.initialization.length, { message: 'chat launcher must use its HTTP-only hint initialization fixture' }).toBeGreaterThan(0); + // OrganisationService exposes app.org in debug mode. Read its PUBLIC + // state only, after all org/building metadata has finished loading. + await expect.poll(() => page.evaluate(() => { + const org = (window as Window & { app?: { org?: { + initialised(): boolean; binding(name: string): unknown; + } } }).app?.org; + return { initialised: org?.initialised() === true, hint: org?.binding('chat_room') ?? null }; + })).toEqual({ initialised: true, hint: null }); + } + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => + (window as Window & { setting?: (key: string) => unknown }).setting?.(key), key)).toEqual(value); + } + return page.locator('topbar a[name="nav-logo"] img'); +} + +for (const enabled of [false, true]) { + test(`WPA draft: allow_dark_mode ${enabled} gates a saved dark theme`, async ({ staffPage, safetyAudit }) => { + const logo = await openBranding(staffPage, { 'app.allow_dark_mode': enabled }, safetyAudit); + if (enabled) await expect(staffPage.locator('body')).toHaveClass(/\btheme-dark\b/); + else await expect(staffPage.locator('body')).not.toHaveClass(/\btheme-dark\b/); + await expect(logo).toBeVisible(); + await expect(logo).toHaveAttribute('src', enabled ? DARK_A : LIGHT); + }); +} + diff --git a/apps/workplace/e2e/settings/general/general.environment-bar.spec.ts b/apps/workplace/e2e/settings/general/general.environment-bar.spec.ts new file mode 100644 index 00000000000..7c425274aeb --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.environment-bar.spec.ts @@ -0,0 +1,231 @@ +// Phase 2 Group 1: migrated accepted scenario from local/wpa-settings-batch28.spec.ts. +// Source SHA-256: d77779e766076d6b6379d6793c68f7325688469efcbd072f83f0435f2c4c983a +// Original title and functional assertions retained; runtime revalidation pending. +import type { BrowserContext, Page } from '@playwright/test'; +import { APP_URL, BACKEND_URL, assertLocalOnly, staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +type Settings = Record; +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guard. +const HIDDEN_CATEGORIES = [ + { id: 'pr1-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr1-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr1-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr1-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr1-hidden-catering': [], 'pr1-hidden-desks': ['_DESKS_'], + 'pr1-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr1-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr1-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr1-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +type Attempt = { url: string; classification: string }; +type Audit = { + websocket_attempts: Attempt[]; + blocked_control_attempts: Attempt[]; + tooling_websockets: Attempt[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + staff_identities: { email: string; privileged: boolean }[]; + observe(context: BrowserContext): void; +}; +const TIMEZONE = 'Etc/UTC'; +// Same exact local control endpoint and Vite socket classification as the +// accepted permanent currency spec. No socket ever calls connectToServer(). +const test = readOnlyTest.extend<{ + safetyAudit: Audit; + isolated: { open(overrides: Settings): Promise }; +}>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const contexts: BrowserContext[] = []; + const closing: Promise[] = []; + const audit: Audit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], staff_identities: [], + observe(context) { + contexts.push(context); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + for (const page of context.pages()) observePage(page); + context.on('page', observePage); + }, + }; + const monitor = async (context: BrowserContext) => { + audit.observe(context); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + const local = url.protocol === protocol && url.host === app.host; + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) { + audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + } else { + audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED socket; these presentation scenarios are HTTP-only' }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed: ${String(error)}`); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + }; + // Expose an async registration hook to isolated.open, before new pages + // or navigation. Audit data itself remains JSON serializable. + const registered = new Set(); + audit.observe = ((observe) => (context: BrowserContext) => { + if (!registered.has(context)) { registered.add(context); observe(context); } + })(audit.observe); + contextMonitors.set(audit, monitor); + try { + await monitor(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close failed: ${String(error)}`); } + } + await Promise.all(closing); + contextMonitors.delete(audit); + try { + await info.attach('legacy-settings-group1-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'no unexpected socket, error or API mutation').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + expect(audit.staff_identities.length, 'each scenario must verify staff identity').toBeGreaterThan(0); + for (const identity of audit.staff_identities) { + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged).toBe(false); + } + } + } + }, { auto: true, timeout: 30_000 }], + isolated: async ({ browser, staffPage, safetyAudit }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + let first = true; + await use({ open: async (overrides) => { + let page: Page; + if (first) { + first = false; + page = staffPage; // The shared parent guard observes identity and checks teardown. + } else { + const context = await browser.newContext({ storageState: auth, baseURL: APP_URL, + ignoreHTTPSErrors: true, timezoneId: TIMEZONE, locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, serviceWorkers: 'block' }); + await contextMonitors.get(safetyAudit)!(context); + // Additional isolated contexts retain the legacy API guard; + // the parent fixture guards the first page independently. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) || + ![new URL(APP_URL).origin, new URL(BACKEND_URL).origin].includes(url.origin)) { + safetyAudit.blocked_api_requests.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else await route.continue(); + }); + page = await context.newPage(); + } + await page.setViewportSize({ width: 1440, height: 1100 }); + const observed = page.waitForResponse((response) => response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') + .then((response) => ({ response }), (error: unknown) => ({ error })); + await stubHiddenAssets(page); + const settings = { ...BASE_SETTINGS, ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + await page.goto('/#/landing'); + const result = await observed; + if ('error' in result) throw result.error; + expect(result.response.ok(), 'the app must load the signed-in staff user').toBe(true); + const user = await result.response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + safetyAudit.staff_identities.push(identity); + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged, 'staff-only scenario').toBeFalsy(); + expect(new URL(page.url()).origin).toBe(new URL(APP_URL).origin); + expect(await page.evaluate(() => new Date().getTimezoneOffset())).toBe(0); + expect(['UTC', TIMEZONE]).toContain(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)); + const stored = await page.evaluate(() => localStorage.getItem('PLACEOS.setting_overrides')); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator('topbar')).toBeVisible(); + return page; + } }); + }, +}); +const contextMonitors = new WeakMap Promise>(); +test.use({ storageState: undefined, timezoneId: TIMEZONE, readOnlyTimezone: TIMEZONE, locale: 'en-AU' }); +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const BASE_SETTINGS: Settings = { + 'app.features': ['parking'], + 'app.show_quick_book': false, + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, +}; + +// GlobalBannerComponent.environment_bar + its body padding effect. +test('WPA Batch 28: environment_bar renders the configured colour and clears for an empty value', async ({ isolated }) => { + const shown = await isolated.open({ 'app.environment_bar': '#123456' }); + const bars = shown.locator('global-banner .environment-bar'); + await expect(bars).toHaveCount(2); + for (const bar of await bars.all()) { + await expect(bar).toBeVisible(); + await expect(bar).toHaveCSS('background-color', 'rgb(18, 52, 86)'); + await expect(bar).toHaveAttribute('aria-hidden', 'true'); + } + await expect(shown.locator('body')).toHaveClass(/\bhas-environment-bar\b/); + const hidden = await isolated.open({ 'app.environment_bar': '' }); + await expect(hidden.locator('global-banner')).toHaveCount(1); + await expect(hidden.locator('global-banner .environment-bar')).toHaveCount(0); + await expect(hidden.locator('body')).not.toHaveClass(/\bhas-environment-bar\b/); + // The other context's setting remains independent. + await expect(bars).toHaveCount(2); +}); diff --git a/apps/workplace/e2e/settings/general/general.global-search.spec.ts b/apps/workplace/e2e/settings/general/general.global-search.spec.ts new file mode 100644 index 00000000000..2a44008aa0b --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.global-search.spec.ts @@ -0,0 +1,224 @@ +// Phase 2 Group 1: migrated accepted scenario from local/wpa-settings-batch28.spec.ts. +// Source SHA-256: d77779e766076d6b6379d6793c68f7325688469efcbd072f83f0435f2c4c983a +// Original title and functional assertions retained; runtime revalidation pending. +import type { BrowserContext, Page } from '@playwright/test'; +import { APP_URL, BACKEND_URL, assertLocalOnly, staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +type Settings = Record; +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guard. +const HIDDEN_CATEGORIES = [ + { id: 'pr1-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr1-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr1-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr1-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr1-hidden-catering': [], 'pr1-hidden-desks': ['_DESKS_'], + 'pr1-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr1-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr1-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr1-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +type Attempt = { url: string; classification: string }; +type Audit = { + websocket_attempts: Attempt[]; + blocked_control_attempts: Attempt[]; + tooling_websockets: Attempt[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + staff_identities: { email: string; privileged: boolean }[]; + observe(context: BrowserContext): void; +}; +const TIMEZONE = 'Etc/UTC'; +// Same exact local control endpoint and Vite socket classification as the +// accepted permanent currency spec. No socket ever calls connectToServer(). +const test = readOnlyTest.extend<{ + safetyAudit: Audit; + isolated: { open(overrides: Settings): Promise }; +}>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const contexts: BrowserContext[] = []; + const closing: Promise[] = []; + const audit: Audit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], staff_identities: [], + observe(context) { + contexts.push(context); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + for (const page of context.pages()) observePage(page); + context.on('page', observePage); + }, + }; + const monitor = async (context: BrowserContext) => { + audit.observe(context); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + const local = url.protocol === protocol && url.host === app.host; + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) { + audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + } else { + audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED socket; these presentation scenarios are HTTP-only' }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed: ${String(error)}`); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + }; + // Expose an async registration hook to isolated.open, before new pages + // or navigation. Audit data itself remains JSON serializable. + const registered = new Set(); + audit.observe = ((observe) => (context: BrowserContext) => { + if (!registered.has(context)) { registered.add(context); observe(context); } + })(audit.observe); + contextMonitors.set(audit, monitor); + try { + await monitor(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close failed: ${String(error)}`); } + } + await Promise.all(closing); + contextMonitors.delete(audit); + try { + await info.attach('legacy-settings-group1-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'no unexpected socket, error or API mutation').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + expect(audit.staff_identities.length, 'each scenario must verify staff identity').toBeGreaterThan(0); + for (const identity of audit.staff_identities) { + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged).toBe(false); + } + } + } + }, { auto: true, timeout: 30_000 }], + isolated: async ({ browser, staffPage, safetyAudit }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + let first = true; + await use({ open: async (overrides) => { + let page: Page; + if (first) { + first = false; + page = staffPage; // The shared parent guard observes identity and checks teardown. + } else { + const context = await browser.newContext({ storageState: auth, baseURL: APP_URL, + ignoreHTTPSErrors: true, timezoneId: TIMEZONE, locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, serviceWorkers: 'block' }); + await contextMonitors.get(safetyAudit)!(context); + // Additional isolated contexts retain the legacy API guard; + // the parent fixture guards the first page independently. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) || + ![new URL(APP_URL).origin, new URL(BACKEND_URL).origin].includes(url.origin)) { + safetyAudit.blocked_api_requests.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else await route.continue(); + }); + page = await context.newPage(); + } + await page.setViewportSize({ width: 1440, height: 1100 }); + const observed = page.waitForResponse((response) => response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') + .then((response) => ({ response }), (error: unknown) => ({ error })); + await stubHiddenAssets(page); + const settings = { ...BASE_SETTINGS, ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + await page.goto('/#/landing'); + const result = await observed; + if ('error' in result) throw result.error; + expect(result.response.ok(), 'the app must load the signed-in staff user').toBe(true); + const user = await result.response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + safetyAudit.staff_identities.push(identity); + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged, 'staff-only scenario').toBeFalsy(); + expect(new URL(page.url()).origin).toBe(new URL(APP_URL).origin); + expect(await page.evaluate(() => new Date().getTimezoneOffset())).toBe(0); + expect(['UTC', TIMEZONE]).toContain(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)); + const stored = await page.evaluate(() => localStorage.getItem('PLACEOS.setting_overrides')); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator('topbar')).toBeVisible(); + return page; + } }); + }, +}); +const contextMonitors = new WeakMap Promise>(); +test.use({ storageState: undefined, timezoneId: TIMEZONE, readOnlyTimezone: TIMEZONE, locale: 'en-AU' }); +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const BASE_SETTINGS: Settings = { + 'app.features': ['parking'], + 'app.show_quick_book': false, + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, +}; + +// docs/settings/workplace.md:42; TopbarComponent.search uses !== false. +test('WPA Batch 28: global_search shows and hides the topbar search control', async ({ isolated }) => { + for (const enabled of [true, false]) { + const page = await isolated.open({ 'app.global_search': enabled }); + const search = page.locator('topbar global-search'); + await expect(search).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(search).toBeVisible(); + await expect(page.locator('topbar a[name="nav-logo"]')).toBeVisible(); + await expect(page.locator('topbar user-controls-sidebar')).toHaveCount(1); + } +}); diff --git a/apps/workplace/e2e/settings/general/general.logo-dark.spec.ts b/apps/workplace/e2e/settings/general/general.logo-dark.spec.ts new file mode 100644 index 00000000000..29afb107df0 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.logo-dark.spec.ts @@ -0,0 +1,264 @@ +import type { ConsoleMessage, Page, Route } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. SettingsService._applyTheme and topbar.logo select +// light/dark branding. app.component's @defer and ChatComponent.can_show gate +// the chat launcher. No theme control or chat control is clicked. +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const image = (colour: string) => `data:image/svg+xml,${encodeURIComponent( + ``, +)}`; +const LIGHT = image('white'); +const DARK_A = image('navy'); +const DARK_B = image('purple'); + +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + initialization: { url: string; parent_ids: string[]; fixture: string }[]; +}; + +/** Query values (tokens in ts-client's URL-authentication mode) never reach artifacts. */ +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** + * The Angular dev server's Vite client socket, and nothing else. + * + * @angular/build:dev-server keeps liveReload on by default, so the injected + * Vite client opens `ws(s):///?token=` (subprotocol vite-hmr) on + * every load (vite/dist/client/client.mjs:856-865). Closing it makes the client + * poll and then location.reload() (:993-998), so it is held open as a mock: it + * is never connected to the dev server and never closed. PlaceOS sockets use + * /api/engine/v2/... or /control/... paths and can never match. + */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +/** + * Exceptions Angular catches never reach `pageerror`: the app's + * LazySentryErrorHandler falls back to ErrorHandler, which logs + * console.error('ERROR', error). Angular runtime errors log as NG0xxx. + */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +// No PlaceOS WebSocket URL is exempted. In particular, the shared +// systems/control endpoint can carry BOTH the optional LLM hint bind and driver +// executions; allowing that URL would not distinguish a safe bind from an +// unsafe command. +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { + websocket_attempts: [], tooling_websockets: [], page_errors: [], app_errors: [], + monitoring_errors: [], initialization: [], + }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => { + audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack }); + }); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + const url = new URL(socket.url()); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: redact(url), + classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control (including LLM hint)' : 'other'; + audit.websocket_attempts.push({ url: redact(url), classification: `UNEXPECTED ${kind}; branding uses HTTP-only initialization` }); + // No connectToServer: the page side is closed before any server connection. + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + await use(audit); + } finally { + // Runs after the test body whether it passed or failed. Listeners + // and interception stay active until the context is closed. Parent + // readOnlyTest still runs its unchanged mutation/identity + // assertions after this auto fixture's teardown. + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('batch-1-branding-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + // One hard assertion, so every category is reported together. + expect({ + websocket_attempts: audit.websocket_attempts, + page_errors: audit.page_errors, + app_errors: audit.app_errors, + monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +async function isolateChatHint(page: Page, audit: SafetyAudit) { + // ChatService._chat_system reads OrganisationService.binding('chat_room'), + // then _bindHint calls getModule(id, 'LLM').variable('user_hint').bind(). + // The launcher (ChatComponent.can_show) reads only chat.enabled and does + // not need that optional hint. Null ONLY this binding in browser GET + // responses for organization/building initialization; preserve all other + // metadata. No backend setting/binding is saved. + // OrganisationService._bulkMetadataDetails uses bindings/bulk?parent_ids; + // _individualMetadata is its source-supported 404 fallback. + await page.addInitScript(() => { + // Cached copies (copied in with the staff storage state) would skip the request. + for (const key of Object.keys(localStorage)) { + if (key.startsWith('PLACEOS.org.metadata.') && key.includes('.bindings.')) localStorage.removeItem(key); + } + }); + const rewrite = (item: { details?: unknown }) => { + const details = typeof item.details === 'string' ? JSON.parse(item.details) : item.details ?? {}; + if (typeof details !== 'object' || details === null || Array.isArray(details)) { + throw new Error('bindings details must be a map'); + } + return { ...item, details: { ...details, chat_room: null } }; + }; + const handle = async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const url = new URL(route.request().url()); + try { + const bulk = url.pathname === '/api/engine/v2/metadata/bindings/bulk'; + const parent_ids = bulk ? url.searchParams.get('parent_ids')!.split(',') : + [decodeURIComponent(url.pathname.split('/')[5])]; + if (!parent_ids.every(Boolean)) throw new Error('binding fixture must identify its requested parents'); + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + // Preserve 404 so the client's existing individual-metadata fallback + // still runs. _individualMetadata maps a missing record to null; that + // is source-supported absence of a hint, not an application exception. + if (response.status() === 404) { + if (!bulk) audit.initialization.push({ url: redact(url), parent_ids, + fixture: 'HTTP GET only: missing bindings metadata (404), no optional chat_room hint' }); + return await route.fulfill({ response }); + } + if (!response.ok()) throw new Error(`bindings initialization GET returned ${response.status()}`); + const body = await response.json(); + if (typeof body !== 'object' || body === null || Array.isArray(body)) throw new Error('bindings response must be a map'); + // showMetadata(id, name) reads data[name] from GET /metadata/id?name=...; + // bulkMetadata instead reads a map of parent IDs to metadata records. + // Only records the server returned are rewritten; none are invented. + const json = { ...body }; + if (bulk) { + for (const id of parent_ids) if (body[id]) json[id] = rewrite(body[id]); + } else if (body.bindings) json.bindings = rewrite(body.bindings); + audit.initialization.push({ url: redact(url), parent_ids, fixture: 'HTTP GET only: optional chat_room hint binding set to null in browser response' }); + await route.fulfill({ json }); + } catch (error) { + // Never hide a fixture problem: record it (fails in teardown) and + // let the unmodified GET continue through the read-only guard. + audit.monitoring_errors.push(`chat hint fixture: ${String(error)} (${redact(url)})`); + await route.fallback().catch(() => undefined); + } + }; + await page.route((url) => ORIGINS.has(url.origin) && + ((url.pathname === '/api/engine/v2/metadata/bindings/bulk' && + [...url.searchParams.keys()].length === 1 && !!url.searchParams.get('parent_ids')) || + (/^\/api\/engine\/v2\/metadata\/[^/]+$/.test(url.pathname) && + [...url.searchParams.keys()].length === 1 && url.searchParams.get('name') === 'bindings')), handle); +} + +async function openBranding(page: Page, overrides: Record, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1000 }); + if (overrides['app.chat.enabled'] === true) await isolateChatHint(page, audit); + let identity: Promise<{ id: string }> | undefined; + page.on('response', (response) => { + if (response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') identity = response.json(); + }); + let theme_reads = 0; + await page.route((url) => ORIGINS.has(url.origin) && + /^\/api\/engine\/v2\/metadata\/[^/]+$/.test(url.pathname) && + [...url.searchParams.keys()].length === 1 && url.searchParams.get('name') === 'settings', async (route) => { + if (route.request().method() !== 'GET' || !identity) return route.fallback(); + try { + const user = await identity; + const url = new URL(route.request().url()); + if (decodeURIComponent(url.pathname.split('/')[5]) !== user.id) return await route.fallback(); + theme_reads++; + // SettingsService.init: showMetadata(user.id, 'settings') -> + // GET /metadata/?name=settings, read as data['settings']. + // A saved theme makes _initDarkMode return before saveUserSetting. + // This is a browser response only; no user setting is persisted. + await route.fulfill({ json: { settings: { name: 'settings', details: { theme: 'dark' } } } }); + } catch (error) { + audit.monitoring_errors.push(`user settings fixture: ${String(error)}`); + await route.fallback().catch(() => undefined); + } + }); + const settings = { ...READ_ONLY_SETTINGS, 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.logo_light': LIGHT, 'app.logo_dark': DARK_A, ...overrides }; + await useSettings(page, settings); + await page.goto('/#/directory'); + await expect(page.locator('[a-directory-user-list]')).toBeVisible(); + // GlobalLoading renders [loader] until initialisationComplete(), and keeps + // it (with [initialisation-error]) when initialization fails internally. + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + await expect.poll(() => theme_reads, { message: 'the saved-theme user settings fixture must be used' }).toBeGreaterThan(0); + if (overrides['app.chat.enabled'] === true) { + await expect.poll(() => audit.initialization.length, { message: 'chat launcher must use its HTTP-only hint initialization fixture' }).toBeGreaterThan(0); + // OrganisationService exposes app.org in debug mode. Read its PUBLIC + // state only, after all org/building metadata has finished loading. + await expect.poll(() => page.evaluate(() => { + const org = (window as Window & { app?: { org?: { + initialised(): boolean; binding(name: string): unknown; + } } }).app?.org; + return { initialised: org?.initialised() === true, hint: org?.binding('chat_room') ?? null }; + })).toEqual({ initialised: true, hint: null }); + } + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => + (window as Window & { setting?: (key: string) => unknown }).setting?.(key), key)).toEqual(value); + } + return page.locator('topbar a[name="nav-logo"] img'); +} + +for (const [label, value, expected] of [ + ['string', DARK_A, DARK_A], + ['object', { src: DARK_B }, DARK_B], +] as const) { + test(`WPA draft: logo_dark ${label} renders the configured image in dark mode`, async ({ staffPage, safetyAudit }) => { + const logo = await openBranding(staffPage, { 'app.allow_dark_mode': true, 'app.logo_dark': value }, safetyAudit); + await expect(staffPage.locator('body')).toHaveClass(/\btheme-dark\b/); + await expect(logo).toBeVisible(); + await expect(logo).toHaveAttribute('src', expected); + await expect.poll(() => logo.evaluate((element: HTMLImageElement) => + element.complete && element.naturalWidth === 80 && element.naturalHeight === 24)).toBe(true); + }); +} + diff --git a/apps/workplace/e2e/settings/general/general.logo-light.spec.ts b/apps/workplace/e2e/settings/general/general.logo-light.spec.ts new file mode 100644 index 00000000000..f2ea4afeda2 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.logo-light.spec.ts @@ -0,0 +1,229 @@ +// Phase 2 Group 1: migrated accepted scenario from local/wpa-settings-batch28.spec.ts. +// Source SHA-256: d77779e766076d6b6379d6793c68f7325688469efcbd072f83f0435f2c4c983a +// Original title and functional assertions retained; runtime revalidation pending. +import type { BrowserContext, Page } from '@playwright/test'; +import { APP_URL, BACKEND_URL, assertLocalOnly, staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +type Settings = Record; +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guard. +const HIDDEN_CATEGORIES = [ + { id: 'pr1-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr1-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr1-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr1-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr1-hidden-catering': [], 'pr1-hidden-desks': ['_DESKS_'], + 'pr1-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr1-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr1-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr1-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +type Attempt = { url: string; classification: string }; +type Audit = { + websocket_attempts: Attempt[]; + blocked_control_attempts: Attempt[]; + tooling_websockets: Attempt[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + staff_identities: { email: string; privileged: boolean }[]; + observe(context: BrowserContext): void; +}; +const TIMEZONE = 'Etc/UTC'; +// Same exact local control endpoint and Vite socket classification as the +// accepted permanent currency spec. No socket ever calls connectToServer(). +const test = readOnlyTest.extend<{ + safetyAudit: Audit; + isolated: { open(overrides: Settings): Promise }; +}>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const contexts: BrowserContext[] = []; + const closing: Promise[] = []; + const audit: Audit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], staff_identities: [], + observe(context) { + contexts.push(context); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + for (const page of context.pages()) observePage(page); + context.on('page', observePage); + }, + }; + const monitor = async (context: BrowserContext) => { + audit.observe(context); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + const local = url.protocol === protocol && url.host === app.host; + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) { + audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + } else { + audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED socket; these presentation scenarios are HTTP-only' }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed: ${String(error)}`); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + }; + // Expose an async registration hook to isolated.open, before new pages + // or navigation. Audit data itself remains JSON serializable. + const registered = new Set(); + audit.observe = ((observe) => (context: BrowserContext) => { + if (!registered.has(context)) { registered.add(context); observe(context); } + })(audit.observe); + contextMonitors.set(audit, monitor); + try { + await monitor(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close failed: ${String(error)}`); } + } + await Promise.all(closing); + contextMonitors.delete(audit); + try { + await info.attach('legacy-settings-group1-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'no unexpected socket, error or API mutation').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + expect(audit.staff_identities.length, 'each scenario must verify staff identity').toBeGreaterThan(0); + for (const identity of audit.staff_identities) { + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged).toBe(false); + } + } + } + }, { auto: true, timeout: 30_000 }], + isolated: async ({ browser, staffPage, safetyAudit }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + let first = true; + await use({ open: async (overrides) => { + let page: Page; + if (first) { + first = false; + page = staffPage; // The shared parent guard observes identity and checks teardown. + } else { + const context = await browser.newContext({ storageState: auth, baseURL: APP_URL, + ignoreHTTPSErrors: true, timezoneId: TIMEZONE, locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, serviceWorkers: 'block' }); + await contextMonitors.get(safetyAudit)!(context); + // Additional isolated contexts retain the legacy API guard; + // the parent fixture guards the first page independently. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) || + ![new URL(APP_URL).origin, new URL(BACKEND_URL).origin].includes(url.origin)) { + safetyAudit.blocked_api_requests.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else await route.continue(); + }); + page = await context.newPage(); + } + await page.setViewportSize({ width: 1440, height: 1100 }); + const observed = page.waitForResponse((response) => response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') + .then((response) => ({ response }), (error: unknown) => ({ error })); + await stubHiddenAssets(page); + const settings = { ...BASE_SETTINGS, ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + await page.goto('/#/landing'); + const result = await observed; + if ('error' in result) throw result.error; + expect(result.response.ok(), 'the app must load the signed-in staff user').toBe(true); + const user = await result.response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + safetyAudit.staff_identities.push(identity); + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged, 'staff-only scenario').toBeFalsy(); + expect(new URL(page.url()).origin).toBe(new URL(APP_URL).origin); + expect(await page.evaluate(() => new Date().getTimezoneOffset())).toBe(0); + expect(['UTC', TIMEZONE]).toContain(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)); + const stored = await page.evaluate(() => localStorage.getItem('PLACEOS.setting_overrides')); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator('topbar')).toBeVisible(); + return page; + } }); + }, +}); +const contextMonitors = new WeakMap Promise>(); +test.use({ storageState: undefined, timezoneId: TIMEZONE, readOnlyTimezone: TIMEZONE, locale: 'en-AU' }); +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const BASE_SETTINGS: Settings = { + 'app.features': ['parking'], + 'app.show_quick_book': false, + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, +}; + +// TopbarComponent.logo accepts logo?.src || logo. A data SVG exercises the +// rendered image without external requests, uploads or backend fixture data. +test('WPA Batch 28: logo_light loads both string and object image configurations', async ({ isolated }) => { + // Data SVG is an in-memory image fixture: no upload, file or backend data. + const svg = ''; + const image = `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; + for (const value of [image, { src: image }]) { + const page = await isolated.open({ 'app.logo_light': value }); + const logo = page.locator('topbar a[name="nav-logo"] img[alt="Logo"]'); + await expect(logo).toBeVisible(); + await expect(logo).toHaveAttribute('src', image); + await expect.poll(() => logo.evaluate((element: HTMLImageElement) => ({ + loaded: element.complete, width: element.naturalWidth, height: element.naturalHeight, + }))).toEqual({ loaded: true, width: 37, height: 19 }); + } +}); diff --git a/apps/workplace/e2e/settings/general/general.show-changelog.spec.ts b/apps/workplace/e2e/settings/general/general.show-changelog.spec.ts new file mode 100644 index 00000000000..6ddaf08f28f --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.show-changelog.spec.ts @@ -0,0 +1,242 @@ +// Phase 2 Group 1: migrated accepted scenario from local/wpa-settings-batch35.spec.ts. +// Source SHA-256: f2068e77c5a986bfd9c5c8dddf116355672b9cc00aad3fefadbf5cbbf7654f36 +// Original title and functional assertions retained; runtime revalidation pending. +import type { BrowserContext, Page } from '@playwright/test'; +import { APP_URL, BACKEND_URL, assertLocalOnly, staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +type Settings = Record; +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guard. +const HIDDEN_CATEGORIES = [ + { id: 'pr1-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr1-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr1-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr1-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr1-hidden-catering': [], 'pr1-hidden-desks': ['_DESKS_'], + 'pr1-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr1-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr1-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr1-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +type Attempt = { url: string; classification: string }; +type Audit = { + websocket_attempts: Attempt[]; + blocked_control_attempts: Attempt[]; + tooling_websockets: Attempt[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + staff_identities: { email: string; privileged: boolean }[]; + observe(context: BrowserContext): void; +}; +const TIMEZONE = 'Etc/UTC'; +// Same exact local control endpoint and Vite socket classification as the +// accepted permanent currency spec. No socket ever calls connectToServer(). +const test = readOnlyTest.extend<{ + safetyAudit: Audit; + isolated: { open(overrides: Settings): Promise }; +}>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const contexts: BrowserContext[] = []; + const closing: Promise[] = []; + const audit: Audit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], staff_identities: [], + observe(context) { + contexts.push(context); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + for (const page of context.pages()) observePage(page); + context.on('page', observePage); + }, + }; + const monitor = async (context: BrowserContext) => { + audit.observe(context); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + const local = url.protocol === protocol && url.host === app.host; + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) { + audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + } else { + audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED socket; these presentation scenarios are HTTP-only' }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed: ${String(error)}`); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push(`WebSocket close failed: ${String(error)}`); + })); + }); + }; + // Expose an async registration hook to isolated.open, before new pages + // or navigation. Audit data itself remains JSON serializable. + const registered = new Set(); + audit.observe = ((observe) => (context: BrowserContext) => { + if (!registered.has(context)) { registered.add(context); observe(context); } + })(audit.observe); + contextMonitors.set(audit, monitor); + try { + await monitor(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close failed: ${String(error)}`); } + } + await Promise.all(closing); + contextMonitors.delete(audit); + try { + await info.attach('legacy-settings-group1-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'no unexpected socket, error or API mutation').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + expect(audit.staff_identities.length, 'each scenario must verify staff identity').toBeGreaterThan(0); + for (const identity of audit.staff_identities) { + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged).toBe(false); + } + } + } + }, { auto: true, timeout: 30_000 }], + isolated: async ({ browser, staffPage, safetyAudit }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + let first = true; + await use({ open: async (overrides) => { + let page: Page; + if (first) { + first = false; + page = staffPage; // The shared parent guard observes identity and checks teardown. + } else { + const context = await browser.newContext({ storageState: auth, baseURL: APP_URL, + ignoreHTTPSErrors: true, timezoneId: TIMEZONE, locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, serviceWorkers: 'block' }); + await contextMonitors.get(safetyAudit)!(context); + // Additional isolated contexts retain the legacy API guard; + // the parent fixture guards the first page independently. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) || + ![new URL(APP_URL).origin, new URL(BACKEND_URL).origin].includes(url.origin)) { + safetyAudit.blocked_api_requests.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else await route.continue(); + }); + page = await context.newPage(); + } + await page.setViewportSize({ width: 1440, height: 1100 }); + const observed = page.waitForResponse((response) => response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') + .then((response) => ({ response }), (error: unknown) => ({ error })); + await stubHiddenAssets(page); + const settings = { ...BASE_SETTINGS, ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + await page.goto('/#/landing'); + const result = await observed; + if ('error' in result) throw result.error; + expect(result.response.ok(), 'the app must load the signed-in staff user').toBe(true); + const user = await result.response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + safetyAudit.staff_identities.push(identity); + expect(identity.email).toBe(staffEmail(info.parallelIndex).toLowerCase()); + expect(identity.privileged, 'staff-only scenario').toBeFalsy(); + expect(new URL(page.url()).origin).toBe(new URL(APP_URL).origin); + expect(await page.evaluate(() => new Date().getTimezoneOffset())).toBe(0); + expect(['UTC', TIMEZONE]).toContain(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)); + const stored = await page.evaluate(() => localStorage.getItem('PLACEOS.setting_overrides')); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator('topbar')).toBeVisible(); + return page; + } }); + }, +}); +const contextMonitors = new WeakMap Promise>(); +test.use({ storageState: undefined, timezoneId: TIMEZONE, readOnlyTimezone: TIMEZONE, locale: 'en-AU' }); +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const BASE_SETTINGS: Settings = { + 'app.features': [], + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, + 'app.show_quick_links': false, + 'app.show_quick_book': false, + 'app.use_region': false, +}; +async function controls(page: Page) { + await page.locator('topbar button[name="user-controls"]').click(); + await expect( + page.locator('user-controls-sidebar user-controls'), + ).toBeVisible(); + return page.locator('user-controls-sidebar user-controls'); +} + +// UserControls only opens the drawer; no version/changelog action is clicked. +test('WPA Batch 35: show_changelog changes the version control between a button and plain text', async ({ + isolated, +}) => { + for (const enabled of [true, false]) { + const page = await isolated.open({ 'app.show_changelog': enabled }); + const drawer = await controls(page); + const version = drawer + .locator('div.w-full.text-xs.opacity-60') + .filter({ hasText: /^\s*Version:/ }); + await expect(version).toHaveCount(1); + await expect(version).toBeVisible(); + await expect(version.locator('button')).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(version.locator('button')).not.toHaveText(''); + else await expect(version.locator('span')).not.toHaveText(''); + await expect( + drawer.getByRole('button', { name: 'Sign Out', exact: true }), + ).toBeVisible(); + } +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.allow-all-day.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.allow-all-day.spec.ts new file mode 100644 index 00000000000..f95afe43374 --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.allow-all-day.spec.ts @@ -0,0 +1,345 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker form', () => { + + test('lockers.allow_all_day true shows All day, which hides the time fields', async ({ staffPage, audit }) => { + const root = await lockerForm(staffPage, audit, { 'app.lockers.allow_all_day': true }); + await expect(allDayBox(root)).toHaveCount(1); + await expect(root.locator('a-duration-field')).toHaveCount(1); + await allDayBox(root).check({ force: true }); + await expect(allDayBox(root)).toBeChecked(); + await expect(root.locator('a-time-field[name="start-time"]')).toHaveCount(0); + await expect(root.locator('a-duration-field')).toHaveCount(0); + await untouchedLocker(staffPage); + }); + + test('lockers.allow_all_day false removes the All day checkbox', async ({ staffPage, audit }) => { + const root = await lockerForm(staffPage, audit, { 'app.lockers.allow_all_day': false }); + await expect(root.locator('mat-checkbox')).toHaveCount(0); + await expect(lockerStart(root)).toHaveCount(1); + await untouchedLocker(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.allow-time-changes.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.allow-time-changes.spec.ts new file mode 100644 index 00000000000..e3c66bef6b5 --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.allow-time-changes.spec.ts @@ -0,0 +1,337 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker form', () => { + for (const allowed of [true, false]) { + test('lockers.allow_time_changes ' + allowed + ' gates the All day option', async ({ staffPage, audit }) => { + const root = await lockerForm(staffPage, audit, { + 'app.lockers.allow_time_changes': allowed, 'app.lockers.allow_all_day': true, + }); + await expect(root.locator('mat-checkbox')).toHaveCount(allowed ? 1 : 0); + await expect(root.locator('a-date-field')).toBeVisible(); + await untouchedLocker(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.disabled-date-select.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.disabled-date-select.spec.ts new file mode 100644 index 00000000000..e90814819b7 --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.disabled-date-select.spec.ts @@ -0,0 +1,337 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker form', () => { + for (const disabled of [true, false]) { + test('lockers.disabled_date_select ' + disabled + ' controls the All day option beside the date', + async ({ staffPage, audit }) => { + const root = await lockerForm(staffPage, audit, { 'app.lockers.disabled_date_select': disabled }); + await expect(root.locator('mat-checkbox')).toHaveCount(disabled ? 0 : 1); + // Source only hides the checkbox; the date field itself stays (template :80-85). + await expect(root.locator('a-date-field button').first()).toBeVisible(); + await untouchedLocker(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.disabled-start-time.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.disabled-start-time.spec.ts new file mode 100644 index 00000000000..2d93b8658fe --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.disabled-start-time.spec.ts @@ -0,0 +1,340 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker form', () => { + for (const disabled of [true, false]) { + test('lockers.disabled_start_time ' + disabled + ' locks the start time', async ({ staffPage, audit }) => { + const root = await lockerForm(staffPage, audit, { 'app.lockers.disabled_start_time': disabled }); + // Tomorrow, so "no options left today" cannot disable the trigger on its own. + await pickDay(staffPage, root, 1); + if (disabled) await expect(lockerStart(root)).toBeDisabled(); + else { + await expect(lockerStart(root)).toBeEnabled(); + expect((await menuValues(staffPage, lockerStart(root), 'data-time')).length).toBeGreaterThan(0); + } + await untouchedLocker(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.hide-end-time.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.hide-end-time.spec.ts new file mode 100644 index 00000000000..013f6d20618 --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.hide-end-time.spec.ts @@ -0,0 +1,335 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker form', () => { + for (const hidden of [true, false]) { + test('lockers.hide_end_time ' + hidden + ' controls the end-time field', async ({ staffPage, audit }) => { + const root = await lockerForm(staffPage, audit, { 'app.lockers.hide_end_time': hidden }); + await expect(root.locator('a-duration-field')).toHaveCount(hidden ? 0 : 1); + await expect(lockerStart(root)).toHaveCount(1); + await untouchedLocker(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.max-duration.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.max-duration.spec.ts new file mode 100644 index 00000000000..b4013b89a61 --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.max-duration.spec.ts @@ -0,0 +1,347 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker form', () => { + for (const max of [120, 240]) { + test('lockers.max_duration ' + max + ' caps the hourly duration choices', async ({ staffPage, audit }) => { + const root = await lockerForm(staffPage, audit, { 'app.lockers.max_duration': max }); + const values = await menuValues(staffPage, root.locator('button[duration-field]'), 'data-duration'); + // The max caps stepped hours (min/step 60), while the locker form + // also supplies weekday/hour-dependent custom durations (:290-298). + // DurationField deliberately exempts custom options from max (:469). + // Assert the entire menu; no unexplained extra durations are ignored. + const custom = await root.evaluate((element) => { + const debug = window as Window & { ng?: { getComponent(element: Element): + { custom_durations(): number[] } | undefined } }; + return debug.ng?.getComponent(element)?.custom_durations(); + }); + expect(custom, 'locker component exposes its source-generated custom durations').toBeDefined(); + const hourly = Array.from({ length: max / 60 }, (_, index) => (index + 1) * 60); + expect(values.filter((value) => value <= max)).toEqual(hourly); + expect(values).toEqual([...new Set([...hourly, ...custom!])].sort((a, b) => a - b)); + await untouchedLocker(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.show-calendar-links.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.show-calendar-links.spec.ts new file mode 100644 index 00000000000..ec0ca9029fd --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.show-calendar-links.spec.ts @@ -0,0 +1,360 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker success calendar links', () => { + for (const enabled of [true, false]) { + test('lockers.show_calendar_links ' + enabled + ' on the locker success page', async ({ staffPage, audit }) => { + const fixture = await prepare(staffPage, audit, { ...LOCKER, 'app.lockers.show_calendar_links': enabled }); + const start = Math.floor(Date.now() / 1000) + 24 * 3600; + await staffPage.addInitScript((value) => sessionStorage.setItem('PLACEOS.last_booked_booking', value), + JSON.stringify({ + id: 'claude-next25-locker', booking_type: 'locker', title: 'E2E Synthetic Locker Booking', + asset_id: 'claude-next25-locker-1', asset_name: 'E2E Synthetic Locker 3', + booking_start: start, booking_end: start + 3600, zones: [], extension_data: {}, + })); + await staffPage.goto('/#/book/locker/success'); + await fixture.ready(); + const page = staffPage.locator('locker-flow-success'); + await expect(page).toHaveCount(1); + // The unstyled host contains an absolute-positioned panel; assert + // the rendered panel and seeded booking, not the host's empty box. + await expect(page.locator(':scope > div')).toBeVisible(); + await expect(page.getByRole('heading')).toBeVisible(); + await expect(page.getByRole('heading')).toContainText('E2E Synthetic Locker 3'); + for (const name of ['locker-outlook-link', 'locker-google-link', 'locker-ical-link']) { + const link = page.locator('a[name="' + name + '"]'); + await expect(link).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(link).toBeVisible(); + } + if (enabled) { + // Never clicked: target=_blank external calendar links. + await expect(page.locator('a[name="locker-outlook-link"]')).toHaveAttribute('href', /^https:\/\/outlook\./); + await expect(page.locator('a[name="locker-google-link"]')).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//); + await expect(page.locator('a[name="locker-ical-link"]')).toHaveAttribute('href', /^data:text\/calendar/); + } + await expect(page.locator('a[name="locker-confirm-continue"]')).toBeVisible(); + }); + } +}); diff --git a/apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts b/apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts new file mode 100644 index 00000000000..6a8402e4ae5 --- /dev/null +++ b/apps/workplace/e2e/settings/lockers/lockers.use-building-timezone.spec.ts @@ -0,0 +1,337 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-lockers-draft.spec.ts (accepted: wpa-next25-L1..L4 (+ reruns)); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 8 lockers.* keys. Routes: /#/book/locker/form, /#/book/locker/success. +// Nothing is submitted; the success page reads a browser-only sessionStorage seed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + + +// ------------------------------------------------------------- locker form +// Consumer: apps/workplace/src/app/book/locker-flow/locker-form-details.component.ts +// getters :214-253, template :80-140. allow_all_day = allow_time_changes && +// (lockers ?? bookings ?? true); disabled_date_select hides only the all-day +// checkbox (:86); disabled_start_time disables the start-time trigger (:116). +const LOCKER: Settings = { + ...COMMON, + 'app.features': ['lockers'], 'app.desks.use_assets': false, + 'app.lockers.default_duration': 60, + 'app.lockers.all_day_default': false, 'app.bookings.all_day_default': false, + 'app.lockers.allow_all_day': true, 'app.bookings.allow_all_day': null, + 'app.lockers.allow_time_changes': true, + 'app.lockers.disabled_date_select': false, 'app.lockers.disabled_start_time': false, + 'app.lockers.hide_end_time': false, + 'app.lockers.max_duration': 240, 'app.bookings.max_duration': null, + 'app.lockers.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.lockers.show_calendar_links': false, +}; +async function lockerForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...LOCKER, ...overrides }); + await page.goto('/#/book/locker/form'); + await fixture.ready(); + const root = page.locator('new-locker-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + return root; +} +/** open-locker-confirm only changes the view; it is never clicked. */ +async function untouchedLocker(page: Page) { + await expect(page.locator('button[name="open-locker-confirm"]')).toBeVisible(); + await expect(page.locator('locker-flow-confirm')).toHaveCount(0); + await expect(page.locator('locker-flow-success')).toHaveCount(0); +} +const allDayBox = (root: Locator) => root.locator('mat-checkbox input[type="checkbox"]'); +const lockerStart = (root: Locator) => root.locator('a-time-field[name="start-time"] button[time-field]'); + + +// locker-flow-success.component.ts:62-114,158-160; data from sessionStorage +// PLACEOS.last_booked_booking (booking-form.service.ts:119,227-231). + +test.describe('WPA next25 DRAFT: locker form', () => { + for (const enabled of [true, false]) { + test('lockers.use_building_timezone ' + enabled + ' gates the building-time label', async ({ staffPage, audit }) => { + await timezoneBuildings(staffPage, audit); + const root = await lockerForm(staffPage, audit, { 'app.lockers.use_building_timezone': enabled }); + expect(audit.fixtures['timezone buildings'] ?? 0).toBeGreaterThan(0); + await expect(lockerStart(root)).toBeVisible(); + await expect(lockerStart(root).locator('div.truncate.text-xs')).toHaveCount(enabled ? 1 : 0); + await untouchedLocker(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.allow-all-day.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.allow-all-day.spec.ts new file mode 100644 index 00000000000..e0c77d717c5 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.allow-all-day.spec.ts @@ -0,0 +1,385 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + + test('parking.allow_all_day true shows the All day checkbox, which hides the time fields', + async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.allow_all_day': true }); + const box = root.locator('mat-checkbox input[type="checkbox"]'); + await expect(box).toHaveCount(1); + await expect(box).not.toBeChecked(); + await expect(root.locator('a-time-field[name="start-time"]')).toHaveCount(1); + await box.check({ force: true }); + await expect(box).toBeChecked(); + await expect(root.locator('a-time-field[name="start-time"]')).toHaveCount(0); + await untouchedParking(staffPage); + }); + + test('parking.allow_all_day false (bookings false) removes the All day checkbox', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.allow_all_day': false }); + await expect(root.locator('mat-checkbox')).toHaveCount(0); + await expect(root.locator('a-time-field[name="start-time"] button[time-field]')).toBeVisible(); + await untouchedParking(staffPage); + }); + + test('parking.allow_all_day false alone keeps the checkbox while bookings.allow_all_day is unset', + async ({ staffPage, audit }) => { + // Source: (parking || bookings) ?? true -> (false || null) ?? true === true. + const root = await parkingForm(staffPage, audit, { + 'app.parking.allow_all_day': false, 'app.bookings.allow_all_day': null, + }); + await expect(root.locator('mat-checkbox')).toHaveCount(1); + await untouchedParking(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.available-period.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.available-period.spec.ts new file mode 100644 index 00000000000..2b7ec0c81f0 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.available-period.spec.ts @@ -0,0 +1,375 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const { day, month } = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); + return { day: date.getDate(), month: date.toLocaleString('en-US', { month: 'long', year: 'numeric' }) }; + }, offset); + await next.click(); + // The grid redraws after the click; until then the old month (whose same + // day number is in the past) is still in the DOM. The heading is the only + // unnamed button (date-calendar.component.ts: display_date | date:'LLLL yyyy'). + await expect(picker.locator('button:not([name])').first()).toHaveText(new RegExp('^\\s*' + month + '\\s*$')); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + + test('parking.available_period 7 allows today+7 and blocks today+8', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.available_period': 7 }); + expect(await dayEnabled(staffPage, root, 7)).toBe(true); + expect(await dayEnabled(staffPage, root, 8)).toBe(false); + await untouchedParking(staffPage); + }); + + test('parking.available_period 21 extends the window to today+21', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.available_period': 21 }); + expect(await dayEnabled(staffPage, root, 8)).toBe(true); + expect(await dayEnabled(staffPage, root, 21)).toBe(true); + expect(await dayEnabled(staffPage, root, 22)).toBe(false); + await untouchedParking(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.bookable-hours.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.bookable-hours.spec.ts new file mode 100644 index 00000000000..aa73c198895 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.bookable-hours.spec.ts @@ -0,0 +1,382 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + for (const [hours, first, last] of [ + [{ start: 9, end: 17 }, HH(9), HH(16, 30)], + [{ start: 6, end: 11 }, HH(6), HH(10, 30)], + ] as const) { + test('parking.bookable_hours ' + hours.start + '-' + hours.end + ' bounds tomorrow\'s start times', + async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.bookable_hours': hours }); + await pickDay(staffPage, root, 1); + const times = await startTimes(staffPage, root); + // Last start leaves the 30-minute default minimum (time-field :504-510). + expect(Math.min(...times)).toBe(first); + expect(Math.max(...times)).toBe(last); + await untouchedParking(staffPage); + }); + } + test('parking.bookable_hours unset offers the whole of tomorrow', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.bookable_hours': null }); + await pickDay(staffPage, root, 1); + const times = await startTimes(staffPage, root); + // time-field.component.ts:351-375 also lists the field's current time when it + // is off the 15-minute step; only the regular options define the window. + const stepped = times.filter((time) => time % 15 === 0); + expect(stepped.length).toBeGreaterThan(0); + expect(Math.min(...stepped)).toBe(0); + expect(Math.max(...stepped)).toBe(HH(23, 45)); + await untouchedParking(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.can-book-for-others.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.can-book-for-others.spec.ts new file mode 100644 index 00000000000..4572fa16aad --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.can-book-for-others.spec.ts @@ -0,0 +1,363 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + for (const enabled of [true, false]) { + test('parking.can_book_for_others ' + enabled + ' controls the host selector', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.can_book_for_others': enabled }); + await expect(root.locator('host-select-field')).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(root.locator('host-select-field')).toBeVisible(); + await untouchedParking(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.custom-duration-options.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.custom-duration-options.spec.ts new file mode 100644 index 00000000000..96ce26cdb5a --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.custom-duration-options.spec.ts @@ -0,0 +1,373 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + + test('parking.custom_duration_options adds out-of-step durations', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.custom_duration_options': [20, 600] }); + const values = await durations(staffPage, root); + // Custom ids skip the min/max filter (duration-field :464-471); 600 > default max 480. + expect(values).toEqual(expect.arrayContaining([20, 30, 480, 600])); + await untouchedParking(staffPage); + }); + + test('parking.custom_duration_options empty keeps the stepped 30-480 list', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.custom_duration_options': [] }); + const values = await durations(staffPage, root); + expect(values).not.toContain(20); + expect(values).not.toContain(600); + expect(Math.min(...values)).toBe(30); + expect(Math.max(...values)).toBe(480); + await untouchedParking(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.max-duration.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.max-duration.spec.ts new file mode 100644 index 00000000000..1d6bae63d5e --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.max-duration.spec.ts @@ -0,0 +1,364 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + for (const max of [120, 480]) { + test('parking.max_duration ' + max + ' caps the duration choices', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { 'app.parking.max_duration': max }); + const values = await durations(staffPage, root); + expect(Math.max(...values)).toBe(max); + expect(Math.min(...values)).toBe(30); + await untouchedParking(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.min-duration.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.min-duration.spec.ts new file mode 100644 index 00000000000..1d51e9941d7 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.min-duration.spec.ts @@ -0,0 +1,367 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + for (const [min, last] of [[30, HH(16, 30)], [120, HH(15)]] as const) { + test('parking.min_duration ' + min + ' moves the latest start within 9-17 hours', async ({ staffPage, audit }) => { + const root = await parkingForm(staffPage, audit, { + 'app.parking.min_duration': min, 'app.parking.bookable_hours': { start: 9, end: 17 }, + }); + await pickDay(staffPage, root, 1); + const times = await startTimes(staffPage, root); + expect(Math.min(...times)).toBe(HH(9)); + expect(Math.max(...times)).toBe(last); + await untouchedParking(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.use-building-timezone.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.use-building-timezone.spec.ts new file mode 100644 index 00000000000..3cbff68ac57 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.use-building-timezone.spec.ts @@ -0,0 +1,367 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking form', () => { + for (const enabled of [true, false]) { + test('parking.use_building_timezone ' + enabled + ' gates the building-time label', async ({ staffPage, audit }) => { + await timezoneBuildings(staffPage, audit); + const root = await parkingForm(staffPage, audit, { 'app.parking.use_building_timezone': enabled }); + expect(audit.fixtures['timezone buildings'] ?? 0).toBeGreaterThan(0); + const secondary = root.locator('a-time-field[name="start-time"] button[time-field] div.truncate.text-xs'); + await expect(root.locator('a-time-field[name="start-time"] button[time-field]')).toBeVisible(); + await expect(secondary).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(secondary).toContainText(/GMT-10|HST|-10/); + await untouchedParking(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.allow-recurrence.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.allow-recurrence.spec.ts new file mode 100644 index 00000000000..4425591ac4b --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.allow-recurrence.spec.ts @@ -0,0 +1,267 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + for (const enabled of [true, false]) { + test('parking.allow_recurrence ' + enabled + ' controls the Single Day / Multi-day choice', + async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.allow_recurrence': enabled }); + await expect(root.getByText('Single Day', { exact: true })).toHaveCount(enabled ? 1 : 0); + await expect(root.getByText('Multi-day', { exact: true })).toHaveCount(enabled ? 1 : 0); + await expect(root.locator('a-date-field')).toBeVisible(); + if (enabled) { + // Local state only: setBookingFrequency('daily') reveals the week choices + // (available_period 14 -> max_weeks 2). + await root.getByText('Multi-day', { exact: true }).click(); + await expect(root.getByRole('button', { name: '1 week', exact: true })).toBeVisible(); + await expect(root.getByRole('button', { name: '2 weeks', exact: true })).toBeVisible(); + } + await untouched(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.hide-availability-counter.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.hide-availability-counter.spec.ts new file mode 100644 index 00000000000..96bca02a1cb --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.hide-availability-counter.spec.ts @@ -0,0 +1,263 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + for (const hidden of [false, true]) { + test('parking.hide_availability_counter ' + hidden + ' controls the location capacity counter', + async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.hide_availability_counter': hidden }); + await expect(root.getByText(/Location Preference/i)).toBeVisible(); + // Spaces are served empty, so the shown counter settles at 0 of 0 with no lookup. + const counter = root.locator('icon').filter({ hasText: /^\s*car_lock\s*$/ }); + await expect(counter).toHaveCount(hidden ? 0 : 1); + await expect(root.getByText('Checking...', { exact: true })).toHaveCount(0); + if (!hidden) await expect(counter).toBeVisible(); + await untouched(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.hide-custom-shift.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.hide-custom-shift.spec.ts new file mode 100644 index 00000000000..02ed57107ae --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.hide-custom-shift.spec.ts @@ -0,0 +1,271 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + for (const hidden of [false, true]) { + test('parking.hide_custom_shift ' + hidden + ' controls the Custom Hours shift option', + async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.hide_custom_shift': hidden }); + const labels = await optionLabels(staffPage, labelledSelect(root, 'Shift Type')); + expect(labels).toHaveLength(hidden ? 2 : 3); + expect(labels.some((label) => label.startsWith('E2E Day Shift'))).toBe(true); + expect(labels.some((label) => label.startsWith('E2E Late Shift'))).toBe(true); + expect(labels.some((label) => label.startsWith('Custom Hours'))).toBe(!hidden); + await untouched(staffPage); + }); + } + test('parking.hide_custom_shift true with no presets forces the All Day window', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { + 'app.parking.hide_custom_shift': true, 'app.parking.request_shift_options': [], + }); + // is_all_day_forced (component :1478-1480) replaces the select with fixed text. + await expect(labelledSelect(root, 'Shift Type')).toHaveCount(0); + await expect(root.getByText(/^\s*All Day\s*$/)).toBeVisible(); + await untouched(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.request-shift-options.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.request-shift-options.spec.ts new file mode 100644 index 00000000000..5055f1d0b54 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.request-shift-options.spec.ts @@ -0,0 +1,274 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + + test('parking.request_shift_options supplies selectable presets', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.request_shift_options': PRESETS }); + const select = labelledSelect(root, 'Shift Type'); + const labels = await optionLabels(staffPage, select); + expect(labels).toEqual([ + expect.stringMatching(/^E2E Day Shift \(08:00 - 17:00\)$/), + expect.stringMatching(/^E2E Late Shift \(10:00 - 18:00\)$/), + expect.stringMatching(/^Custom Hours/), + ]); + await select.click(); + await staffPage.getByRole('option', { name: /E2E Late Shift/ }).click(); + await expect(select).toContainText('E2E Late Shift'); + await untouched(staffPage); + }); + + test('parking.request_shift_options empty leaves only editable custom times', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.request_shift_options': [] }); + await expect(labelledSelect(root, 'Shift Type')).toHaveCount(0); + await expect(labelledSelect(root, 'Start time')).toBeVisible(); + await expect(labelledSelect(root, 'End time')).toBeVisible(); + await expect(root.getByText('E2E Day Shift', { exact: false })).toHaveCount(0); + await untouched(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.request-space-restrictions.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.request-space-restrictions.spec.ts new file mode 100644 index 00000000000..28ff7e40f57 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.request-space-restrictions.spec.ts @@ -0,0 +1,267 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + + test('parking.request_space_restrictions renders a single-choice radio group', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.request_space_restrictions': RESTRICTIONS }); + const group = root.locator('mat-radio-group[aria-labelledby="parking-space-restrictions-label"]'); + await expect(group.getByRole('radio')).toHaveCount(2); + await group.getByRole('radio', { name: 'E2E Accessible Bay' }).check(); + await expect(group.getByRole('radio', { name: 'E2E Accessible Bay' })).toBeChecked(); + await expect(group.getByRole('radio', { name: 'E2E Standard Bay' })).not.toBeChecked(); + await untouched(staffPage); + }); + + test('parking.request_space_restrictions empty renders no radio group', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.request_space_restrictions': [] }); + await expect(root.locator('mat-radio-group[aria-labelledby="parking-space-restrictions-label"]')).toHaveCount(0); + await expect(root.locator('#parking-space-restrictions-label')).toHaveCount(0); + await untouched(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.request-types.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.request-types.spec.ts new file mode 100644 index 00000000000..f77ecc7047c --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.request-types.spec.ts @@ -0,0 +1,277 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + + test('parking.request_types replaces the default request types', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { + 'app.parking.request_types': [ + { id: 'standard', name: 'E2E Commuter Request', description: 'E2E commuter description', badge: 'E2E Badge' }, + { id: 'claude-night', name: 'E2E Night Request' }, + { id: 'claude-hidden', name: 'E2E Group-only Request', groups: ['claude-no-such-group'] }, + ], + }); + await expect(typeCard(root, 'E2E Commuter Request')).toContainText('E2E commuter description'); + await expect(typeCard(root, 'E2E Commuter Request')).toContainText('E2E Badge'); + await expect(typeCard(root, 'E2E Night Request')).toHaveCount(1); + // groups the user lacks filter the type out (component :1608-1612). + await expect(typeCard(root, 'E2E Group-only Request')).toHaveCount(0); + for (const name of DEFAULT_TYPES) await expect(root.getByText(name, { exact: true })).toHaveCount(0); + await typeCard(root, 'E2E Night Request').click(); + await expect(typeCard(root, 'E2E Night Request')).toHaveClass(/\bborder-info\b/); + await expect(typeCard(root, 'E2E Commuter Request')).not.toHaveClass(/\bborder-info\b/); + await untouched(staffPage); + }); + + test('parking.request_types unset shows the three default request types', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.request_types': null }); + for (const name of DEFAULT_TYPES) await expect(typeCard(root, name)).toHaveCount(1); + await expect(root.getByText('E2E Commuter Request', { exact: true })).toHaveCount(0); + await untouched(staffPage); + }); +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.require-plate-number.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.require-plate-number.spec.ts new file mode 100644 index 00000000000..373ef327d23 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.require-plate-number.spec.ts @@ -0,0 +1,261 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + for (const required of [true, false]) { + test('parking.require_plate_number ' + required + ' controls the Registration required marker', + async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.require_plate_number': required }); + const label = root.locator('label').filter({ hasText: /^\s*Registration\s*\*?\s*$/ }); + await expect(label).toHaveCount(1); + await expect(label.locator('span')).toHaveCount(required ? 1 : 0); + if (required) await expect(label.locator('span')).toHaveText('*'); + await untouched(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.require-space-restriction.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.require-space-restriction.spec.ts new file mode 100644 index 00000000000..31979b3a8b1 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.require-space-restriction.spec.ts @@ -0,0 +1,269 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + for (const required of [true, false]) { + test('parking.require_space_restriction ' + required + ' marks the restriction group ' + + (required ? 'required and invalid until a bay is chosen' : 'optional and valid without a choice'), + async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { + 'app.parking.require_space_restriction': required, + 'app.parking.request_space_restrictions': RESTRICTIONS, + }); + const group = root.locator('mat-radio-group[aria-labelledby="parking-space-restrictions-label"]'); + await expect(root.locator('#parking-space-restrictions-label span[aria-hidden="true"]')) + .toHaveCount(required ? 1 : 0); + await expect(group).toHaveAttribute('aria-required', String(required)); + // booking-form.model.ts:291-296 validates only when required. + await expect(group).toHaveAttribute('aria-invalid', String(required)); + await group.getByRole('radio', { name: 'E2E Standard Bay' }).check(); + await expect(group).toHaveAttribute('aria-invalid', 'false'); + await untouched(staffPage); + }); + } +}); diff --git a/apps/workplace/e2e/settings/parking-request/parking-request.vehicle-types.spec.ts b/apps/workplace/e2e/settings/parking-request/parking-request.vehicle-types.spec.ts new file mode 100644 index 00000000000..d8d1d95b0cb --- /dev/null +++ b/apps/workplace/e2e/settings/parking-request/parking-request.vehicle-types.spec.ts @@ -0,0 +1,269 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-request-draft.spec.ts (accepted: wpa-next25-P1/P5/P6/P7); only this setting's tests. + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 13 parking REQUEST form keys (route /#/book/parking-request/form). +// Consumer: apps/workplace/src/app/book/parking-request-flow/ +// parking-request-form-details.component.ts:1343-1403, 1600-1617 (signals), +// template :160-1180; parking-request-form.component.ts:153-155 (notes HTML). +// Nothing is submitted: the submit button, plate-number remove buttons and +// favourite icons are never clicked. Clicks only change local form state. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // ParkingService.loadBookings: no existing parking booking for today. + await getOnly(page, audit, 'parking bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'parking', () => []); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + +// ------------------------------------------------------------- request form +const ROUTE = '/#/book/parking-request/form'; +const PRESETS = [ + { id: 'claude-day', name: 'E2E Day Shift', start_time: 480, end_time: 1020 }, + { id: 'claude-late', name: 'E2E Late Shift', start_time: 600, end_time: 1080 }, +]; +const REQUEST: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, + 'app.parking.allow_recurrence': false, + 'app.parking.available_period': 14, + 'app.parking.use_building_timezone': false, 'app.bookings.use_building_timezone': false, + 'app.parking.hidden_buildings': [], + 'app.parking.request_types': null, + 'app.parking.request_shift_options': PRESETS, + 'app.parking.hide_custom_shift': false, + 'app.parking.hide_availability_counter': true, + 'app.parking.hide_prefer_toggle': false, + 'app.parking.default_location_from_desk_booking': false, + 'app.parking.require_plate_number': false, + 'app.parking.require_space_restriction': false, + 'app.parking.request_space_restrictions': [], + 'app.parking.extra_space_restrictions': [], + 'app.parking.approver_groups': [], + 'app.parking.auto_approved_groups': [], + 'app.parking.vehicle_types': null, + 'app.parking.request_submission_notes_html': '', + 'app.parking.can_book_for_anyone': false, 'app.bookings.can_book_for_anyone': null, + 'app.parking.can_book_for_others': false, 'app.bookings.can_book_for_others': null, +}; +const DEFAULT_TYPES = ['Standard Parking Request', 'P2 Special Needs Request', 'After-hours Private Access Request']; +const DEFAULT_VEHICLES = ['Car', 'Bike', 'Van', 'Truck', 'Other']; +const RESTRICTIONS = [ + { id: 'claude-standard-bay', name: 'E2E Standard Bay' }, + { id: 'claude-accessible-bay', name: 'E2E Accessible Bay' }, +]; + +async function openRequest(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...REQUEST, ...overrides }); + await page.goto(ROUTE); + await fixture.ready(); + const root = page.locator('parking-request-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + return root; +} +/** Nothing was submitted: still on the form, success view absent. */ +async function untouched(page: Page) { + await expect(page.locator('parking-request-form button[btn]').filter({ has: page.locator('icon', { hasText: /^\s*send\s*$/ }) })).toBeVisible(); + await expect(page.locator('parking-request-success')).toHaveCount(0); +} +// English-label exceptions (APP.locale pinned to en-AU; shared/assets/locale/en-AU.json): +// these mat-selects have no name or id. Scope through the exact sibling label. +function labelledSelect(root: Locator, label: string) { + return root.locator('label').filter({ hasText: new RegExp('^\\s*' + label + '\\s*\\*?\\s*$') }) + .locator('..').locator('mat-select'); +} +function approverSelect(root: Locator) { + return root.locator('h3').filter({ hasText: 'Approver Group' }).locator('xpath=..').locator('mat-select'); +} +function typeCard(root: Locator, name: string) { + return root.locator('div.cursor-pointer').filter({ has: root.page().getByText(name, { exact: true }) }); +} +/** Opens a mat-select, returns option labels, closes it without choosing. */ +async function optionLabels(page: Page, select: Locator) { + await expect(select).toHaveCount(1); + await select.click(); + const options = page.getByRole('option'); + await expect(options.first()).toBeVisible(); + const labels = (await options.allInnerTexts()).map((text) => text.replace(/\s+/g, ' ').trim()); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return labels; +} + +test.describe('WPA next25 DRAFT: parking request form', () => { + + test('parking.vehicle_types replaces the vehicle choices', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { + 'app.parking.vehicle_types': [ + { id: 'claude-ev', name: 'E2E Electric' }, + { id: '', name: 'E2E Missing Id' }, + { id: 'claude-moto', name: 'E2E Motorbike' }, + ], + }); + // _normaliseOptions drops entries without an id (component :2358-2367). + expect(await optionLabels(staffPage, labelledSelect(root, 'Vehicle Type'))).toEqual(['E2E Electric', 'E2E Motorbike']); + await untouched(staffPage); + }); + + test('parking.vehicle_types unset offers the five default vehicles', async ({ staffPage, audit }) => { + const root = await openRequest(staffPage, audit, { 'app.parking.vehicle_types': null }); + expect(await optionLabels(staffPage, labelledSelect(root, 'Vehicle Type'))).toEqual(DEFAULT_VEHICLES); + await untouched(staffPage); + }); +}); diff --git a/apps/workplace/playwright.config.ts b/apps/workplace/playwright.config.ts index d497f2671fe..a113ed2f848 100644 --- a/apps/workplace/playwright.config.ts +++ b/apps/workplace/playwright.config.ts @@ -64,7 +64,11 @@ export default defineConfig({ // so the only thing to wait on is the stack itself. { name: 'local', - testDir: './e2e/local', + testDir: './e2e', + testMatch: [ + '**/local/**/*.@(spec|test).?(c|m)[jt]s?(x)', + '**/settings/**/*.@(spec|test).?(c|m)[jt]s?(x)', + ], dependencies: ['preflight'], use: { ...devices['Desktop Chrome'] }, }, @@ -84,7 +88,7 @@ export default defineConfig({ { name: 'mock', testDir: './e2e', - testIgnore: ['**/local/**'], + testIgnore: ['**/local/**', '**/settings/**'], use: { ...devices['Desktop Chrome'] }, }, ], From 4074e934ad80207d025e2b1052becc13781d3bd1 Mon Sep 17 00:00:00 2001 From: sharmilaseenivasan17 Date: Tue, 20 Oct 2026 17:12:00 +1100 Subject: [PATCH 002/105] test(e2e): expand Workplace App settings coverage (11 settings, 21 scenarios) --- .../desk-booking.hide-checkin.spec.ts | 263 ++++++++++++ .../general/general.css-variables.spec.ts | 393 ++++++++++++++++++ .../general/general.default-route.spec.ts | 360 ++++++++++++++++ .../general.group-events-calendar.spec.ts | 199 +++++++++ .../settings/general/general.locales.spec.ts | 385 +++++++++++++++++ .../general/general.menu-embeds.spec.ts | 390 +++++++++++++++++ .../e2e/settings/general/general.name.spec.ts | 360 ++++++++++++++++ .../general/general.short-name.spec.ts | 361 ++++++++++++++++ .../parking-booking.show-waitlist.spec.ts | 365 ++++++++++++++++ .../room-booking.hide-nearby-desks.spec.ts | 195 +++++++++ .../schedule.visitors-allow-editing.spec.ts | 258 ++++++++++++ 11 files changed, 3529 insertions(+) create mode 100644 apps/workplace/e2e/settings/desk-booking/desk-booking.hide-checkin.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.css-variables.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.default-route.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.group-events-calendar.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.locales.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.menu-embeds.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.name.spec.ts create mode 100644 apps/workplace/e2e/settings/general/general.short-name.spec.ts create mode 100644 apps/workplace/e2e/settings/parking-booking/parking-booking.show-waitlist.spec.ts create mode 100644 apps/workplace/e2e/settings/room-booking/room-booking.hide-nearby-desks.spec.ts create mode 100644 apps/workplace/e2e/settings/schedule/schedule.visitors-allow-editing.spec.ts diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.hide-checkin.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.hide-checkin.spec.ts new file mode 100644 index 00000000000..f650862bcab --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.hide-checkin.spec.ts @@ -0,0 +1,263 @@ +// Phase 2 migration: historically accepted (Batch 4, 2 passed); relocated runtime validation PENDING. +// Covered active keys: bookings.hide_checkin +// Read-only: the desk booking is a browser-only GET fixture; check-in is never clicked. +// Its conditional local-midnight skip is preserved, so a run between 22:55 and 24:00 +// runner-local time reports both scenarios as skipped, not passed. +// Historical evidence: apps/workplace/reports/wpa-controlled-execution-20261019-150825-batch4/results.json +// Source: apps/workplace/e2e/local/settings-blocked/desk-booking/desk-booking.hide-checkin.spec.ts +// (sha256 7000549a06df16039dcf1dd2344ae1f8045ed24db0de5fd2dbb6482af0d1736c); unchanged except import paths. The parked original is left in place. +import type { ConsoleMessage, Page, Route, WebSocketRoute } from '@playwright/test'; +import { SchedulePage } from '../../../../../e2e/support/bookings/schedule.page'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. Source: desk-flow-form.show_reserved_desk_overlay; +// BookingFormService.settingForType / assignedResourceBooking; +// BookingDetailsModal.can_checkin; GroupEventsStateService.calendar. +// All fixtures below are browser-only GET responses. No fixture is saved; +// every non-GET request still reaches the read-only guard. +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const ID = 9100047; +const PARKING_CATEGORY = 'e2e-draft-parking-category'; +const PARKING_TYPES = ['e2e-draft-parking-spaces-type', 'e2e-draft-parking-users-type']; +type Identity = { id: string; email: string; name: string }; +type DebugWindow = Window & { ng?: { getComponent(element: Element): unknown }; setting?: (key: string) => unknown }; + +// ---------------------------------------------------------------- safety audit +// Adapted from the passing Batch 1 drafts. The desk form (picker not opened), +// your-bookings and group-events make no getModule/bind/execute calls with chat +// off and no location_services module bound, so NO PlaceOS WebSocket is expected. +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; + +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** Only the Angular dev server's Vite client socket (vite client.mjs:856-865, reload on close :993-998). */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +function isLocalControlSocket(url: URL) { + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.protocol === protocol && url.host === app.host && + url.pathname === '/api/engine/v2/systems/control'; +} + +/** ErrorHandler logs console.error('ERROR', e); Angular runtime errors log NG0xxx. */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], page_errors: [], + app_errors: [], monitoring_errors: [], fixtures: {} }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + const closeSocket = async (socket: WebSocketRoute, url: string) => { + try { + await socket.close(); + } catch (error) { + audit.monitoring_errors.push(`WebSocket close failed for ${url}: ${String(error)}`); + } + }; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = redact(url); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: socket_url, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (isLocalControlSocket(url)) { + audit.blocked_control_attempts.push({ + url: socket_url, + classification: 'known Workplace shell initialization; intercepted and closed before server connection', + }); + } else { + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control' : 'other'; + audit.websocket_attempts.push({ url: socket_url, classification: `UNEXPECTED ${kind}; these pages are HTTP-only` }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed for ${socket_url}: ${String(error)}`); + } + closing.push(closeSocket(socket, socket_url)); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('readonly-data-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +/** A named, counted, GET-only browser response. Every other method reaches the read-only guard. */ +async function stubGet(page: Page, audit: SafetyAudit, name: string, + match: (url: URL) => boolean, body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(`${name} fixture: ${String(error)}`); + await route.fallback().catch(() => undefined); + } + }); +} + +async function preparePage(page: Page, overrides: Record, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + let identity: Promise | undefined; + page.on('response', (response) => { + if (response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') identity = response.json(); + }); + // BookingFormService.loadForm/newForm -> CalendarService.loadCalendars reads + // the calendar provider (/api/staff/v1/calendars). Keep it local and empty. + await stubGet(page, audit, 'calendar provider list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + const settings = { ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.use_region': false, + 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], + // Keeps any schedule event listing on PlaceOS bookings, never staff-api /events. + 'app.events.use_bookings': true, + ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-US')); + return { + user: async () => { + if (!identity) throw new Error('the app must establish its existing staff identity before loading fixtures'); + return identity; + }, + ready: async () => { + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => (window as DebugWindow).setting?.(key), key)).toEqual(value); + } + }, + }; +} + +for (const hidden of [false, true]) { + test(`WPA draft: bookings.hide_checkin ${hidden} gates the control on a synthetic owned desk booking`, async ({ staffPage, safetyAudit }) => { + const fixture = await preparePage(staffPage, { + 'app.features': ['desks'], 'app.bookings.hide_checkin': hidden, + 'app.desks.hide_checkin': false, 'app.desk.hide_checkin': false, + 'app.desk.auto_checkin': false, + }, safetyAudit); + // Every booking-card injects ParkingService, whose constructor effects + // resolve the hidden _PARKING_ category and _PARKING_SPACES_/_PARKING_USERS_ + // types (parking-assets.fn.ts). Each create (POST) or move (PUT) runs only + // when a GET finds nothing or a type has the wrong category; the local + // backend is known to lack _PARKING_SPACES_ (Batch 8 blocker). Answer + // those GETs so nothing is created, and list no parking assets. + await stubGet(staffPage, safetyAudit, 'parking hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', + () => [{ id: PARKING_CATEGORY, name: '_PARKING_', hidden: true }]); + await stubGet(staffPage, safetyAudit, 'parking types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('category_id') === PARKING_CATEGORY && url.searchParams.get('limit') === '500', + () => [ + { id: PARKING_TYPES[0], name: '_PARKING_SPACES_', category_id: PARKING_CATEGORY, brand: 'PlaceOS' }, + { id: PARKING_TYPES[1], name: '_PARKING_USERS_', category_id: PARKING_CATEGORY, brand: 'PlaceOS' }, + ]); + await stubGet(staffPage, safetyAudit, 'parking assets', (url) => url.pathname === '/api/engine/v2/assets' && + PARKING_TYPES.includes(url.searchParams.get('type_id') ?? '') && + url.searchParams.has('zone_id') && url.searchParams.get('limit') === '500', () => []); + // Freeze Date.now (timers keep running) so the near-start window holds. + await staffPage.clock.install({ time: new Date() }); + await staffPage.clock.setFixedTime(new Date()); + const now = Math.floor(Date.now() / 1000); + // The schedule lists one local day; a booking crossing midnight would be + // outside it, so that window is a precondition, not a product result. + const end_of_day = new Date(); end_of_day.setHours(23, 59, 59, 999); + test.skip(now + 3900 > end_of_day.valueOf() / 1000, 'synthetic booking would cross local midnight'); + const booking = async () => { + const user = await fixture.user(); + return { + id: ID, booking_type: 'desk', type: 'desk', title: 'Draft check-in visibility', + asset_id: 'e2e-draft-readonly-desk', asset_name: 'Draft check-in desk', + booking_start: now + 300, booking_end: now + 3900, + user_id: user.id, user_email: user.email, user_name: user.name, + booked_by_id: user.id, booked_by_email: user.email, + approved: true, checked_in: false, rejected: false, deleted: false, + zones: [], extension_data: {}, + }; + }; + // ScheduleStateService: GET /api/staff/v1/bookings?period_start&period_end&type=desk&... (raw array). + // The details modal reuses the card's booking and does not refetch it. + await stubGet(staffPage, safetyAudit, 'desk booking listing', (url) => + url.pathname === '/api/staff/v1/bookings' && url.searchParams.get('type') === 'desk', async (url) => { + const start = Number(url.searchParams.get('period_start')); + const end = Number(url.searchParams.get('period_end')); + if (!(start > 0 && end > start)) throw new Error('listing must carry a valid period'); + const data = await booking(); + return data.booking_start < end && data.booking_end > start ? [data] : []; + }); + // SchedulePage.open assumes visitors; use the existing desk filter and + // inherited card/modal selectors without enabling unrelated flows. + await staffPage.goto('/#/your-bookings?view=list'); + const schedule = new SchedulePage(staffPage); + await fixture.ready(); + await expect(schedule.card(ID)).toBeVisible(); + expect(safetyAudit.fixtures['desk booking listing'] ?? 0).toBeGreaterThan(0); + // A type lookup can come from ParkingService._loadUsers alone; + // _loadSpaces only runs when the active organisation has parking levels. + // This proves a bootstrap lookup used the stub, not that spaces exist. + await expect.poll(() => safetyAudit.fixtures['parking types'] ?? 0, + { message: 'ParkingService must resolve at least one type from the GET fixture' }).toBeGreaterThan(0); + const modal = await schedule.openDetails(ID); + await expect(modal).toContainText('Draft check-in visibility'); + const control = schedule.checkInButton(modal); + if (hidden) await expect(control).toHaveCount(0); + else await expect(control).toBeVisible(); + await expect(modal.locator('status-pill')).toBeVisible(); + // Never click check-in, edit, cancel, overflow actions or map controls. + await staffPage.keyboard.press('Escape'); + await expect(modal).toHaveCount(0); + }); +} diff --git a/apps/workplace/e2e/settings/general/general.css-variables.spec.ts b/apps/workplace/e2e/settings/general/general.css-variables.spec.ts new file mode 100644 index 00000000000..bbc905594c2 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.css-variables.spec.ts @@ -0,0 +1,393 @@ +// Phase 2 migration: historically accepted; relocated runtime validation PENDING. +// Covered active keys: css_variables +// Scenario owners: S61, S62. Shared scenarios are registered once. +// Historical evidence: reports/wpa-settings-reference/preparation/run35-observed-results.json; reports/wpa-settings-reference/preparation/run35-followup-verified/decoded.json +import type { Browser as MigrationBrowser, BrowserContext as MigrationContext, BrowserContextOptions as MigrationContextOptions, Page as MigrationPage, TestInfo as MigrationTestInfo } from '@playwright/test'; +import { expect as migrationExpect, test as migrationBase } from '../../../../../e2e/support/fixtures'; +import { APP_URL as MIGRATION_APP_URL, BACKEND_URL as MIGRATION_BACKEND_URL, assertLocalOnly as migrationAssertLocalOnly, staffEmail as migrationStaffEmail } from '../../../../../e2e/support/env'; +import { readOnlyTest as migrationReadOnlyBase } from '../../local/wpa-settings-readonly-legacy.fixture'; +import type { BrowserContext } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { BACKEND_URL } from '../../../../../e2e/support/env'; +import { assertLocalOnly } from '../../../../../e2e/support/env'; +import { staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; + +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guards. +const HIDDEN_CATEGORIES = [ + { id: 'pr2-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr2-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr2-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr2-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr2-hidden-catering': [], 'pr2-hidden-desks': ['_DESKS_'], + 'pr2-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr2-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr2-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr2-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +const MIGRATION_ALLOW_CLASH_QUERY = false; +// Test-local audit only: no shared fixtures or existing specs are changed. +type MigrationAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + readonly_queries: { method: string; path: string }[]; + staff_identities: { email: string; privileged: boolean }[]; +}; +let migrationActive: { + audit: MigrationAudit; + register(context: MigrationContext): Promise; +} | undefined; + +async function migrationNewContext(browser: MigrationBrowser, options: MigrationContextOptions = {}) { + if (!migrationActive) throw new Error('Migration audit must be active before context creation'); + const context = await browser.newContext({ ...options, serviceWorkers: 'block' }); + await migrationActive.register(context); + return context; +} + +const migrationAuditFixture: { migrationAudit: [ + (args: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => Promise, + { auto: true; timeout: number }, +] } = { + migrationAudit: [async ({ staffPage }: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => { + migrationAssertLocalOnly(MIGRATION_APP_URL, MIGRATION_BACKEND_URL); + const audit: MigrationAudit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + readonly_queries: [], staff_identities: [], + }; + const contexts: MigrationContext[] = []; + const closing: Promise[] = []; + const identities: Promise[] = []; + const navigated = new Set(); + const identified = new Set(); + const origins = new Set([new URL(MIGRATION_APP_URL).origin, new URL(MIGRATION_BACKEND_URL).origin]); + const register = async (context: MigrationContext) => { + contexts.push(context); + const observe = (page: MigrationPage) => { + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame() && frame.url().startsWith(new URL(MIGRATION_APP_URL).origin + '/')) navigated.add(context); + }); + page.on('pageerror', (error) => audit.page_errors.push(String(error))); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) audit.app_errors.push(message.text().slice(0, 500)); + }); + page.on('response', (response) => { + const url = new URL(response.url()); + if (response.request().method() !== 'GET' || !origins.has(url.origin) || url.pathname !== '/api/engine/v2/users/current') return; + identities.push((async () => { + migrationExpect(response.ok(), 'staff identity GET must succeed').toBe(true); + const user = await response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + audit.staff_identities.push(identity); + migrationExpect(identity.email).toBe(migrationStaffEmail(info.parallelIndex).toLowerCase()); + migrationExpect(identity.privileged, 'staff-only scenario').toBe(false); + identified.add(context); + })().catch((error: unknown) => { audit.monitoring_errors.push(`Staff identity: ${String(error)}`); })); + }); + }; + for (const page of context.pages()) observe(page); + context.on('page', observe); + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const read = ['GET', 'HEAD', 'OPTIONS'].includes(request.method()); + // findBookingClashes is a source-supported availability query, + // not booking creation (libs/bookings/src/lib/bookings.fn.ts). + // Enabled only for the preserved Batch 21 recurrence scenario. + const clash = MIGRATION_ALLOW_CLASH_QUERY && request.method() === 'POST' && + url.pathname === '/api/staff/v1/bookings/clashing-assets'; + if (origins.has(url.origin) && (read || clash)) { + if (clash) audit.readonly_queries.push({ method: 'POST', path: url.pathname }); + await route.continue(); + } else { + audit.blocked_api_requests.push(`${request.method()} ${url.origin}${url.pathname}`); + await route.abort('blockedbyclient'); + } + }); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(MIGRATION_APP_URL); + const local = url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + else audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED connection; intercepted before server connection' }); + } catch (error) { audit.monitoring_errors.push(`WebSocket interception: ${String(error)}`); } + // No connectToServer call: all connections remain browser-local. + closing.push(socket.close().catch((error: unknown) => { audit.monitoring_errors.push(`WebSocket close: ${String(error)}`); })); + }); + }; + migrationActive = { audit, register }; + try { + await register(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close: ${String(error)}`); } + } + await Promise.all(closing); + await Promise.all(identities); + for (const context of navigated) { + if (!identified.has(context)) audit.monitoring_errors.push('A navigated context did not establish staff identity'); + } + migrationActive = undefined; + try { + await info.attach('legacy-settings-migration-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + migrationExpect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'migration safety audit must be clean').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + migrationExpect(audit.staff_identities.length, 'staff identity must be observed').toBeGreaterThan(0); + } + } + }, { auto: true, timeout: 30_000 }], +}; +const migrationTest = migrationBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +const migrationReadOnlyTest = migrationReadOnlyBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +// Drop the shared default admin state even for legacy staff-auth-only contexts. +migrationTest.use({ storageState: undefined }); +migrationReadOnlyTest.use({ storageState: undefined }); + +// Source: apps/workplace/e2e/local/wpa-settings-batch35.spec.ts +// SHA-256: f2068e77c5a986bfd9c5c8dddf116355672b9cc00aad3fefadbf5cbbf7654f36 +{ +const base = migrationTest; +type Settings = Record; + +interface Isolated { + open(overrides: Settings, route?: string): Promise; +} + +const TIMEZONE = 'Etc/UTC'; + +const test = base.extend<{ isolated: Isolated }>({ + isolated: async ({ browser, staffPage }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + const contexts: BrowserContext[] = []; + const blocked: string[] = []; + const failures: unknown[] = []; + try { + await use({ + open: async (overrides, route = ROUTE) => { + const context = await migrationNewContext.bind(null, browser)({ + storageState: auth, + baseURL: APP_URL, + ignoreHTTPSErrors: true, + timezoneId: TIMEZONE, + locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, + serviceWorkers: 'block', + }); + contexts.push(context); + // Register before page creation/navigation; service workers cannot + // bypass this guard. Auth refresh under /auth remains available. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes( + request.method(), + ) || + ![ + new URL(APP_URL).origin, + new URL(BACKEND_URL).origin, + ].includes(url.origin) + ) { + blocked.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else { + await route.fallback(); + } + }); + const page = await context.newPage(); + // The app's own read establishes staff identity; no extra API call. + const identity = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === + '/api/engine/v2/users/current', + ); + const observed = identity.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const settings = { ...BASE_SETTINGS, ...overrides }; + await stubHiddenAssets(page); + await useSettings(page, settings); + await page.addInitScript(() => + localStorage.setItem('APP.locale', 'en-AU'), + ); + await page.goto(route); + const result = await observed; + if ('error' in result) throw result.error; + expect( + result.response.ok(), + 'the app must load the signed-in staff user', + ).toBe(true); + const user = await result.response.json(); + expect(user.email?.toLowerCase()).toBe( + staffEmail(info.parallelIndex).toLowerCase(), + ); + expect( + user.sys_admin || user.support, + 'staff-only scenario', + ).toBeFalsy(); + expect(new URL(page.url()).origin).toBe( + new URL(APP_URL).origin, + ); + expect( + await page.evaluate(() => + new Date().getTimezoneOffset(), + ), + ).toBe(0); + expect(['UTC', TIMEZONE]).toContain( + await page.evaluate( + () => + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), + ); + const stored = await page.evaluate(() => + localStorage.getItem('PLACEOS.setting_overrides'), + ); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator(READY)).toBeVisible(); + return page; + }, + }); + } finally { + // Fixture teardown is separate from the test body and always checks + // blocked mutations, including when an earlier UI assertion failed. + for (const context of contexts) { + try { + await context.close(); + } catch (error) { + failures.push(error); + } + } + if (blocked.length) + failures.push( + new Error( + `Unexpected API mutation/origin attempts blocked: ${blocked.join(', ')}`, + ), + ); + failures.forEach((error) => console.error(error)); + if (failures.length) throw failures[0]; + } + }, +}); + +test.use({ storageState: undefined, timezoneId: TIMEZONE }); + +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const ROUTE = '/#/landing'; + +const READY = 'topbar'; + +const BASE_SETTINGS: Settings = { + 'app.features': [], + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, + 'app.show_quick_links': false, + 'app.show_quick_book': false, + 'app.use_region': false, +}; + +async function core(page: Page) { + // /landing is now the landing-new component; its host has no header. + await expect(page.locator('landing-new')).toHaveCount(1); + await expect(page.locator('landing-new > *').first()).toBeVisible(); + await expect( + page.locator('topbar button[name="user-controls"]'), + ).toBeVisible(); +} + +test('WPA Batch 35: css_variables supplies multiple computed theme values in an isolated page', async ({ + isolated, +}) => { + const page = await isolated.open({ + 'app.css_variables': { 'brand-500': '#123456', 'base-100': '#234567' }, + }); + await expect + .poll(() => + page.locator('body').evaluate((body) => { + const style = getComputedStyle(body); + return { + brand: style.getPropertyValue('--brand-500').trim(), + base: style.getPropertyValue('--base-100').trim(), + }; + }), + ) + .toEqual({ brand: '#123456', base: '#234567' }); + await core(page); +}); + +test('WPA Batch 35: empty css_variables replaces the configured override map without leaking contexts', async ({ + isolated, +}) => { + const configured = await isolated.open({ + 'app.css_variables': { 'brand-500': '#123456' }, + }); + const brand = (page: Page) => + page + .locator('body') + .evaluate((body) => + getComputedStyle(body).getPropertyValue('--brand-500').trim(), + ); + await expect.poll(() => brand(configured)).toBe('#123456'); + const empty = await isolated.open({ 'app.css_variables': {} }); + // Playwright text matching excludes STYLE elements; read the DOM text directly. + await expect + .poll(() => empty.locator('style#css-var-overrides').textContent()) + // develop now also writes an (empty) dark-theme rule for css_variables_dark. + .toMatch(/^body\s*\{\s*\}(\s*body\.theme-dark\s*\{\s*\})?$/); + await expect.poll(() => brand(empty)).not.toBe('#123456'); + await expect.poll(() => brand(configured)).toBe('#123456'); + await core(empty); +}); +} diff --git a/apps/workplace/e2e/settings/general/general.default-route.spec.ts b/apps/workplace/e2e/settings/general/general.default-route.spec.ts new file mode 100644 index 00000000000..d9b7d6cc0b6 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.default-route.spec.ts @@ -0,0 +1,360 @@ +// Phase 2 migration: historically accepted; relocated runtime validation PENDING. +// Covered active keys: default_route +// Scenario owners: S66. Shared scenarios are registered once. +// Historical evidence: reports/wpa-settings-reference/preparation/run35-observed-results.json; reports/wpa-settings-reference/preparation/run35-followup-verified/decoded.json +import type { Browser as MigrationBrowser, BrowserContext as MigrationContext, BrowserContextOptions as MigrationContextOptions, Page as MigrationPage, TestInfo as MigrationTestInfo } from '@playwright/test'; +import { expect as migrationExpect, test as migrationBase } from '../../../../../e2e/support/fixtures'; +import { APP_URL as MIGRATION_APP_URL, BACKEND_URL as MIGRATION_BACKEND_URL, assertLocalOnly as migrationAssertLocalOnly, staffEmail as migrationStaffEmail } from '../../../../../e2e/support/env'; +import { readOnlyTest as migrationReadOnlyBase } from '../../local/wpa-settings-readonly-legacy.fixture'; +import type { BrowserContext } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { BACKEND_URL } from '../../../../../e2e/support/env'; +import { assertLocalOnly } from '../../../../../e2e/support/env'; +import { staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; + +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guards. +const HIDDEN_CATEGORIES = [ + { id: 'pr2-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr2-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr2-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr2-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr2-hidden-catering': [], 'pr2-hidden-desks': ['_DESKS_'], + 'pr2-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr2-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr2-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr2-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +const MIGRATION_ALLOW_CLASH_QUERY = false; +// Test-local audit only: no shared fixtures or existing specs are changed. +type MigrationAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + readonly_queries: { method: string; path: string }[]; + staff_identities: { email: string; privileged: boolean }[]; +}; +let migrationActive: { + audit: MigrationAudit; + register(context: MigrationContext): Promise; +} | undefined; + +async function migrationNewContext(browser: MigrationBrowser, options: MigrationContextOptions = {}) { + if (!migrationActive) throw new Error('Migration audit must be active before context creation'); + const context = await browser.newContext({ ...options, serviceWorkers: 'block' }); + await migrationActive.register(context); + return context; +} + +const migrationAuditFixture: { migrationAudit: [ + (args: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => Promise, + { auto: true; timeout: number }, +] } = { + migrationAudit: [async ({ staffPage }: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => { + migrationAssertLocalOnly(MIGRATION_APP_URL, MIGRATION_BACKEND_URL); + const audit: MigrationAudit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + readonly_queries: [], staff_identities: [], + }; + const contexts: MigrationContext[] = []; + const closing: Promise[] = []; + const identities: Promise[] = []; + const navigated = new Set(); + const identified = new Set(); + const origins = new Set([new URL(MIGRATION_APP_URL).origin, new URL(MIGRATION_BACKEND_URL).origin]); + const register = async (context: MigrationContext) => { + contexts.push(context); + const observe = (page: MigrationPage) => { + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame() && frame.url().startsWith(new URL(MIGRATION_APP_URL).origin + '/')) navigated.add(context); + }); + page.on('pageerror', (error) => audit.page_errors.push(String(error))); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) audit.app_errors.push(message.text().slice(0, 500)); + }); + page.on('response', (response) => { + const url = new URL(response.url()); + if (response.request().method() !== 'GET' || !origins.has(url.origin) || url.pathname !== '/api/engine/v2/users/current') return; + identities.push((async () => { + migrationExpect(response.ok(), 'staff identity GET must succeed').toBe(true); + const user = await response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + audit.staff_identities.push(identity); + migrationExpect(identity.email).toBe(migrationStaffEmail(info.parallelIndex).toLowerCase()); + migrationExpect(identity.privileged, 'staff-only scenario').toBe(false); + identified.add(context); + })().catch((error: unknown) => { audit.monitoring_errors.push(`Staff identity: ${String(error)}`); })); + }); + }; + for (const page of context.pages()) observe(page); + context.on('page', observe); + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const read = ['GET', 'HEAD', 'OPTIONS'].includes(request.method()); + // findBookingClashes is a source-supported availability query, + // not booking creation (libs/bookings/src/lib/bookings.fn.ts). + // Enabled only for the preserved Batch 21 recurrence scenario. + const clash = MIGRATION_ALLOW_CLASH_QUERY && request.method() === 'POST' && + url.pathname === '/api/staff/v1/bookings/clashing-assets'; + if (origins.has(url.origin) && (read || clash)) { + if (clash) audit.readonly_queries.push({ method: 'POST', path: url.pathname }); + await route.continue(); + } else { + audit.blocked_api_requests.push(`${request.method()} ${url.origin}${url.pathname}`); + await route.abort('blockedbyclient'); + } + }); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(MIGRATION_APP_URL); + const local = url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + else audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED connection; intercepted before server connection' }); + } catch (error) { audit.monitoring_errors.push(`WebSocket interception: ${String(error)}`); } + // No connectToServer call: all connections remain browser-local. + closing.push(socket.close().catch((error: unknown) => { audit.monitoring_errors.push(`WebSocket close: ${String(error)}`); })); + }); + }; + migrationActive = { audit, register }; + try { + await register(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close: ${String(error)}`); } + } + await Promise.all(closing); + await Promise.all(identities); + for (const context of navigated) { + if (!identified.has(context)) audit.monitoring_errors.push('A navigated context did not establish staff identity'); + } + migrationActive = undefined; + try { + await info.attach('legacy-settings-migration-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + migrationExpect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'migration safety audit must be clean').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + migrationExpect(audit.staff_identities.length, 'staff identity must be observed').toBeGreaterThan(0); + } + } + }, { auto: true, timeout: 30_000 }], +}; +const migrationTest = migrationBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +const migrationReadOnlyTest = migrationReadOnlyBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +// Drop the shared default admin state even for legacy staff-auth-only contexts. +migrationTest.use({ storageState: undefined }); +migrationReadOnlyTest.use({ storageState: undefined }); + +// Source: apps/workplace/e2e/local/wpa-settings-batch35.spec.ts +// SHA-256: f2068e77c5a986bfd9c5c8dddf116355672b9cc00aad3fefadbf5cbbf7654f36 +{ +const base = migrationTest; +type Settings = Record; + +interface Isolated { + open(overrides: Settings, route?: string): Promise; +} + +const TIMEZONE = 'Etc/UTC'; + +const test = base.extend<{ isolated: Isolated }>({ + isolated: async ({ browser, staffPage }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + const contexts: BrowserContext[] = []; + const blocked: string[] = []; + const failures: unknown[] = []; + try { + await use({ + open: async (overrides, route = ROUTE) => { + const context = await migrationNewContext.bind(null, browser)({ + storageState: auth, + baseURL: APP_URL, + ignoreHTTPSErrors: true, + timezoneId: TIMEZONE, + locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, + serviceWorkers: 'block', + }); + contexts.push(context); + // Register before page creation/navigation; service workers cannot + // bypass this guard. Auth refresh under /auth remains available. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes( + request.method(), + ) || + ![ + new URL(APP_URL).origin, + new URL(BACKEND_URL).origin, + ].includes(url.origin) + ) { + blocked.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else { + await route.fallback(); + } + }); + const page = await context.newPage(); + // The app's own read establishes staff identity; no extra API call. + const identity = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === + '/api/engine/v2/users/current', + ); + const observed = identity.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const settings = { ...BASE_SETTINGS, ...overrides }; + await stubHiddenAssets(page); + await useSettings(page, settings); + await page.addInitScript(() => + localStorage.setItem('APP.locale', 'en-AU'), + ); + await page.goto(route); + const result = await observed; + if ('error' in result) throw result.error; + expect( + result.response.ok(), + 'the app must load the signed-in staff user', + ).toBe(true); + const user = await result.response.json(); + expect(user.email?.toLowerCase()).toBe( + staffEmail(info.parallelIndex).toLowerCase(), + ); + expect( + user.sys_admin || user.support, + 'staff-only scenario', + ).toBeFalsy(); + expect(new URL(page.url()).origin).toBe( + new URL(APP_URL).origin, + ); + expect( + await page.evaluate(() => + new Date().getTimezoneOffset(), + ), + ).toBe(0); + expect(['UTC', TIMEZONE]).toContain( + await page.evaluate( + () => + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), + ); + const stored = await page.evaluate(() => + localStorage.getItem('PLACEOS.setting_overrides'), + ); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator(READY)).toBeVisible(); + return page; + }, + }); + } finally { + // Fixture teardown is separate from the test body and always checks + // blocked mutations, including when an earlier UI assertion failed. + for (const context of contexts) { + try { + await context.close(); + } catch (error) { + failures.push(error); + } + } + if (blocked.length) + failures.push( + new Error( + `Unexpected API mutation/origin attempts blocked: ${blocked.join(', ')}`, + ), + ); + failures.forEach((error) => console.error(error)); + if (failures.length) throw failures[0]; + } + }, +}); + +test.use({ storageState: undefined, timezoneId: TIMEZONE }); + +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const ROUTE = '/#/landing'; + +const READY = 'topbar'; + +const BASE_SETTINGS: Settings = { + 'app.features': [], + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, + 'app.show_quick_links': false, + 'app.show_quick_book': false, + 'app.use_region': false, +}; + +async function core(page: Page) { + // /landing is now the landing-new component; its host has no header. + await expect(page.locator('landing-new')).toHaveCount(1); + await expect(page.locator('landing-new > *').first()).toBeVisible(); + await expect( + page.locator('topbar button[name="user-controls"]'), + ).toBeVisible(); +} + +test('WPA Batch 35: default_route redirects the application entry route to the configured landing page', async ({ + isolated, +}) => { + const page = await isolated.open( + { 'app.default_route': '/landing' }, + '/#/-', + ); + await expect(page).toHaveURL(/#\/landing$/); + await core(page); +}); +} diff --git a/apps/workplace/e2e/settings/general/general.group-events-calendar.spec.ts b/apps/workplace/e2e/settings/general/general.group-events-calendar.spec.ts new file mode 100644 index 00000000000..9e475564386 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.group-events-calendar.spec.ts @@ -0,0 +1,199 @@ +import type { ConsoleMessage, Page, Route, WebSocketRoute } from '@playwright/test'; +import { SchedulePage } from '../../../../../e2e/support/bookings/schedule.page'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. Source: desk-flow-form.show_reserved_desk_overlay; +// BookingFormService.settingForType / assignedResourceBooking; +// BookingDetailsModal.can_checkin; GroupEventsStateService.calendar. +// All fixtures below are browser-only GET responses. No fixture is saved; +// every non-GET request still reaches the read-only guard. +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const ID = 9100047; +const PARKING_CATEGORY = 'e2e-draft-parking-category'; +const PARKING_TYPES = ['e2e-draft-parking-spaces-type', 'e2e-draft-parking-users-type']; +type Identity = { id: string; email: string; name: string }; +type DebugWindow = Window & { ng?: { getComponent(element: Element): unknown }; setting?: (key: string) => unknown }; + +// ---------------------------------------------------------------- safety audit +// Adapted from the passing Batch 1 drafts. The desk form (picker not opened), +// your-bookings and group-events make no getModule/bind/execute calls with chat +// off and no location_services module bound, so NO PlaceOS WebSocket is expected. +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; + +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** Only the Angular dev server's Vite client socket (vite client.mjs:856-865, reload on close :993-998). */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +function isLocalControlSocket(url: URL) { + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.protocol === protocol && url.host === app.host && + url.pathname === '/api/engine/v2/systems/control'; +} + +/** ErrorHandler logs console.error('ERROR', e); Angular runtime errors log NG0xxx. */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], page_errors: [], + app_errors: [], monitoring_errors: [], fixtures: {} }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + const closeSocket = async (socket: WebSocketRoute, url: string) => { + try { + await socket.close(); + } catch (error) { + audit.monitoring_errors.push(`WebSocket close failed for ${url}: ${String(error)}`); + } + }; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = redact(url); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: socket_url, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (isLocalControlSocket(url)) { + audit.blocked_control_attempts.push({ + url: socket_url, + classification: 'known Workplace shell initialization; intercepted and closed before server connection', + }); + } else { + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control' : 'other'; + audit.websocket_attempts.push({ url: socket_url, classification: `UNEXPECTED ${kind}; these pages are HTTP-only` }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed for ${socket_url}: ${String(error)}`); + } + closing.push(closeSocket(socket, socket_url)); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('readonly-data-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +/** A named, counted, GET-only browser response. Every other method reaches the read-only guard. */ +async function stubGet(page: Page, audit: SafetyAudit, name: string, + match: (url: URL) => boolean, body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(`${name} fixture: ${String(error)}`); + await route.fallback().catch(() => undefined); + } + }); +} + +async function preparePage(page: Page, overrides: Record, audit: SafetyAudit) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + let identity: Promise | undefined; + page.on('response', (response) => { + if (response.request().method() === 'GET' && + new URL(response.url()).pathname === '/api/engine/v2/users/current') identity = response.json(); + }); + // BookingFormService.loadForm/newForm -> CalendarService.loadCalendars reads + // the calendar provider (/api/staff/v1/calendars). Keep it local and empty. + await stubGet(page, audit, 'calendar provider list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + const settings = { ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.use_region': false, + 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], + // Keeps any schedule event listing on PlaceOS bookings, never staff-api /events. + 'app.events.use_bookings': true, + ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-US')); + return { + user: async () => { + if (!identity) throw new Error('the app must establish its existing staff identity before loading fixtures'); + return identity; + }, + ready: async () => { + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((key) => (window as DebugWindow).setting?.(key), key)).toEqual(value); + } + }, + }; +} + +for (const calendar of ['draft-calendar-a@example.invalid', 'draft-calendar-b@example.invalid']) { + test(`WPA draft: group_events_calendar ${calendar} scopes the calendar lookup`, async ({ staffPage, safetyAudit }) => { + const fixture = await preparePage(staffPage, { + 'app.features': ['group-events'], 'app.group_events_calendar': calendar, + }, safetyAudit); + const lookups: string[] = []; + // GroupEventsStateService: GET /api/engine/v2/systems/with_emails?in=. + // No resolved system: queryEvents (staff-api /events, the provider) is never called. + await stubGet(staffPage, safetyAudit, 'calendar system lookup', (url) => + url.pathname === '/api/engine/v2/systems/with_emails' && url.searchParams.has('in'), (url) => { + lookups.push(url.searchParams.get('in')!); + return []; + }); + await staffPage.goto('/#/group-events'); + await fixture.ready(); + await expect(staffPage.locator('[group-events] group-events-sidebar')).toBeVisible(); + await expect.poll(() => lookups.length).toBeGreaterThan(0); + expect([...new Set(lookups)]).toEqual([calendar]); + await expect(staffPage.locator('[group-events] img[src="assets/icons/no-results.svg"]')).toBeVisible(); + await expect(staffPage.locator('group-event-card')).toHaveCount(0); + }); +} diff --git a/apps/workplace/e2e/settings/general/general.locales.spec.ts b/apps/workplace/e2e/settings/general/general.locales.spec.ts new file mode 100644 index 00000000000..3fa74242276 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.locales.spec.ts @@ -0,0 +1,385 @@ +// Phase 2 migration: historically accepted; relocated runtime validation PENDING. +// Covered active keys: locales +// Scenario owners: S64, S65. Shared scenarios are registered once. +// Historical evidence: reports/wpa-settings-reference/preparation/run35-observed-results.json; reports/wpa-settings-reference/preparation/run35-followup-verified/decoded.json +import type { Browser as MigrationBrowser, BrowserContext as MigrationContext, BrowserContextOptions as MigrationContextOptions, Page as MigrationPage, TestInfo as MigrationTestInfo } from '@playwright/test'; +import { expect as migrationExpect, test as migrationBase } from '../../../../../e2e/support/fixtures'; +import { APP_URL as MIGRATION_APP_URL, BACKEND_URL as MIGRATION_BACKEND_URL, assertLocalOnly as migrationAssertLocalOnly, staffEmail as migrationStaffEmail } from '../../../../../e2e/support/env'; +import { readOnlyTest as migrationReadOnlyBase } from '../../local/wpa-settings-readonly-legacy.fixture'; +import type { BrowserContext } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { BACKEND_URL } from '../../../../../e2e/support/env'; +import { assertLocalOnly } from '../../../../../e2e/support/env'; +import { staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; + +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guards. +const HIDDEN_CATEGORIES = [ + { id: 'pr2-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr2-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr2-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr2-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr2-hidden-catering': [], 'pr2-hidden-desks': ['_DESKS_'], + 'pr2-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr2-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr2-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr2-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +const MIGRATION_ALLOW_CLASH_QUERY = false; +// Test-local audit only: no shared fixtures or existing specs are changed. +type MigrationAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + readonly_queries: { method: string; path: string }[]; + staff_identities: { email: string; privileged: boolean }[]; +}; +let migrationActive: { + audit: MigrationAudit; + register(context: MigrationContext): Promise; +} | undefined; + +async function migrationNewContext(browser: MigrationBrowser, options: MigrationContextOptions = {}) { + if (!migrationActive) throw new Error('Migration audit must be active before context creation'); + const context = await browser.newContext({ ...options, serviceWorkers: 'block' }); + await migrationActive.register(context); + return context; +} + +const migrationAuditFixture: { migrationAudit: [ + (args: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => Promise, + { auto: true; timeout: number }, +] } = { + migrationAudit: [async ({ staffPage }: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => { + migrationAssertLocalOnly(MIGRATION_APP_URL, MIGRATION_BACKEND_URL); + const audit: MigrationAudit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + readonly_queries: [], staff_identities: [], + }; + const contexts: MigrationContext[] = []; + const closing: Promise[] = []; + const identities: Promise[] = []; + const navigated = new Set(); + const identified = new Set(); + const origins = new Set([new URL(MIGRATION_APP_URL).origin, new URL(MIGRATION_BACKEND_URL).origin]); + const register = async (context: MigrationContext) => { + contexts.push(context); + const observe = (page: MigrationPage) => { + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame() && frame.url().startsWith(new URL(MIGRATION_APP_URL).origin + '/')) navigated.add(context); + }); + page.on('pageerror', (error) => audit.page_errors.push(String(error))); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) audit.app_errors.push(message.text().slice(0, 500)); + }); + page.on('response', (response) => { + const url = new URL(response.url()); + if (response.request().method() !== 'GET' || !origins.has(url.origin) || url.pathname !== '/api/engine/v2/users/current') return; + identities.push((async () => { + migrationExpect(response.ok(), 'staff identity GET must succeed').toBe(true); + const user = await response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + audit.staff_identities.push(identity); + migrationExpect(identity.email).toBe(migrationStaffEmail(info.parallelIndex).toLowerCase()); + migrationExpect(identity.privileged, 'staff-only scenario').toBe(false); + identified.add(context); + })().catch((error: unknown) => { audit.monitoring_errors.push(`Staff identity: ${String(error)}`); })); + }); + }; + for (const page of context.pages()) observe(page); + context.on('page', observe); + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const read = ['GET', 'HEAD', 'OPTIONS'].includes(request.method()); + // findBookingClashes is a source-supported availability query, + // not booking creation (libs/bookings/src/lib/bookings.fn.ts). + // Enabled only for the preserved Batch 21 recurrence scenario. + const clash = MIGRATION_ALLOW_CLASH_QUERY && request.method() === 'POST' && + url.pathname === '/api/staff/v1/bookings/clashing-assets'; + if (origins.has(url.origin) && (read || clash)) { + if (clash) audit.readonly_queries.push({ method: 'POST', path: url.pathname }); + await route.continue(); + } else { + audit.blocked_api_requests.push(`${request.method()} ${url.origin}${url.pathname}`); + await route.abort('blockedbyclient'); + } + }); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(MIGRATION_APP_URL); + const local = url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + else audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED connection; intercepted before server connection' }); + } catch (error) { audit.monitoring_errors.push(`WebSocket interception: ${String(error)}`); } + // No connectToServer call: all connections remain browser-local. + closing.push(socket.close().catch((error: unknown) => { audit.monitoring_errors.push(`WebSocket close: ${String(error)}`); })); + }); + }; + migrationActive = { audit, register }; + try { + await register(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close: ${String(error)}`); } + } + await Promise.all(closing); + await Promise.all(identities); + for (const context of navigated) { + if (!identified.has(context)) audit.monitoring_errors.push('A navigated context did not establish staff identity'); + } + migrationActive = undefined; + try { + await info.attach('legacy-settings-migration-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + migrationExpect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'migration safety audit must be clean').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + migrationExpect(audit.staff_identities.length, 'staff identity must be observed').toBeGreaterThan(0); + } + } + }, { auto: true, timeout: 30_000 }], +}; +const migrationTest = migrationBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +const migrationReadOnlyTest = migrationReadOnlyBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +// Drop the shared default admin state even for legacy staff-auth-only contexts. +migrationTest.use({ storageState: undefined }); +migrationReadOnlyTest.use({ storageState: undefined }); + +// Source: apps/workplace/e2e/local/wpa-settings-batch35.spec.ts +// SHA-256: f2068e77c5a986bfd9c5c8dddf116355672b9cc00aad3fefadbf5cbbf7654f36 +{ +const base = migrationTest; +type Settings = Record; + +interface Isolated { + open(overrides: Settings, route?: string): Promise; +} + +const TIMEZONE = 'Etc/UTC'; + +const test = base.extend<{ isolated: Isolated }>({ + isolated: async ({ browser, staffPage }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + const contexts: BrowserContext[] = []; + const blocked: string[] = []; + const failures: unknown[] = []; + try { + await use({ + open: async (overrides, route = ROUTE) => { + const context = await migrationNewContext.bind(null, browser)({ + storageState: auth, + baseURL: APP_URL, + ignoreHTTPSErrors: true, + timezoneId: TIMEZONE, + locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, + serviceWorkers: 'block', + }); + contexts.push(context); + // Register before page creation/navigation; service workers cannot + // bypass this guard. Auth refresh under /auth remains available. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes( + request.method(), + ) || + ![ + new URL(APP_URL).origin, + new URL(BACKEND_URL).origin, + ].includes(url.origin) + ) { + blocked.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else { + await route.fallback(); + } + }); + const page = await context.newPage(); + // The app's own read establishes staff identity; no extra API call. + const identity = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === + '/api/engine/v2/users/current', + ); + const observed = identity.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const settings = { ...BASE_SETTINGS, ...overrides }; + await stubHiddenAssets(page); + await useSettings(page, settings); + await page.addInitScript(() => + localStorage.setItem('APP.locale', 'en-AU'), + ); + await page.goto(route); + const result = await observed; + if ('error' in result) throw result.error; + expect( + result.response.ok(), + 'the app must load the signed-in staff user', + ).toBe(true); + const user = await result.response.json(); + expect(user.email?.toLowerCase()).toBe( + staffEmail(info.parallelIndex).toLowerCase(), + ); + expect( + user.sys_admin || user.support, + 'staff-only scenario', + ).toBeFalsy(); + expect(new URL(page.url()).origin).toBe( + new URL(APP_URL).origin, + ); + expect( + await page.evaluate(() => + new Date().getTimezoneOffset(), + ), + ).toBe(0); + expect(['UTC', TIMEZONE]).toContain( + await page.evaluate( + () => + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), + ); + const stored = await page.evaluate(() => + localStorage.getItem('PLACEOS.setting_overrides'), + ); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator(READY)).toBeVisible(); + return page; + }, + }); + } finally { + // Fixture teardown is separate from the test body and always checks + // blocked mutations, including when an earlier UI assertion failed. + for (const context of contexts) { + try { + await context.close(); + } catch (error) { + failures.push(error); + } + } + if (blocked.length) + failures.push( + new Error( + `Unexpected API mutation/origin attempts blocked: ${blocked.join(', ')}`, + ), + ); + failures.forEach((error) => console.error(error)); + if (failures.length) throw failures[0]; + } + }, +}); + +test.use({ storageState: undefined, timezoneId: TIMEZONE }); + +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const ROUTE = '/#/landing'; + +const READY = 'topbar'; + +const BASE_SETTINGS: Settings = { + 'app.features': [], + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, + 'app.show_quick_links': false, + 'app.show_quick_book': false, + 'app.use_region': false, +}; + +async function controls(page: Page) { + await page.locator('topbar button[name="user-controls"]').click(); + await expect( + page.locator('user-controls-sidebar user-controls'), + ).toBeVisible(); + return page.locator('user-controls-sidebar user-controls'); +} + +const LANGUAGES = [ + { id: 'en-AU', name: 'E2E English', local: 'E2E English', flag: '' }, + { id: 'fr', name: 'E2E French', local: 'E2E French', flag: '' }, +]; + +test('WPA Batch 35: locales replaces language choices without persisting a language selection', async ({ + isolated, +}) => { + const page = await isolated.open({ 'app.locales': LANGUAGES }); + const drawer = await controls(page); + await drawer.getByRole('button', { name: /Language/ }).click(); + const menu = page.locator('language-select'); + await expect(menu).toBeVisible(); + for (const language of LANGUAGES) + await expect( + menu.getByRole('button', { name: language.name, exact: true }), + ).toBeVisible(); + await expect(menu.getByRole('button')).toHaveCount(2); + await expect(menu.getByText('Español', { exact: true })).toHaveCount(0); +}); + +test('WPA Batch 35: zero or one locale hides the language selector while user controls remain', async ({ + isolated, +}) => { + for (const languages of [[], [LANGUAGES[0]]]) { + const page = await isolated.open({ 'app.locales': languages }); + const drawer = await controls(page); + await expect( + drawer.getByRole('button', { name: /Language/ }), + ).toHaveCount(0); + await expect( + drawer.getByRole('button', { name: 'Sign Out', exact: true }), + ).toBeVisible(); + await expect(page.locator('language-select')).toHaveCount(0); + } +}); +} diff --git a/apps/workplace/e2e/settings/general/general.menu-embeds.spec.ts b/apps/workplace/e2e/settings/general/general.menu-embeds.spec.ts new file mode 100644 index 00000000000..30af79f4ca8 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.menu-embeds.spec.ts @@ -0,0 +1,390 @@ +// Phase 2 migration: historically accepted; relocated runtime validation PENDING. +// Covered active keys: menu_embeds +// Scenario owners: S57, S58. Shared scenarios are registered once. +// Historical evidence: reports/wpa-settings-reference/preparation/batch34-file-only-execution.json +import type { Browser as MigrationBrowser, BrowserContext as MigrationContext, BrowserContextOptions as MigrationContextOptions, Page as MigrationPage, TestInfo as MigrationTestInfo } from '@playwright/test'; +import { expect as migrationExpect, test as migrationBase } from '../../../../../e2e/support/fixtures'; +import { APP_URL as MIGRATION_APP_URL, BACKEND_URL as MIGRATION_BACKEND_URL, assertLocalOnly as migrationAssertLocalOnly, staffEmail as migrationStaffEmail } from '../../../../../e2e/support/env'; +import { readOnlyTest as migrationReadOnlyBase } from '../../local/wpa-settings-readonly-legacy.fixture'; +import type { BrowserContext } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { BACKEND_URL } from '../../../../../e2e/support/env'; +import { assertLocalOnly } from '../../../../../e2e/support/env'; +import { staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; + +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guards. +const HIDDEN_CATEGORIES = [ + { id: 'pr2-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr2-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr2-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr2-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr2-hidden-catering': [], 'pr2-hidden-desks': ['_DESKS_'], + 'pr2-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr2-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr2-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr2-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +const MIGRATION_ALLOW_CLASH_QUERY = false; +// Test-local audit only: no shared fixtures or existing specs are changed. +type MigrationAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + readonly_queries: { method: string; path: string }[]; + staff_identities: { email: string; privileged: boolean }[]; +}; +let migrationActive: { + audit: MigrationAudit; + register(context: MigrationContext): Promise; +} | undefined; + +async function migrationNewContext(browser: MigrationBrowser, options: MigrationContextOptions = {}) { + if (!migrationActive) throw new Error('Migration audit must be active before context creation'); + const context = await browser.newContext({ ...options, serviceWorkers: 'block' }); + await migrationActive.register(context); + return context; +} + +const migrationAuditFixture: { migrationAudit: [ + (args: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => Promise, + { auto: true; timeout: number }, +] } = { + migrationAudit: [async ({ staffPage }: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => { + migrationAssertLocalOnly(MIGRATION_APP_URL, MIGRATION_BACKEND_URL); + const audit: MigrationAudit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + readonly_queries: [], staff_identities: [], + }; + const contexts: MigrationContext[] = []; + const closing: Promise[] = []; + const identities: Promise[] = []; + const navigated = new Set(); + const identified = new Set(); + const origins = new Set([new URL(MIGRATION_APP_URL).origin, new URL(MIGRATION_BACKEND_URL).origin]); + const register = async (context: MigrationContext) => { + contexts.push(context); + const observe = (page: MigrationPage) => { + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame() && frame.url().startsWith(new URL(MIGRATION_APP_URL).origin + '/')) navigated.add(context); + }); + page.on('pageerror', (error) => audit.page_errors.push(String(error))); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) audit.app_errors.push(message.text().slice(0, 500)); + }); + page.on('response', (response) => { + const url = new URL(response.url()); + if (response.request().method() !== 'GET' || !origins.has(url.origin) || url.pathname !== '/api/engine/v2/users/current') return; + identities.push((async () => { + migrationExpect(response.ok(), 'staff identity GET must succeed').toBe(true); + const user = await response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + audit.staff_identities.push(identity); + migrationExpect(identity.email).toBe(migrationStaffEmail(info.parallelIndex).toLowerCase()); + migrationExpect(identity.privileged, 'staff-only scenario').toBe(false); + identified.add(context); + })().catch((error: unknown) => { audit.monitoring_errors.push(`Staff identity: ${String(error)}`); })); + }); + }; + for (const page of context.pages()) observe(page); + context.on('page', observe); + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const read = ['GET', 'HEAD', 'OPTIONS'].includes(request.method()); + // findBookingClashes is a source-supported availability query, + // not booking creation (libs/bookings/src/lib/bookings.fn.ts). + // Enabled only for the preserved Batch 21 recurrence scenario. + const clash = MIGRATION_ALLOW_CLASH_QUERY && request.method() === 'POST' && + url.pathname === '/api/staff/v1/bookings/clashing-assets'; + if (origins.has(url.origin) && (read || clash)) { + if (clash) audit.readonly_queries.push({ method: 'POST', path: url.pathname }); + await route.continue(); + } else { + audit.blocked_api_requests.push(`${request.method()} ${url.origin}${url.pathname}`); + await route.abort('blockedbyclient'); + } + }); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(MIGRATION_APP_URL); + const local = url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + else audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED connection; intercepted before server connection' }); + } catch (error) { audit.monitoring_errors.push(`WebSocket interception: ${String(error)}`); } + // No connectToServer call: all connections remain browser-local. + closing.push(socket.close().catch((error: unknown) => { audit.monitoring_errors.push(`WebSocket close: ${String(error)}`); })); + }); + }; + migrationActive = { audit, register }; + try { + await register(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close: ${String(error)}`); } + } + await Promise.all(closing); + await Promise.all(identities); + for (const context of navigated) { + if (!identified.has(context)) audit.monitoring_errors.push('A navigated context did not establish staff identity'); + } + migrationActive = undefined; + try { + await info.attach('legacy-settings-migration-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + migrationExpect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'migration safety audit must be clean').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + migrationExpect(audit.staff_identities.length, 'staff identity must be observed').toBeGreaterThan(0); + } + } + }, { auto: true, timeout: 30_000 }], +}; +const migrationTest = migrationBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +const migrationReadOnlyTest = migrationReadOnlyBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +// Drop the shared default admin state even for legacy staff-auth-only contexts. +migrationTest.use({ storageState: undefined }); +migrationReadOnlyTest.use({ storageState: undefined }); + +// Source: apps/workplace/e2e/local/wpa-settings-batch34.spec.ts +// SHA-256: ad0d524dae74119de0e7371d4c7fdb1ece1009c0e969729f6ed47cad09f7cdc9 +{ +const base = migrationTest; +type Settings = Record; + +interface Isolated { + open(overrides: Settings): Promise; +} + +const TIMEZONE = 'Etc/UTC'; + +const test = base.extend<{ isolated: Isolated }>({ + isolated: async ({ browser, staffPage }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + const contexts: BrowserContext[] = []; + const blocked: string[] = []; + const failures: unknown[] = []; + try { + await use({ + open: async (overrides) => { + const context = await migrationNewContext.bind(null, browser)({ + storageState: auth, + baseURL: APP_URL, + ignoreHTTPSErrors: true, + timezoneId: TIMEZONE, + locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, + serviceWorkers: 'block', + }); + contexts.push(context); + // Register before page creation/navigation; service workers cannot + // bypass this guard. Auth refresh under /auth remains available. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes( + request.method(), + ) || + ![ + new URL(APP_URL).origin, + new URL(BACKEND_URL).origin, + ].includes(url.origin) + ) { + blocked.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else { + await route.fallback(); + } + }); + const page = await context.newPage(); + // The app's own read establishes staff identity; no extra API call. + const identity = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === + '/api/engine/v2/users/current', + ); + const observed = identity.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const settings = { ...BASE_SETTINGS, ...overrides }; + await stubHiddenAssets(page); + await useSettings(page, settings); + await page.addInitScript(() => + localStorage.setItem('APP.locale', 'en-AU'), + ); + await page.goto(ROUTE); + const result = await observed; + if ('error' in result) throw result.error; + expect( + result.response.ok(), + 'the app must load the signed-in staff user', + ).toBe(true); + const user = await result.response.json(); + expect(user.email?.toLowerCase()).toBe( + staffEmail(info.parallelIndex).toLowerCase(), + ); + expect( + user.sys_admin || user.support, + 'staff-only scenario', + ).toBeFalsy(); + expect(new URL(page.url()).origin).toBe( + new URL(APP_URL).origin, + ); + expect( + await page.evaluate(() => + new Date().getTimezoneOffset(), + ), + ).toBe(0); + expect(['UTC', TIMEZONE]).toContain( + await page.evaluate( + () => + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), + ); + const stored = await page.evaluate(() => + localStorage.getItem('PLACEOS.setting_overrides'), + ); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator(READY)).toBeVisible(); + return page; + }, + }); + } finally { + // Fixture teardown is separate from the test body and always checks + // blocked mutations, including when an earlier UI assertion failed. + for (const context of contexts) { + try { + await context.close(); + } catch (error) { + failures.push(error); + } + } + if (blocked.length) + failures.push( + new Error( + `Unexpected API mutation/origin attempts blocked: ${blocked.join(', ')}`, + ), + ); + failures.forEach((error) => console.error(error)); + if (failures.length) throw failures[0]; + } + }, +}); + +test.use({ storageState: undefined, timezoneId: TIMEZONE }); + +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const ROUTE = '/#/landing'; + +const READY = 'topbar'; + +const BASE_SETTINGS: Settings = { + 'app.features': ['parking'], + 'app.allow_dark_mode': false, + 'app.show_quick_book': false, + 'app.show_quick_links': false, + 'app.chat.enabled': false, + 'app.menu_embeds': [], +}; + +async function core(page: Page) { + // /landing is now the landing-new component; its host has no header. + await expect(page.locator('landing-new')).toHaveCount(1); + await expect(page.locator('landing-new > *').first()).toBeVisible(); + await expect(page.locator('topbar a[name="nav-logo"]')).toBeVisible(); +} + +test('WPA Batch 34: internal menu embeds point at the local embedded route and empty list removes them', async ({ + isolated, +}) => { + const item = { + id: 'e2e-local-guide', + name: 'E2E Local Guide', + url: 'https://example.invalid/guide', + external: false, + icon: 'help', + }; + const page = await isolated.open({ 'app.menu_embeds': [item] }); + const link = page.locator(`top-menu a[name="nav-embed-${item.id}"]`); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', `#/embedded/${item.id}`); + await expect(link.locator('icon').first()).toHaveText(item.icon); + const empty = await isolated.open({ 'app.menu_embeds': [] }); + await expect(empty.locator('top-menu a[name^="nav-embed-"]')).toHaveCount( + 0, + ); + await expect(link).toBeVisible(); + await core(empty); +}); + +test('WPA Batch 34: external menu embeds expose a safe new-tab link without navigation', async ({ + isolated, +}) => { + const item = { + id: 'e2e-external-guide', + name: 'E2E External Guide', + url: 'https://example.invalid/external', + external: true, + }; + const page = await isolated.open({ 'app.menu_embeds': [item] }); + const link = page.locator(`top-menu a[name="nav-embed-${item.id}"]`); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', item.url); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + await expect(link.locator('icon').first()).toHaveText('open_in_browser'); + await expect(page).toHaveURL(/#\/landing/); + await core(page); +}); +} diff --git a/apps/workplace/e2e/settings/general/general.name.spec.ts b/apps/workplace/e2e/settings/general/general.name.spec.ts new file mode 100644 index 00000000000..ac32dc1bd2a --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.name.spec.ts @@ -0,0 +1,360 @@ +// Phase 2 migration: historically accepted; relocated runtime validation PENDING. +// Covered active keys: name +// Scenario owners: S59. Shared scenarios are registered once. +// Historical evidence: reports/wpa-settings-reference/preparation/run35-observed-results.json +import type { Browser as MigrationBrowser, BrowserContext as MigrationContext, BrowserContextOptions as MigrationContextOptions, Page as MigrationPage, TestInfo as MigrationTestInfo } from '@playwright/test'; +import { expect as migrationExpect, test as migrationBase } from '../../../../../e2e/support/fixtures'; +import { APP_URL as MIGRATION_APP_URL, BACKEND_URL as MIGRATION_BACKEND_URL, assertLocalOnly as migrationAssertLocalOnly, staffEmail as migrationStaffEmail } from '../../../../../e2e/support/env'; +import { readOnlyTest as migrationReadOnlyBase } from '../../local/wpa-settings-readonly-legacy.fixture'; +import type { BrowserContext } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { BACKEND_URL } from '../../../../../e2e/support/env'; +import { assertLocalOnly } from '../../../../../e2e/support/env'; +import { staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; + +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guards. +const HIDDEN_CATEGORIES = [ + { id: 'pr2-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr2-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr2-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr2-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr2-hidden-catering': [], 'pr2-hidden-desks': ['_DESKS_'], + 'pr2-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr2-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr2-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr2-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +const MIGRATION_ALLOW_CLASH_QUERY = false; +// Test-local audit only: no shared fixtures or existing specs are changed. +type MigrationAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + readonly_queries: { method: string; path: string }[]; + staff_identities: { email: string; privileged: boolean }[]; +}; +let migrationActive: { + audit: MigrationAudit; + register(context: MigrationContext): Promise; +} | undefined; + +async function migrationNewContext(browser: MigrationBrowser, options: MigrationContextOptions = {}) { + if (!migrationActive) throw new Error('Migration audit must be active before context creation'); + const context = await browser.newContext({ ...options, serviceWorkers: 'block' }); + await migrationActive.register(context); + return context; +} + +const migrationAuditFixture: { migrationAudit: [ + (args: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => Promise, + { auto: true; timeout: number }, +] } = { + migrationAudit: [async ({ staffPage }: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => { + migrationAssertLocalOnly(MIGRATION_APP_URL, MIGRATION_BACKEND_URL); + const audit: MigrationAudit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + readonly_queries: [], staff_identities: [], + }; + const contexts: MigrationContext[] = []; + const closing: Promise[] = []; + const identities: Promise[] = []; + const navigated = new Set(); + const identified = new Set(); + const origins = new Set([new URL(MIGRATION_APP_URL).origin, new URL(MIGRATION_BACKEND_URL).origin]); + const register = async (context: MigrationContext) => { + contexts.push(context); + const observe = (page: MigrationPage) => { + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame() && frame.url().startsWith(new URL(MIGRATION_APP_URL).origin + '/')) navigated.add(context); + }); + page.on('pageerror', (error) => audit.page_errors.push(String(error))); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) audit.app_errors.push(message.text().slice(0, 500)); + }); + page.on('response', (response) => { + const url = new URL(response.url()); + if (response.request().method() !== 'GET' || !origins.has(url.origin) || url.pathname !== '/api/engine/v2/users/current') return; + identities.push((async () => { + migrationExpect(response.ok(), 'staff identity GET must succeed').toBe(true); + const user = await response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + audit.staff_identities.push(identity); + migrationExpect(identity.email).toBe(migrationStaffEmail(info.parallelIndex).toLowerCase()); + migrationExpect(identity.privileged, 'staff-only scenario').toBe(false); + identified.add(context); + })().catch((error: unknown) => { audit.monitoring_errors.push(`Staff identity: ${String(error)}`); })); + }); + }; + for (const page of context.pages()) observe(page); + context.on('page', observe); + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const read = ['GET', 'HEAD', 'OPTIONS'].includes(request.method()); + // findBookingClashes is a source-supported availability query, + // not booking creation (libs/bookings/src/lib/bookings.fn.ts). + // Enabled only for the preserved Batch 21 recurrence scenario. + const clash = MIGRATION_ALLOW_CLASH_QUERY && request.method() === 'POST' && + url.pathname === '/api/staff/v1/bookings/clashing-assets'; + if (origins.has(url.origin) && (read || clash)) { + if (clash) audit.readonly_queries.push({ method: 'POST', path: url.pathname }); + await route.continue(); + } else { + audit.blocked_api_requests.push(`${request.method()} ${url.origin}${url.pathname}`); + await route.abort('blockedbyclient'); + } + }); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(MIGRATION_APP_URL); + const local = url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + else audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED connection; intercepted before server connection' }); + } catch (error) { audit.monitoring_errors.push(`WebSocket interception: ${String(error)}`); } + // No connectToServer call: all connections remain browser-local. + closing.push(socket.close().catch((error: unknown) => { audit.monitoring_errors.push(`WebSocket close: ${String(error)}`); })); + }); + }; + migrationActive = { audit, register }; + try { + await register(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close: ${String(error)}`); } + } + await Promise.all(closing); + await Promise.all(identities); + for (const context of navigated) { + if (!identified.has(context)) audit.monitoring_errors.push('A navigated context did not establish staff identity'); + } + migrationActive = undefined; + try { + await info.attach('legacy-settings-migration-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + migrationExpect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'migration safety audit must be clean').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + migrationExpect(audit.staff_identities.length, 'staff identity must be observed').toBeGreaterThan(0); + } + } + }, { auto: true, timeout: 30_000 }], +}; +const migrationTest = migrationBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +const migrationReadOnlyTest = migrationReadOnlyBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +// Drop the shared default admin state even for legacy staff-auth-only contexts. +migrationTest.use({ storageState: undefined }); +migrationReadOnlyTest.use({ storageState: undefined }); + +// Source: apps/workplace/e2e/local/wpa-settings-batch35.spec.ts +// SHA-256: f2068e77c5a986bfd9c5c8dddf116355672b9cc00aad3fefadbf5cbbf7654f36 +{ +const base = migrationTest; +type Settings = Record; + +interface Isolated { + open(overrides: Settings, route?: string): Promise; +} + +const TIMEZONE = 'Etc/UTC'; + +const test = base.extend<{ isolated: Isolated }>({ + isolated: async ({ browser, staffPage }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + const contexts: BrowserContext[] = []; + const blocked: string[] = []; + const failures: unknown[] = []; + try { + await use({ + open: async (overrides, route = ROUTE) => { + const context = await migrationNewContext.bind(null, browser)({ + storageState: auth, + baseURL: APP_URL, + ignoreHTTPSErrors: true, + timezoneId: TIMEZONE, + locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, + serviceWorkers: 'block', + }); + contexts.push(context); + // Register before page creation/navigation; service workers cannot + // bypass this guard. Auth refresh under /auth remains available. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes( + request.method(), + ) || + ![ + new URL(APP_URL).origin, + new URL(BACKEND_URL).origin, + ].includes(url.origin) + ) { + blocked.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else { + await route.fallback(); + } + }); + const page = await context.newPage(); + // The app's own read establishes staff identity; no extra API call. + const identity = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === + '/api/engine/v2/users/current', + ); + const observed = identity.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const settings = { ...BASE_SETTINGS, ...overrides }; + await stubHiddenAssets(page); + await useSettings(page, settings); + await page.addInitScript(() => + localStorage.setItem('APP.locale', 'en-AU'), + ); + await page.goto(route); + const result = await observed; + if ('error' in result) throw result.error; + expect( + result.response.ok(), + 'the app must load the signed-in staff user', + ).toBe(true); + const user = await result.response.json(); + expect(user.email?.toLowerCase()).toBe( + staffEmail(info.parallelIndex).toLowerCase(), + ); + expect( + user.sys_admin || user.support, + 'staff-only scenario', + ).toBeFalsy(); + expect(new URL(page.url()).origin).toBe( + new URL(APP_URL).origin, + ); + expect( + await page.evaluate(() => + new Date().getTimezoneOffset(), + ), + ).toBe(0); + expect(['UTC', TIMEZONE]).toContain( + await page.evaluate( + () => + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), + ); + const stored = await page.evaluate(() => + localStorage.getItem('PLACEOS.setting_overrides'), + ); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator(READY)).toBeVisible(); + return page; + }, + }); + } finally { + // Fixture teardown is separate from the test body and always checks + // blocked mutations, including when an earlier UI assertion failed. + for (const context of contexts) { + try { + await context.close(); + } catch (error) { + failures.push(error); + } + } + if (blocked.length) + failures.push( + new Error( + `Unexpected API mutation/origin attempts blocked: ${blocked.join(', ')}`, + ), + ); + failures.forEach((error) => console.error(error)); + if (failures.length) throw failures[0]; + } + }, +}); + +test.use({ storageState: undefined, timezoneId: TIMEZONE }); + +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const ROUTE = '/#/landing'; + +const READY = 'topbar'; + +const BASE_SETTINGS: Settings = { + 'app.features': [], + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, + 'app.show_quick_links': false, + 'app.show_quick_book': false, + 'app.use_region': false, +}; + +async function core(page: Page) { + // /landing is now the landing-new component; its host has no header. + await expect(page.locator('landing-new')).toHaveCount(1); + await expect(page.locator('landing-new > *').first()).toBeVisible(); + await expect( + page.locator('topbar button[name="user-controls"]'), + ).toBeVisible(); +} + +test('WPA Batch 35: name supplies the browser title when short_name is empty', async ({ + isolated, +}) => { + const page = await isolated.open({ + 'app.name': 'E2E Workplace Name', + 'app.short_name': '', + }); + await expect(page).toHaveTitle('E2E Workplace Name | Home'); + await core(page); +}); +} diff --git a/apps/workplace/e2e/settings/general/general.short-name.spec.ts b/apps/workplace/e2e/settings/general/general.short-name.spec.ts new file mode 100644 index 00000000000..9b81f5794b5 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.short-name.spec.ts @@ -0,0 +1,361 @@ +// Phase 2 migration: historically accepted; relocated runtime validation PENDING. +// Covered active keys: short_name +// Scenario owners: S60. Shared scenarios are registered once. +// Historical evidence: reports/wpa-settings-reference/preparation/run35-observed-results.json +import type { Browser as MigrationBrowser, BrowserContext as MigrationContext, BrowserContextOptions as MigrationContextOptions, Page as MigrationPage, TestInfo as MigrationTestInfo } from '@playwright/test'; +import { expect as migrationExpect, test as migrationBase } from '../../../../../e2e/support/fixtures'; +import { APP_URL as MIGRATION_APP_URL, BACKEND_URL as MIGRATION_BACKEND_URL, assertLocalOnly as migrationAssertLocalOnly, staffEmail as migrationStaffEmail } from '../../../../../e2e/support/env'; +import { readOnlyTest as migrationReadOnlyBase } from '../../local/wpa-settings-readonly-legacy.fixture'; +import type { BrowserContext } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { BACKEND_URL } from '../../../../../e2e/support/env'; +import { assertLocalOnly } from '../../../../../e2e/support/env'; +import { staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; + +// The landing page starts the hidden asset bootstrap (parking, locker and desk +// categories/types). It creates records when a GET finds none, so serve those +// GETs from fixtures. Non-GET requests fall through to the read-only guards. +const HIDDEN_CATEGORIES = [ + { id: 'pr2-hidden-catering', name: '_CATERING_', hidden: true }, + { id: 'pr2-hidden-desks', name: '_DESKS_', hidden: true }, + { id: 'pr2-hidden-parking', name: '_PARKING_', hidden: true }, + { id: 'pr2-hidden-lockers', name: '_LOCKERS_', hidden: true }, +]; +const HIDDEN_TYPES: Record = { + 'pr2-hidden-catering': [], 'pr2-hidden-desks': ['_DESKS_'], + 'pr2-hidden-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'pr2-hidden-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +async function stubHiddenAssets(page: Page) { + const origins = [new URL(APP_URL).origin, new URL(BACKEND_URL).origin]; + const paths = ['/api/engine/v2/asset_categories', '/api/engine/v2/asset_types', '/api/engine/v2/assets']; + await page.route((url) => origins.includes(url.origin) && paths.includes(url.pathname), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET') return route.fallback(); + if (url.pathname === paths[0] && url.searchParams.get('hidden') === 'true') { + return route.fulfill({ json: HIDDEN_CATEGORIES }); + } + const category = url.searchParams.get('category_id') ?? ''; + if (url.pathname === paths[1] && Object.hasOwn(HIDDEN_TYPES, category)) { + return route.fulfill({ json: HIDDEN_TYPES[category].map((name) => ({ + id: 'pr2-type-' + name, name, category_id: category, brand: 'PlaceOS' })) }); + } + if (url.pathname === paths[2] && (url.searchParams.get('type_id') ?? '').startsWith('pr2-type-')) { + return route.fulfill({ json: [] }); + } + return route.fallback(); + }); +} +const MIGRATION_ALLOW_CLASH_QUERY = false; +// Test-local audit only: no shared fixtures or existing specs are changed. +type MigrationAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + blocked_api_requests: string[]; + readonly_queries: { method: string; path: string }[]; + staff_identities: { email: string; privileged: boolean }[]; +}; +let migrationActive: { + audit: MigrationAudit; + register(context: MigrationContext): Promise; +} | undefined; + +async function migrationNewContext(browser: MigrationBrowser, options: MigrationContextOptions = {}) { + if (!migrationActive) throw new Error('Migration audit must be active before context creation'); + const context = await browser.newContext({ ...options, serviceWorkers: 'block' }); + await migrationActive.register(context); + return context; +} + +const migrationAuditFixture: { migrationAudit: [ + (args: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => Promise, + { auto: true; timeout: number }, +] } = { + migrationAudit: [async ({ staffPage }: { staffPage: MigrationPage }, use: (audit: MigrationAudit) => Promise, info: MigrationTestInfo) => { + migrationAssertLocalOnly(MIGRATION_APP_URL, MIGRATION_BACKEND_URL); + const audit: MigrationAudit = { + websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], + page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + readonly_queries: [], staff_identities: [], + }; + const contexts: MigrationContext[] = []; + const closing: Promise[] = []; + const identities: Promise[] = []; + const navigated = new Set(); + const identified = new Set(); + const origins = new Set([new URL(MIGRATION_APP_URL).origin, new URL(MIGRATION_BACKEND_URL).origin]); + const register = async (context: MigrationContext) => { + contexts.push(context); + const observe = (page: MigrationPage) => { + page.on('framenavigated', (frame) => { + if (frame === page.mainFrame() && frame.url().startsWith(new URL(MIGRATION_APP_URL).origin + '/')) navigated.add(context); + }); + page.on('pageerror', (error) => audit.page_errors.push(String(error))); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) audit.app_errors.push(message.text().slice(0, 500)); + }); + page.on('response', (response) => { + const url = new URL(response.url()); + if (response.request().method() !== 'GET' || !origins.has(url.origin) || url.pathname !== '/api/engine/v2/users/current') return; + identities.push((async () => { + migrationExpect(response.ok(), 'staff identity GET must succeed').toBe(true); + const user = await response.json(); + const identity = { email: user.email?.toLowerCase() ?? '', privileged: !!(user.sys_admin || user.support) }; + audit.staff_identities.push(identity); + migrationExpect(identity.email).toBe(migrationStaffEmail(info.parallelIndex).toLowerCase()); + migrationExpect(identity.privileged, 'staff-only scenario').toBe(false); + identified.add(context); + })().catch((error: unknown) => { audit.monitoring_errors.push(`Staff identity: ${String(error)}`); })); + }); + }; + for (const page of context.pages()) observe(page); + context.on('page', observe); + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const read = ['GET', 'HEAD', 'OPTIONS'].includes(request.method()); + // findBookingClashes is a source-supported availability query, + // not booking creation (libs/bookings/src/lib/bookings.fn.ts). + // Enabled only for the preserved Batch 21 recurrence scenario. + const clash = MIGRATION_ALLOW_CLASH_QUERY && request.method() === 'POST' && + url.pathname === '/api/staff/v1/bookings/clashing-assets'; + if (origins.has(url.origin) && (read || clash)) { + if (clash) audit.readonly_queries.push({ method: 'POST', path: url.pathname }); + await route.continue(); + } else { + audit.blocked_api_requests.push(`${request.method()} ${url.origin}${url.pathname}`); + await route.abort('blockedbyclient'); + } + }); + await context.routeWebSocket('**/*', (socket) => { + try { + const url = new URL(socket.url()); + const app = new URL(MIGRATION_APP_URL); + const local = url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + const tooling = local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; + const control = local && url.pathname === '/api/engine/v2/systems/control'; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + if (tooling) { + audit.tooling_websockets.push({ url: url.href, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (control) audit.blocked_control_attempts.push({ url: url.href, + classification: 'known Workplace shell initialization; intercepted and closed before server connection' }); + else audit.websocket_attempts.push({ url: url.href, classification: 'UNEXPECTED connection; intercepted before server connection' }); + } catch (error) { audit.monitoring_errors.push(`WebSocket interception: ${String(error)}`); } + // No connectToServer call: all connections remain browser-local. + closing.push(socket.close().catch((error: unknown) => { audit.monitoring_errors.push(`WebSocket close: ${String(error)}`); })); + }); + }; + migrationActive = { audit, register }; + try { + await register(staffPage.context()); + await use(audit); + } finally { + for (const context of contexts) { + try { await context.close(); } + catch (error) { audit.monitoring_errors.push(`Context close: ${String(error)}`); } + } + await Promise.all(closing); + await Promise.all(identities); + for (const context of navigated) { + if (!identified.has(context)) audit.monitoring_errors.push('A navigated context did not establish staff identity'); + } + migrationActive = undefined; + try { + await info.attach('legacy-settings-migration-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + migrationExpect({ websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + blocked_api_requests: audit.blocked_api_requests }, 'migration safety audit must be clean').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], blocked_api_requests: [], + }); + migrationExpect(audit.staff_identities.length, 'staff identity must be observed').toBeGreaterThan(0); + } + } + }, { auto: true, timeout: 30_000 }], +}; +const migrationTest = migrationBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +const migrationReadOnlyTest = migrationReadOnlyBase.extend<{ migrationAudit: MigrationAudit }>(migrationAuditFixture); +// Drop the shared default admin state even for legacy staff-auth-only contexts. +migrationTest.use({ storageState: undefined }); +migrationReadOnlyTest.use({ storageState: undefined }); + +// Source: apps/workplace/e2e/local/wpa-settings-batch35.spec.ts +// SHA-256: f2068e77c5a986bfd9c5c8dddf116355672b9cc00aad3fefadbf5cbbf7654f36 +{ +const base = migrationTest; +type Settings = Record; + +interface Isolated { + open(overrides: Settings, route?: string): Promise; +} + +const TIMEZONE = 'Etc/UTC'; + +const test = base.extend<{ isolated: Isolated }>({ + isolated: async ({ browser, staffPage }, use, info) => { + assertLocalOnly(APP_URL, BACKEND_URL); + const auth = await staffPage.context().storageState(); + const contexts: BrowserContext[] = []; + const blocked: string[] = []; + const failures: unknown[] = []; + try { + await use({ + open: async (overrides, route = ROUTE) => { + const context = await migrationNewContext.bind(null, browser)({ + storageState: auth, + baseURL: APP_URL, + ignoreHTTPSErrors: true, + timezoneId: TIMEZONE, + locale: 'en-AU', + viewport: { width: 1440, height: 1100 }, + serviceWorkers: 'block', + }); + contexts.push(context); + // Register before page creation/navigation; service workers cannot + // bypass this guard. Auth refresh under /auth remains available. + await context.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes( + request.method(), + ) || + ![ + new URL(APP_URL).origin, + new URL(BACKEND_URL).origin, + ].includes(url.origin) + ) { + blocked.push(`${request.method()} ${url.pathname}`); + await route.abort('blockedbyclient'); + } else { + await route.fallback(); + } + }); + const page = await context.newPage(); + // The app's own read establishes staff identity; no extra API call. + const identity = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === + '/api/engine/v2/users/current', + ); + const observed = identity.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const settings = { ...BASE_SETTINGS, ...overrides }; + await stubHiddenAssets(page); + await useSettings(page, settings); + await page.addInitScript(() => + localStorage.setItem('APP.locale', 'en-AU'), + ); + await page.goto(route); + const result = await observed; + if ('error' in result) throw result.error; + expect( + result.response.ok(), + 'the app must load the signed-in staff user', + ).toBe(true); + const user = await result.response.json(); + expect(user.email?.toLowerCase()).toBe( + staffEmail(info.parallelIndex).toLowerCase(), + ); + expect( + user.sys_admin || user.support, + 'staff-only scenario', + ).toBeFalsy(); + expect(new URL(page.url()).origin).toBe( + new URL(APP_URL).origin, + ); + expect( + await page.evaluate(() => + new Date().getTimezoneOffset(), + ), + ).toBe(0); + expect(['UTC', TIMEZONE]).toContain( + await page.evaluate( + () => + Intl.DateTimeFormat().resolvedOptions() + .timeZone, + ), + ); + const stored = await page.evaluate(() => + localStorage.getItem('PLACEOS.setting_overrides'), + ); + expect(JSON.parse(stored ?? 'null')).toEqual(settings); + await expect(page.locator(READY)).toBeVisible(); + return page; + }, + }); + } finally { + // Fixture teardown is separate from the test body and always checks + // blocked mutations, including when an earlier UI assertion failed. + for (const context of contexts) { + try { + await context.close(); + } catch (error) { + failures.push(error); + } + } + if (blocked.length) + failures.push( + new Error( + `Unexpected API mutation/origin attempts blocked: ${blocked.join(', ')}`, + ), + ); + failures.forEach((error) => console.error(error)); + if (failures.length) throw failures[0]; + } + }, +}); + +test.use({ storageState: undefined, timezoneId: TIMEZONE }); + +test.describe.configure({ retries: 0, timeout: 120_000 }); + +const ROUTE = '/#/landing'; + +const READY = 'topbar'; + +const BASE_SETTINGS: Settings = { + 'app.features': [], + 'app.allow_dark_mode': false, + 'app.chat.enabled': false, + 'app.show_quick_links': false, + 'app.show_quick_book': false, + 'app.use_region': false, +}; + +async function core(page: Page) { + // /landing is now the landing-new component; its host has no header. + await expect(page.locator('landing-new')).toHaveCount(1); + await expect(page.locator('landing-new > *').first()).toBeVisible(); + await expect( + page.locator('topbar button[name="user-controls"]'), + ).toBeVisible(); +} + +test('WPA Batch 35: short_name takes precedence over the full name in the browser title', async ({ + isolated, +}) => { + const page = await isolated.open({ + 'app.name': 'E2E Full Name', + 'app.short_name': 'E2E Short', + }); + await expect(page).toHaveTitle('E2E Short | Home'); + await expect(page).not.toHaveTitle(/E2E Full Name/); + await core(page); +}); +} diff --git a/apps/workplace/e2e/settings/parking-booking/parking-booking.show-waitlist.spec.ts b/apps/workplace/e2e/settings/parking-booking/parking-booking.show-waitlist.spec.ts new file mode 100644 index 00000000000..05c62918814 --- /dev/null +++ b/apps/workplace/e2e/settings/parking-booking/parking-booking.show-waitlist.spec.ts @@ -0,0 +1,365 @@ +import type { Locator, Page, Route } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/parking/parking.settings'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// Migrated unchanged from apps/workplace/e2e/local/claude-wpa-next25-parking-booking-draft.spec.ts (accepted: wpa-next25-P1..P4/P7); only this setting's tests. + + +// CLAUDE OWNED DRAFT. NOT RUN. No accepted coverage credit until a controlled run passes. +// Target: 11 parking booking-form / display keys. Routes: /#/book/parking/form, +// /#/book/parking/success, /#/book/parking-request/success, /#/your-bookings. +// Nothing is submitted. Synthetic data is served only by GET browser routes or +// a sessionStorage seed; no backend record is created or changed. +type Settings = Record; +type Debug = Window & { setting?: (key: string) => unknown }; +type Audit = { + websocket_attempts: string[]; + blocked_control_attempts: string[]; + tooling_websockets: string[]; + page_errors: string[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +function safeURL(raw: string) { + const url = new URL(raw); + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} +// Same policy as the corrected Batch 2/4/5 drafts. HTTP: the parent +// readOnlyTest guard aborts every non-GET or non-local /api request and fails +// the test in teardown. WebSockets: the Vite dev-server socket is held open, +// the shell's local control socket is closed before it connects and recorded, +// and any other socket fails. +const test = readOnlyTest.extend<{ audit: Audit }>({ + audit: [async ({ staffPage }, use, info) => { + const context = staffPage.context(); + const audit: Audit = { websocket_attempts: [], blocked_control_attempts: [], + tooling_websockets: [], page_errors: [], app_errors: [], monitoring_errors: [], fixtures: {} }; + const observe = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push(error.name + ': ' + error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text())) { + audit.app_errors.push(message.text().slice(0, 500)); + } + }); + }; + observe(staffPage); + context.on('page', observe); + const closing: Promise[] = []; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = safeURL(url.href); + const app = new URL(APP_URL); + const local = url.host === app.host && + url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:'); + // Vite client.mjs:856-865; closing it reloads the page (:993-998). + if (local && url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token') { + audit.tooling_websockets.push(socket_url); + return; + } + if (local && url.pathname === '/api/engine/v2/systems/control') { + audit.blocked_control_attempts.push(socket_url); + } else audit.websocket_attempts.push(socket_url); + } catch (error) { + audit.monitoring_errors.push('WebSocket interception for ' + socket_url + ': ' + String(error)); + } + closing.push(socket.close().catch((error: unknown) => { + audit.monitoring_errors.push('WebSocket close for ' + socket_url + ': ' + String(error)); + })); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push('Context close: ' + String(error)); + } + await Promise.all(closing); + try { + await info.attach('claude-next25-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + ws: audit.websocket_attempts, page: audit.page_errors, + app: audit.app_errors, monitoring: audit.monitoring_errors, + }, 'zero unexpected WebSocket, application or monitoring failures').toEqual({ + ws: [], page: [], app: [], monitoring: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** Origin-limited GET-only responses. Non-GET always falls through to the guard. */ +async function getOnly(page: Page, audit: Audit, name: string, match: (url: URL) => boolean, + body: (url: URL) => unknown | Promise) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const json = await body(new URL(route.request().url())); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json }); + } catch (error) { + audit.monitoring_errors.push(name + ' fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +// ensure_hidden_category / ensure_type POST when a GET finds nothing and +// move_type_to_category PUTs on a category mismatch (parking-assets.fn.ts:71-157, +// locker-assets.fn.ts). One shared response carries every hidden category. +const CATEGORIES = [ + { id: 'claude-next25-catering', name: '_CATERING_', hidden: true }, + { id: 'claude-next25-desks', name: '_DESKS_', hidden: true }, + { id: 'claude-next25-parking', name: '_PARKING_', hidden: true }, + { id: 'claude-next25-lockers', name: '_LOCKERS_', hidden: true }, +]; +const TYPES: Record = { + 'claude-next25-catering': [], 'claude-next25-desks': ['_DESKS_'], + 'claude-next25-parking': ['_PARKING_SPACES_', '_PARKING_USERS_', '_PARKING_FLEET_VEHICLES_'], + 'claude-next25-lockers': ['_LOCKER_BANKS_', '_LOCKERS_'], +}; +const COMMON: Settings = { + ...READ_ONLY_SETTINGS, + 'app.chat.enabled': false, 'app.global_search': false, 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], 'app.use_region': false, 'app.use_geolocation': false, + 'app.allow_dark_mode': false, 'app.use_24_hour_time': true, +}; +async function prepare(page: Page, audit: Audit, settings: Settings, + bookings: (type: string) => unknown[] | Promise = () => []) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await getOnly(page, audit, 'hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', () => CATEGORIES); + await getOnly(page, audit, 'hidden types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('limit') === '500' && Object.hasOwn(TYPES, url.searchParams.get('category_id') ?? ''), + (url) => TYPES[url.searchParams.get('category_id')!].map((name) => ({ + id: 'claude-next25-type-' + name, name, category_id: url.searchParams.get('category_id'), brand: 'PlaceOS', + }))); + await getOnly(page, audit, 'hidden assets', (url) => url.pathname === '/api/engine/v2/assets' && + (url.searchParams.get('type_id') ?? '').startsWith('claude-next25-type-'), () => []); + await getOnly(page, audit, 'calendar list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + // Parking/locker booking lists (ParkingService.loadBookings, locker flow + // _loadTodaysBookings, schedule queryAllBookings): empty unless a test + // supplies a synthetic GET row. Other booking types reach the real backend. + await getOnly(page, audit, 'parking/locker bookings', (url) => url.pathname === '/api/staff/v1/bookings' && + ['parking', 'locker'].includes(url.searchParams.get('type') ?? ''), + (url) => bookings(url.searchParams.get('type')!)); + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + return { + ready: async (bootstrap = true) => { + await expect(page.locator('global-loading')).toHaveCount(1); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect(page.locator('global-loading [initialisation-error]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => page.evaluate((name) => (window as Debug).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + // Pages that start a hidden-asset bootstrap must read the fixture, never create records. + if (bootstrap) await expect.poll(() => audit.fixtures['hidden categories'] ?? 0, + { message: 'hidden-category bootstrap served by the fixture' }).toBeGreaterThan(0); + }, + }; +} + + +// ------------------------------------------------------------- shared form helpers +type Identity = { id: string; email: string; name: string }; +/** The staff identity the app itself reads; no extra API call is made. */ +function watchIdentity(page: Page) { + let identity: Promise | undefined; + page.on('response', (response) => { + const url = new URL(response.url()); + if (ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/users/current' && + response.request().method() === 'GET') identity = response.json(); + }); + return async () => { + await expect.poll(() => !!identity, { message: 'the app must read the staff identity' }).toBe(true); + return identity!; + }; +} +async function openCalendar(page: Page, root: Locator) { + const picker = page.locator('.cdk-overlay-container date-calendar'); + // Forms re-render after async init, so retry until the picker opens. + await expect(async () => { + if (!(await picker.isVisible().catch(() => false))) { + await root.locator('a-date-field button').first().click({ timeout: 5_000 }); + } + await expect(picker).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 30_000 }); + return picker; +} +/** + * The calendar cell `offset` days after today. Cells are consecutive days, so + * the index is counted from the cell carrying today's ring. If the day is past + * the grid, move one month on: the right chevron (named schedule-previous-month + * in date-calendar.component.ts) is disabled once the grid's last cell is past + * `to`, so a later day cannot be selectable and null is returned. + */ +async function dayCell(page: Page, picker: Locator, offset: number): Promise { + const cells = picker.locator('button[name="schedule-set-date"]'); + await expect(cells.first()).toBeVisible(); + const today = await cells.evaluateAll((elements) => + elements.findIndex((element) => !!element.querySelector('div.border-secondary'))); + expect(today, 'the calendar marks today').toBeGreaterThanOrEqual(0); + if (today + offset < await cells.count()) return cells.nth(today + offset); + const next = picker.locator('button[name="schedule-previous-month"]'); + if (await next.isDisabled()) return null; + const day = await page.evaluate((days) => { + const date = new Date(); date.setDate(date.getDate() + days); return date.getDate(); + }, offset); + await next.click(); + // Out-of-month cells carry text-base-300!; pick the in-month day. + const cell = picker.locator('button[name="schedule-set-date"]:not([class*="text-base-300"])') + .filter({ hasText: new RegExp('^\\s*' + day + '\\s*$') }); + await expect(cell).toHaveCount(1); + return cell; +} +/** Whether the day `offset` days ahead is selectable. Closes the picker unchanged. */ +async function dayEnabled(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + const enabled = cell ? await cell.isEnabled() : false; + await page.keyboard.press('Escape'); + await expect(picker).toHaveCount(0); + return enabled; +} +/** Chooses a day in the local form model only. */ +async function pickDay(page: Page, root: Locator, offset: number) { + const picker = await openCalendar(page, root); + const cell = await dayCell(page, picker, offset); + expect(cell, offset + ' days ahead must be reachable').not.toBeNull(); + await expect(cell!).toBeEnabled(); + await cell!.click(); + await expect(picker).toHaveCount(0); +} +/** Values of a time/duration menu (data-time "HH:mm" or data-duration minutes). */ +async function menuValues(page: Page, trigger: Locator, attribute: 'data-time' | 'data-duration') { + await expect(trigger).toBeEnabled(); + await trigger.click(); + const options = page.locator('.cdk-overlay-container button[' + attribute + ']'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements, name) => + elements.map((element) => element.getAttribute(name)!), attribute); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return values.map((value) => { + if (attribute === 'data-duration') return Number(value); + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} +/** Real building rows with timezone rewritten (as in the accepted next20 draft). */ +async function timezoneBuildings(page: Page, audit: Audit) { + await page.addInitScript(() => { + for (const key of Object.keys(localStorage)) if (key.startsWith('PLACEOS.org')) localStorage.removeItem(key); + }); + await page.route((url) => ORIGINS.has(url.origin) && url.pathname === '/api/engine/v2/zones' && + url.searchParams.get('tags') === 'building' && url.searchParams.get('limit') === '500', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const body = await response.json(); + const rows: Record[] = Array.isArray(body) ? body : body?.results ?? []; + audit.fixtures['timezone buildings'] = (audit.fixtures['timezone buildings'] ?? 0) + rows.length; + const patched = rows.map((row) => ({ ...row, timezone: 'Pacific/Honolulu' })); + await route.fulfill({ response, json: Array.isArray(body) ? patched : { ...body, results: patched } }); + } catch (error) { + audit.monitoring_errors.push('building timezone fixture: ' + String(error)); + await route.abort('failed').catch(() => undefined); + } + }); +} +const HH = (h: number, m = 0) => h * 60 + m; + +// ------------------------------------------------------------- parking booking form +// Consumer: apps/workplace/src/app/book/parking-flow/parking-form-details.component.ts +// getters :190-258 (`parking || bookings` fallbacks), template :66-136. +const BOOKING: Settings = { + ...COMMON, + 'app.features': ['parking'], 'app.desks.use_assets': false, + 'app.parking.restrict_home_location': false, 'app.parking.require_space_restriction': false, + 'app.parking.default_duration': 60, + 'app.bookings.all_day_default': false, 'app.parking.all_day_default': null, + 'app.bookings.allow_all_day': false, 'app.parking.allow_all_day': false, + 'app.bookings.bookable_hours': null, 'app.parking.bookable_hours': null, + 'app.bookings.max_duration': null, 'app.parking.max_duration': null, + 'app.bookings.min_duration': null, 'app.parking.min_duration': null, + 'app.bookings.custom_duration_options': null, 'app.parking.custom_duration_options': null, + 'app.bookings.can_book_for_others': null, 'app.parking.can_book_for_others': false, + 'app.bookings.use_building_timezone': false, 'app.parking.use_building_timezone': false, + 'app.parking.available_period': null, + 'app.parking.show_calendar_links': false, + 'app.parking.hide_selected_space': false, 'app.parking.show_waitlist': true, +}; +async function parkingForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }); + await page.goto('/#/book/parking/form'); + await fixture.ready(); + const root = page.locator('parking-form-details'); + await expect(root).toBeVisible(); + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + return root; +} +/** Confirm Reservation only changes the view; it is never clicked. */ +async function untouchedParking(page: Page) { + await expect(page.locator('parking-flow-form button[confirm]')).toBeVisible(); + await expect(page.locator('parking-flow-confirm')).toHaveCount(0); + await expect(page.locator('parking-flow-success')).toHaveCount(0); +} +const startTimes = (page: Page, root: Locator) => + menuValues(page, root.locator('a-time-field[name="start-time"] button[time-field]'), 'data-time'); +const durations = (page: Page, root: Locator) => menuValues(page, root.locator('button[duration-field]'), 'data-duration'); +// ------------------------------------------------------------- your bookings card +// booking-card.component.ts:210-243 (show_waitlist -> status 'info'), +// :214-217,311-324 (hide_selected_space -> "Parking" label). The schedule's +// parking list GET (schedule-state.service.ts:692-720) returns one synthetic +// row; no card, modal or action is clicked. +async function scheduleCard(page: Page, audit: Audit, overrides: Settings, row: Record) { + const user = watchIdentity(page); + const start = Math.floor(Date.now() / 1000) + 120; + const midnight = Math.floor(new Date().setUTCHours(24, 0, 0, 0) / 1000); + const end = Math.min(start + 1800, midnight - 60); + expect(end - start, 'run outside the last minutes of the UTC day so the row stays on today').toBeGreaterThan(300); + const fixture = await prepare(page, audit, { ...BOOKING, ...overrides }, async (type) => { + if (type !== 'parking') return []; + const me = await user(); + return [{ + id: 'claude-next25-parking-1', booking_type: 'parking', title: 'E2E Synthetic Reservation', + asset_id: 'claude-next25-bay', asset_name: 'E2E Synthetic Bay 7', description: '', + booking_start: start, booking_end: end, user_id: me.id, user_email: me.email, user_name: me.name, + rejected: false, deleted: false, zones: [], extension_data: {}, ...row, + }]; + }); + await page.goto('/#/your-bookings?view=list'); + await fixture.ready(); + const card = page.locator('a[name="view-booking-details"]').filter({ hasText: 'E2E Synthetic Reservation' }); + await expect(card).toHaveCount(1); + expect(audit.fixtures['parking/locker bookings'] ?? 0).toBeGreaterThan(0); + return card; +} + +test.describe('WPA next25 DRAFT: parking booking card', () => { + for (const shown of [true, false]) { + test('parking.show_waitlist ' + shown + ' colours a wait-listed request', async ({ staffPage, audit }) => { + // parkingRequestStatus (booking.utilities.ts:34-43): unapproved, not manual, process_state wait_list. + const card = await scheduleCard(staffPage, audit, { 'app.parking.show_waitlist': shown }, + { approved: false, process_state: 'wait_list' }); + const pill = card.locator('status-pill > div'); + await expect(pill).toHaveClass(shown ? /\bbg-info-light\b/ : /\bbg-warning-light\b/); + await expect(pill).not.toHaveClass(shown ? /\bbg-warning-light\b/ : /\bbg-info-light\b/); + }); + } +}); diff --git a/apps/workplace/e2e/settings/room-booking/room-booking.hide-nearby-desks.spec.ts b/apps/workplace/e2e/settings/room-booking/room-booking.hide-nearby-desks.spec.ts new file mode 100644 index 00000000000..c949a9ecdd4 --- /dev/null +++ b/apps/workplace/e2e/settings/room-booking/room-booking.hide-nearby-desks.spec.ts @@ -0,0 +1,195 @@ +import type { ConsoleMessage, Page, Route, WebSocketRoute } from '@playwright/test'; +import { ROOM_BOOKINGS_MODE, useSettings } from '../../../../../e2e/support/room/room.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS, readOnlyTest } from '../../local/wpa-settings-readonly-legacy.fixture'; + +// DRAFT / NOT EXECUTED. events.hide_nearby_desks gates the "book nearby desk" +// button on the meeting success screen (meeting-flow-success.component.ts:132-137, +// template :78 `@if (space?.email && allow_desk_booking)`, where +// allow_desk_booking = features includes 'desks' && hide_nearby_desks !== true). +// +// How the screen is reached without a booking: route book/meeting/:step +// (book.routes.ts) -> meeting-flow.component.ts setView('success') renders +// , whose last_success comes from +// EventFormService.loadLastSuccess = new CalendarEvent(JSON.parse( +// sessionStorage['PLACEOS.last_modified_event'])) (event-form.service.ts:413-421,514). +// Why the seed cannot become a booking: sessionStorage belongs to this browser +// context only and is only READ here; the success component's ngOnInit makes one +// GET (SpacePipe lookup, answered below) and never calls postForm/saveEvent; +// the nearby-desk button and the continue link are never clicked; every non-GET +// request still reaches the read-only guard, which fails the test. +type Settings = Record; +type DebugWindow = Window & { setting?: (key: string) => unknown }; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); +const CATEGORY = 'e2e-draft-empty-catering'; +const ROOM = { id: 'e2e-draft-success-room', email: 'draft-success-room@example.invalid', name: 'Draft success room', zones: [] }; + +// ---------------------------------------------------------------- safety audit +// Same policy as the corrected Batch 2/4/5 drafts. +type SafetyAudit = { + websocket_attempts: { url: string; classification: string }[]; + blocked_control_attempts: { url: string; classification: string }[]; + tooling_websockets: { url: string; classification: string }[]; + page_errors: { name: string; message: string; stack?: string }[]; + app_errors: string[]; + monitoring_errors: string[]; + fixtures: Record; +}; + +function redact(url: URL) { + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, '[redacted]'); + return url.href; +} + +/** Only the Angular dev server's Vite client socket (vite client.mjs:856-865, reload on close :993-998). */ +function isDevServerSocket(url: URL) { + const app = new URL(APP_URL); + return url.host === app.host && url.protocol === (app.protocol === 'https:' ? 'wss:' : 'ws:') && + url.pathname === '/' && [...url.searchParams.keys()].join(',') === 'token'; +} + +function isLocalControlSocket(url: URL) { + const app = new URL(APP_URL); + const protocol = app.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.protocol === protocol && url.host === app.host && + url.pathname === '/api/engine/v2/systems/control'; +} + +/** ErrorHandler logs console.error('ERROR', e); Angular runtime errors log NG0xxx. */ +function isAppError(message: ConsoleMessage) { + return message.type() === 'error' && /^(ERROR\b|NG0\d+)/.test(message.text()); +} + +const test = readOnlyTest.extend<{ safetyAudit: SafetyAudit }>({ + safetyAudit: [async ({ staffPage }, use, info) => { + const audit: SafetyAudit = { websocket_attempts: [], blocked_control_attempts: [], tooling_websockets: [], page_errors: [], + app_errors: [], monitoring_errors: [], fixtures: {} }; + const context = staffPage.context(); + const observePage = (page: Page) => { + page.on('pageerror', (error) => audit.page_errors.push({ name: error.name, message: error.message, stack: error.stack })); + page.on('console', (message) => { + if (isAppError(message)) audit.app_errors.push(message.text().slice(0, 500)); + }); + }; + observePage(staffPage); + context.on('page', observePage); + const closing: Promise[] = []; + const closeSocket = async (socket: WebSocketRoute, url: string) => { + try { + await socket.close(); + } catch (error) { + audit.monitoring_errors.push(`WebSocket close failed for ${url}: ${String(error)}`); + } + }; + try { + await context.routeWebSocket('**/*', (socket) => { + let socket_url = ''; + try { + const url = new URL(socket.url()); + socket_url = redact(url); + if (isDevServerSocket(url)) { + audit.tooling_websockets.push({ url: socket_url, classification: 'dev-server live reload; mocked and held open, never connected' }); + return; + } + if (isLocalControlSocket(url)) { + audit.blocked_control_attempts.push({ + url: socket_url, + classification: 'known Workplace shell initialization; intercepted and closed before server connection', + }); + } else { + const kind = url.pathname.startsWith('/api/engine/v2/chatgpt/chat/') ? 'chat' : + ['/api/engine/v2/systems/control', '/control/websocket'].includes(url.pathname) ? 'driver/control' : 'other'; + audit.websocket_attempts.push({ url: socket_url, classification: `UNEXPECTED ${kind}; success screen is HTTP-only` }); + } + } catch (error) { + audit.monitoring_errors.push(`WebSocket interception failed for ${socket_url}: ${String(error)}`); + } + closing.push(closeSocket(socket, socket_url)); + }); + await use(audit); + } finally { + try { + await context.close(); + } catch (error) { + audit.monitoring_errors.push(`Context close failed: ${String(error)}`); + } + await Promise.all(closing); + try { + await info.attach('room-success-safety-audit', { + body: Buffer.from(JSON.stringify(audit, null, 2)), contentType: 'application/json', + }); + } finally { + expect({ + websocket_attempts: audit.websocket_attempts, page_errors: audit.page_errors, + app_errors: audit.app_errors, monitoring_errors: audit.monitoring_errors, + }, 'no WebSocket attempt, application exception or monitoring failure is permitted').toEqual({ + websocket_attempts: [], page_errors: [], app_errors: [], monitoring_errors: [], + }); + } + } + }, { auto: true, timeout: 15_000 }], +}); + +/** A named, counted, GET-only browser response. Every other method reaches the read-only guard. */ +async function stubGet(page: Page, audit: SafetyAudit, name: string, + match: (url: URL) => boolean, body: (url: URL) => unknown) { + await page.route((url) => ORIGINS.has(url.origin) && match(url), async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + audit.fixtures[name] = (audit.fixtures[name] ?? 0) + 1; + await route.fulfill({ json: body(new URL(route.request().url())) }); + }); +} + +for (const [label, features, hidden, shown] of [ + ['desks feature, hide_nearby_desks false', ['spaces', 'desks'], false, true], + ['desks feature, hide_nearby_desks true', ['spaces', 'desks'], true, false], + ['no desks feature, hide_nearby_desks false (control)', ['spaces'], false, false], +] as const) { + test(`WPA draft: events.hide_nearby_desks success screen with ${label}`, async ({ staffPage, safetyAudit }) => { + assertLocalOnly(APP_URL, BACKEND_URL); + await staffPage.setViewportSize({ width: 1440, height: 1100 }); + // Browser-local seed of a clearly synthetic, already-"created" meeting. + const start = new Date(); start.setDate(start.getDate() + 1); start.setHours(10, 0, 0, 0); + await staffPage.addInitScript((event) => { + sessionStorage.setItem('PLACEOS.last_modified_event', JSON.stringify(event)); + }, { id: 'e2e-draft-success-event', title: 'Draft success preview', date: start.valueOf(), duration: 60, resources: [ROOM] }); + // SpacePipe._loadSpace: an email id skips showSystem and uses + // GET /api/engine/v2/systems/with_emails?in=; no match keeps the seeded room. + await stubGet(staffPage, safetyAudit, 'seeded room lookup', (url) => url.pathname === '/api/engine/v2/systems/with_emails' && + url.searchParams.get('in') === ROOM.email, () => []); + // Form state still initialises: keep the calendar provider and catering bootstrap local. + await stubGet(staffPage, safetyAudit, 'calendar provider list', (url) => url.pathname === '/api/staff/v1/calendars', () => []); + await stubGet(staffPage, safetyAudit, 'catering hidden categories', (url) => url.pathname === '/api/engine/v2/asset_categories' && + url.searchParams.get('hidden') === 'true' && url.searchParams.get('limit') === '500', + () => [{ id: CATEGORY, name: '_CATERING_', hidden: true }]); + await stubGet(staffPage, safetyAudit, 'catering types', (url) => url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('category_id') === CATEGORY && url.searchParams.get('limit') === '500', () => []); + const settings: Settings = { + ...READ_ONLY_SETTINGS, ...ROOM_BOOKINGS_MODE, + 'app.features': [...features], 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, 'app.schedule.event_sources': ['api'], + 'app.catering.backend': 'native', 'app.events.hide_nearby_desks': hidden, + // Desk asset bootstrap (POST of _DESKS_) stays off: metadata desks only. + 'app.desks.use_assets': false, + }; + await useSettings(staffPage, settings); + await staffPage.addInitScript(() => localStorage.setItem('APP.locale', 'en-US')); + await staffPage.goto('/#/book/meeting/success'); + await expect(staffPage.locator('global-loading [loader]')).toHaveCount(0); + for (const [key, value] of Object.entries(settings)) { + await expect.poll(() => staffPage.evaluate((key) => (window as DebugWindow).setting?.(key), key)).toEqual(value); + } + const success = staffPage.locator('meeting-flow-success'); + const finished = success.locator('a[name="meeting-created-continue"]'); + // Rendered after the component's 500ms loading delay. + await expect(finished).toBeVisible(); + // The seeded synthetic meeting (not any real booking) is what is shown. + await expect(success.locator('main')).toContainText(ROOM.name); + expect(safetyAudit.fixtures['seeded room lookup'] ?? 0, 'the seeded room lookup must be answered locally').toBeGreaterThan(0); + // The only