diff --git a/.github/workflows/e2e-advisory.yml b/.github/workflows/e2e-advisory.yml index 08dc654e140..74906ee4265 100644 --- a/.github/workflows/e2e-advisory.yml +++ b/.github/workflows/e2e-advisory.yml @@ -82,6 +82,11 @@ concurrency: permissions: contents: read +# Which Playwright projects run on which trigger: +# push, schedule, workflow_dispatch `local` and `mock` (job `workplace`) +# schedule, workflow_dispatch also `settings` (job `workplace`, own step) +# push, schedule, workflow_dispatch `settings-browser` (job `settings-browser`, +# GitHub-hosted, no stack) jobs: workplace: # The job name is what appears in the commit's Checks list, next to the @@ -93,16 +98,21 @@ jobs: # To fall back to GitHub-hosted, set `runs-on: ubuntu-latest` and restore # the `Raise vm.max_map_count` step (see below). runs-on: [self-hosted, placeos-e2e] - # Includes up to 120 minutes waiting for the native e2e job (see "Wait for - # the native e2e job to finish"). - timeout-minutes: 165 + # 120 minutes waiting for the native e2e job (see "Wait for the native e2e + # job to finish"), 45 for setup, the stack and the `local` and `mock` + # tests, and 60 for the `settings` step's own timeout on scheduled and + # manual runs. + timeout-minutes: 225 env: E2E_BACKEND_URL: https://localhost:9443 # The runner is a 12-core, 16 GB Mac that also hosts the stack's # containers, the Angular dev server and the browsers. Seeding reads # this too (one staff user, desk and room set per worker), so it must - # be the same here as in the test step. - E2E_WORKERS: '4' + # be the same here as in the test step. Six workers became faster + # than four once the specs paused the animated page background + # (e2e/support/background.ts); before that the browsers used all + # the CPU. + E2E_WORKERS: '6' TZ: 'Etc/UTC' steps: # persist-credentials: false — checkout otherwise leaves the token in @@ -151,6 +161,8 @@ jobs: - name: Reclaim the machine from any previous run run: | e2e/stack/down.sh --volumes || true + # The `settings` step's reports, which a push run does not rewrite. + rm -rf reports/e2e/workplace-settings reports/e2e/workplace-settings-results.json # Playwright refuses to start its webServer if 4214 is held. lsof -nP -iTCP:4214 -sTCP:LISTEN -t 2>/dev/null | xargs -r kill -9 || true # Images the previous run's pull replaced. A running stack's images @@ -260,11 +272,37 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Run workplace e2e - run: bunx playwright test --config apps/workplace/playwright.config.ts + id: local + run: bunx playwright test --config apps/workplace/playwright.config.ts --project=local --project=mock + + # The Workplace settings suite: about 400 tests, about 12 minutes on + # this machine with six workers. Scheduled and manual runs only. It + # also runs after a failed `local` step, but not when that step was + # skipped (the stack never came up). + # + # The PLAYWRIGHT_* variables and --output move this run's reports and + # test artifacts away from the `local` run's, which Playwright would + # otherwise overwrite. + - name: Run workplace settings e2e + id: settings + if: ${{ !cancelled() && steps.local.outcome != 'skipped' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + timeout-minutes: 60 + env: + PLAYWRIGHT_HTML_OUTPUT_DIR: reports/e2e/workplace-settings + PLAYWRIGHT_JSON_OUTPUT_FILE: reports/e2e/workplace-settings-results.json + run: >- + bunx playwright test --config apps/workplace/playwright.config.ts + --project=settings --output=dist/.playwright/workplace-settings - name: Summarise results if: ${{ always() }} - run: node e2e/stack/ci-summary.mjs >> "$GITHUB_STEP_SUMMARY" + env: + SETTINGS_OUTCOME: ${{ steps.settings.outcome }} + run: | + node e2e/stack/ci-summary.mjs >> "$GITHUB_STEP_SUMMARY" + if [ "$SETTINGS_OUTCOME" != skipped ]; then + node e2e/stack/ci-summary.mjs reports/e2e/workplace-settings-results.json 'workplace settings e2e' >> "$GITHUB_STEP_SUMMARY" + fi # Collect EVERY service, not a hand-picked list. The first run failed # in Elasticsearch — which was not on the list — so the log that @@ -289,6 +327,8 @@ jobs: path: | reports/e2e/workplace reports/e2e/workplace-results.json + reports/e2e/workplace-settings + reports/e2e/workplace-settings-results.json retention-days: 14 if-no-files-found: warn @@ -359,3 +399,95 @@ jobs: --header 'Content-Type: application/json' \ --data @- \ "$STATUS_URL" + + # The settings specs that answer every request in the browser. They need the + # Workplace dev server but no PlaceOS stack, so this job runs on a + # GitHub-hosted runner beside the self-hosted one. + settings-browser: + name: 'workplace settings e2e, browser only (advisory, does not block builds)' + # The pinned Playwright has no Chromium build for ubuntu-latest (26.04). + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + TZ: 'Etc/UTC' + # The config's webServer timeout, in ms: how long Playwright waits + # for `nx serve workplace` to answer. + E2E_WEBSERVER_TIMEOUT_MS: '900000' + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # Same bun version as the `workplace` job, build.yml and pull-request.yml. + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.4.3' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Setup version + run: bun run postinstall + + - name: Install Playwright browser + run: bunx playwright install --with-deps chromium + + # Playwright starts the Workplace dev server itself (the config's + # webServer). Its API proxy points at a backend that does not exist + # here; the specs answer those requests in the browser. + - name: Run workplace settings e2e (browser only) + run: bunx playwright test --config apps/workplace/playwright.config.ts --project=settings-browser + + - name: Summarise results + if: ${{ always() }} + run: node e2e/stack/ci-summary.mjs reports/e2e/workplace-results.json 'workplace settings e2e, browser only' >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Playwright report + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report-workplace-settings-browser + path: | + reports/e2e/workplace + reports/e2e/workplace-results.json + retention-days: 14 + if-no-files-found: warn + + - name: Upload failure artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-failures-workplace-settings-browser + path: dist/.playwright + retention-days: 14 + if-no-files-found: warn + + # The same notification as the `workplace` job's "Notify on failure". + - name: Notify on failure + if: ${{ failure() }} + continue-on-error: true + env: + STATUS_URL: ${{ secrets.STATUS_URL }} + CHAT_URL: ${{ secrets.CHAT_URL }} + REPO: ${{ github.repository }} + COMMIT: ${{ github.sha }} + BRANCH: ${{ github.ref_name }} + RUN_ID: ${{ github.run_id }} + run: | + node -e ' + process.stdout.write(JSON.stringify({ + chat_url: process.env.CHAT_URL, + name: process.env.REPO, + commit: process.env.COMMIT, + branch: process.env.BRANCH, + url: `https://github.com/${process.env.REPO}/actions/runs/${process.env.RUN_ID}`, + pipeline_name: "e2e settings-browser (advisory): FAILED", + status: "failure", + })); + ' \ + | curl --silent --show-error --fail \ + --max-time 30 \ + --header 'Content-Type: application/json' \ + --data @- \ + "$STATUS_URL" diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index 2e2794392da..1c5b71fc4fa 100644 --- a/E2E_USER_STORIES.md +++ b/E2E_USER_STORIES.md @@ -163,7 +163,7 @@ was built in. Extending there is a separate piece of work, not an oversight. | VIS-17 | P1 | Declining that confirmation leaves the invite alone. | **done** — `local/visitor-cancel.spec.ts`. A dialog whose decline button also deletes is worse than one that fails to delete, and only this catches it. | | VIS-18 | P1 | An address that is not an email is refused **before anything is sent**. | **done** — `local/visitor-validation.spec.ts`. Asserts on the network, not on a message: the backend stores whatever it is given, so the form is the only guard. Includes a control that a valid address does send. | | VIS-19 | P2 | A visitor taken off the list before sending is not invited, and the rest still are. | **done** — `local/visitor-validation.spec.ts` | -| VIS-20 | P2 | A pass number and the international-visitor flag are stored with the invite. | **partial** — `local/visitor-details.spec.ts`. The pass number is stored. The international flag is `test.fixme`: with `visitors.allow_international` on, the new invite flow has no control for it and sends `international: false`. | +| VIS-20 | P2 | A pass number and the international-visitor flag are stored with the invite. | **done**: `local/visitor-details.spec.ts`. With `visitors.allow_international` on, the invite flow shows an international checkbox for a named visitor, and the tick is stored as `extension_data.international`. | | VIS-21 | P2 | The company is stored, and the separate guest record carries the visitor's name. | **done** — `local/visitor-details.spec.ts`. The guest record is what reception reads and it outlives the booking. | | VIS-22 | P1 | The start time and length chosen on the form are the ones stored. | **done** — `local/visitor-times.spec.ts`. Everything else here takes the default hour, so a form that ignored the picker would have passed the whole suite. | | VIS-23 | P2 | A maximum visit length and bookable hours limit what the form offers. | **done** — `local/visitor-times.spec.ts`. Asserts on the options offered, because an out-of-range choice is absent rather than refused. Red-checked: without the settings the form offers 240 minutes against a 60 minute cap. | @@ -287,8 +287,8 @@ PR #561 changed the components that these routes open: assertions. `desk-flow.page.ts`, `meeting-flow.page.ts`, `invite-form.page.ts`, `landing-new.page.ts` and `your-bookings.page.ts` are the page objects of the new views. The page objects of the replaced views are deleted. -- **Four stories are blocked by the new views** and are `test.fixme`. They are in the table - "Blocked by the new views" below. +- **Four stories were blocked by the new views** and were `test.fixme`. The ones still blocked + are in the table "Blocked by the new views" below. - **Six rows that were blocked on the replaced flows now pass**: ROOM-11, ROOM-13, ROOM-14, ROOM-21, ROOM-22 and VIS-15. - **After the move, a full local run of 161 tests has 154 passes, 7 `fixme` and no failure** @@ -358,12 +358,11 @@ of WP-E2E-05, and its two checks of the success view are now in that test. ### Blocked by the new views -Each of these is `test.fixme` with the measurement in its comment. The first two passed on the -views before PR #561. +Each of these is `test.fixme` with the measurement in its comment. The first passed on the views +before PR #561. | ID | Story | What blocks it | |----|-------|----------------| -| VIS-20 (half) | The international-visitor flag is stored with the invite. | With `visitors.allow_international` on, the new invite flow has no control for the flag and sends `international: false`. | | ROOM-28 | A meeting booked for a colleague is stored with that colleague as the host. | The new meeting flow cannot name a colleague on this stack. `events.can_book_for_anyone` renders no host field. `events.can_book_for_others` renders a select that is filled from `GET /calendars`, which answers `500` without a calendar tenant, so the select holds only the signed-in user. | | NEW-DESK-03 | A desk can be selected when another desk of the building has no `features` list. | `desk-flow-select.component.ts` calls `.trim()` on the features of every desk with no guard. One desk without the list throws `TypeError: Cannot read properties of undefined (reading 'trim')`, and after that no row of the list shows as selected. The earlier flow read the list through `BookingFormService.features`, which has the guard. | @@ -518,8 +517,8 @@ nothing, so the broker keeps its last value. source would need redis keyspace no or through `use_bookings`?** Nobody has answered it. If the answer is "the calendar", the green rows in §1b guard a path those customers never take. This needs a product answer, not more specs. -- **Five rows are blocked on product fixes, not on test effort** (REG-08, REG-09, the - international half of VIS-20, ROOM-28 and NEW-DESK-03). The last three are gaps or faults of +- **Four rows are blocked on product fixes, not on test effort** (REG-08, REG-09, ROOM-28 and + NEW-DESK-03). The last two are gaps or faults of the views that PR #561 made the default; §1c has them. All were found by this suite. Leaving them visible here is the point — a blocked row is coverage information, a deleted row is not. - **One row is blocked by the environment rather than by a bug** (AUTH-E2E-08), and it says diff --git a/apps/workplace/e2e/fixtures.ts b/apps/workplace/e2e/fixtures.ts index 45675cddc29..2fa04a65c48 100644 --- a/apps/workplace/e2e/fixtures.ts +++ b/apps/workplace/e2e/fixtures.ts @@ -1,4 +1,5 @@ import { test as base, expect } from '@playwright/test'; +import { pauseBackground } from '../../../e2e/support/background'; /** * Shared Playwright fixtures for the Workplace e2e suite. @@ -16,8 +17,14 @@ import { test as base, expect } from '@playwright/test'; * Setting localStorage via `addInitScript` (before any app script runs) is * therefore required for the app to be functional offline in tests. This * fixture applies it to every test/page automatically. + * + * The context also pauses the animated page background (see background.ts). */ export const test = base.extend({ + context: async ({ context }, use) => { + await pauseBackground(context); + await use(context); + }, page: async ({ page }, use) => { await page.addInitScript(() => window.localStorage.setItem('mock', 'true'), diff --git a/apps/workplace/e2e/local/bookings-empty.spec.ts b/apps/workplace/e2e/local/bookings-empty.spec.ts index 12fa1585a7a..dafaab7f58a 100644 --- a/apps/workplace/e2e/local/bookings-empty.spec.ts +++ b/apps/workplace/e2e/local/bookings-empty.spec.ts @@ -20,6 +20,7 @@ import { type BrowserContext } from '@playwright/test'; import { apiFor, currentUser, ENGINE_API } from '../../../../e2e/support/api'; import { calendarBacked } from '../../../../e2e/support/calendar/calendar.api'; import { buildStorageState, mintToken } from '../../../../e2e/support/auth'; +import { pauseBackground } from '../../../../e2e/support/background'; import { APP_URL, BACKEND_URL } from '../../../../e2e/support/env'; import { test as base, expect } from '../../../../e2e/support/fixtures'; import { SCHEDULE_DAYS, slotOn } from '../../../../e2e/support/bookings/bookings.env'; @@ -60,6 +61,7 @@ const test = base.extend({ context = await browser.newContext({ storageState: buildStorageState(token, APP_URL), }); + await pauseBackground(context); await use(await context.newPage()); } finally { testInfo.setTimeout(testInfo.timeout + 30_000); diff --git a/apps/workplace/e2e/local/bookings-limit.spec.ts b/apps/workplace/e2e/local/bookings-limit.spec.ts index 07a4a00b065..ae9813f8ba1 100644 --- a/apps/workplace/e2e/local/bookings-limit.spec.ts +++ b/apps/workplace/e2e/local/bookings-limit.spec.ts @@ -16,6 +16,7 @@ import { uniqueTitle, } from '../../../../e2e/support/api'; import { buildStorageState, mintToken } from '../../../../e2e/support/auth'; +import { pauseBackground } from '../../../../e2e/support/background'; import { createBookingViaApi, releaseFor, @@ -84,6 +85,7 @@ test.describe('your bookings — high cancelled history', () => { context = await browser.newContext({ storageState: buildStorageState(token, APP_URL), }); + await pauseBackground(context); const page = await context.newPage(); page.on('response', async (response) => { if (!response.url().includes(`${STAFF_API}/bookings`)) return; diff --git a/apps/workplace/e2e/local/visitor-delegate.spec.ts b/apps/workplace/e2e/local/visitor-delegate.spec.ts index 334e01bdf42..abb45f0358a 100644 --- a/apps/workplace/e2e/local/visitor-delegate.spec.ts +++ b/apps/workplace/e2e/local/visitor-delegate.spec.ts @@ -38,6 +38,7 @@ import { listBookings, uniqueTitle, } from '../../../../e2e/support/api'; +import { pauseBackground } from '../../../../e2e/support/background'; import { WORKERS, roleFor, staffEmail } from '../../../../e2e/support/env'; import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; import { deleteGuest, releaseVisitor } from '../../../../e2e/support/visitor/visitor.api'; @@ -176,6 +177,7 @@ test.describe('visitor invite on behalf of another host', () => { storageState: host.role.storagePath, ignoreHTTPSErrors: true, }); + await pauseBackground(host_ctx); const host_page = await host_ctx.newPage(); try { const theirs = new YourBookingsPage(host_page); diff --git a/apps/workplace/e2e/local/visitor-details.spec.ts b/apps/workplace/e2e/local/visitor-details.spec.ts index 53288b1fe3f..8259cc3c4c4 100644 --- a/apps/workplace/e2e/local/visitor-details.spec.ts +++ b/apps/workplace/e2e/local/visitor-details.spec.ts @@ -17,12 +17,11 @@ * The pass number is behind `visitors.allow_pass_number`, off by default — the * classic "only breaks for the customers who switched it on" shape. * - * ## Three tests, and one of them is `fixme` + * ## Three tests * * The pass number and the international tick were one test (VIS-20) while the - * old form had both controls. The `visitor-flow-new` page has no control for - * the tick, so the two are split: the pass number stays covered, and the tick - * waits as `fixme`. See the note on that test. + * old form had both controls. They were split when the `visitor-flow-new` page + * lacked the tick; the page has it again, so each is now its own test. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; @@ -85,19 +84,12 @@ test.describe('visitor invite details', () => { }); /** - * The other half of VIS-20. `fixme`, because the `visitor-flow-new` page - * has no control for the international tick. - * - * Measured with `visitors.allow_international` on: the page renders no - * `input[name$=".international"]` (count 0), and the invite it sends has - * `extension_data.international: false`. No file under `visitor-flow-new` - * reads the setting. The old form (`invite-visitor-form`) rendered the - * checkbox, but `/book/visitor` no longer opens that form. - * - * Written as it should pass once the page has the control. It fails today - * at `setCheckbox`, with "the checkbox is not on the page". + * The other half of VIS-20. With `visitors.allow_international` on, + * `visitor-flow-invites` shows a per-visitor international checkbox once a + * visitor is named; in single mode it is bound to the form's + * `international` field, so it renders as `input[name$=".international"]`. */ - test.fixme('the international flag is stored with the invite', async ({ + test('the international flag is stored with the invite', async ({ staffPage, staffApi, }, testInfo) => { 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..4e7d1298090 --- /dev/null +++ b/apps/workplace/e2e/local/wpa-settings-readonly-legacy.fixture.ts @@ -0,0 +1,111 @@ +import * as path from 'path'; +import { pauseBackground } from '../../../../e2e/support/background'; +import { APP_URL, AUTH_DIR, BACKEND_URL, staffEmail } from '../../../../e2e/support/env'; +import { expect, test } from '../../../../e2e/support/fixtures'; +import { closedByTeardown } from '../../../../e2e/support/teardown'; + +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, +}; + +/** + * The staff page in a fresh context (no admin state, service workers blocked, + * optional time zone) for the read-only settings specs. Every /api request that + * is not a local GET, HEAD or OPTIONS is aborted and recorded; teardown fails + * the test on any recorded request and checks that the app loaded this + * worker's non-admin staff user. + */ +export const readOnlyTest = test.extend<{ + readOnlyTimezone: string | undefined; +}>({ + readOnlyTimezone: [undefined, { option: true }], + storageState: async ({}, use) => use(undefined), + // Depends on staffToken only because that worker fixture writes the staff + // storageState file this context loads (as the base staffPage does). + staffPage: async ({ browser, staffToken, readOnlyTimezone }, use, info) => { + void staffToken; + const context = await browser.newContext({ + storageState: path.join(AUTH_DIR, `staff-${info.parallelIndex}.json`), + baseURL: APP_URL, + ignoreHTTPSErrors: true, + serviceWorkers: 'block', + timezoneId: readOnlyTimezone, + }); + await pauseBackground(context); + const blocked: string[] = []; + type StaffIdentity = { + email?: string; + sys_admin?: boolean; + support?: boolean; + }; + // undefined: the body was still loading when the context closed. + const identities: Promise[] = []; + try { + // Registered before the page exists, so its first request is covered. + 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'); + return; + } + await route.continue(); + }); + const page = await context.newPage(); + // Reads the app's own identity GET; no extra request is made. + 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((error) => + closedByTeardown(error) ? undefined : null, + ), + ); + } + }); + await use(page); + } finally { + await context.close(); + expect( + blocked, + 'read-only scenario must perform zero API mutations', + ).toEqual([]); + // A test that uses the settings audit has its context closed by + // the audit first, so a late body can be cut off before this. + const observed = (await Promise.all(identities)).filter( + (user) => user !== undefined, + ); + 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/bookings/bookings.all-day-default.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.all-day-default.spec.ts new file mode 100644 index 00000000000..8eecde5f1f1 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.all-day-default.spec.ts @@ -0,0 +1,21 @@ + +import { expect } from '../../../../../e2e/support/fixtures'; +import { test, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +for (const enabled of [false, true]) { + test('bookings.all_day_default ' + enabled + ' initializes the visitor timed or all-day form', + async ({ staffPage, audit }) => { + const form = await visitor(staffPage, audit, { + 'app.visitor.all_day_default': null, 'app.visitors.all_day_default': null, + 'app.bookings.all_day_default': enabled, 'app.visitors.allow_all_day': true, + }); + if (enabled) await expect(form.allDay).toBeChecked(); + else await expect(form.allDay).not.toBeChecked(); + await expect(form.root.locator('a-time-field[name="time"]')).toHaveCount(enabled ? 0 : 1); + await expect(form.root.locator('a-duration-field')).toHaveCount(enabled ? 0 : 1); + await expect(form.visitorName).toBeVisible(); + await untouchedVisitor(form); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.allow-all-day.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.allow-all-day.spec.ts new file mode 100644 index 00000000000..41f2a461587 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.allow-all-day.spec.ts @@ -0,0 +1,61 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { + futureDesk, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// DeskFlowDetails.allow_all_day uses desks.allow_all_day ?? bookings.allow_all_day. +for (const enabled of [false, true]) { + test( + 'bookings.allow_all_day ' + + enabled + + ' supplies the generic desk all-day option with desks.allow_all_day unset', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.allow_all_day': null, + 'app.bookings.allow_all_day': enabled, + }); + const box = details.locator('mat-checkbox input[type="checkbox"]'); + await expect(box).toHaveCount(enabled ? 1 : 0); + // Tomorrow at 10:00 (local form only), so start times exist at any hour of the run. + await futureDesk(staffPage, details); + await expect( + details.locator('time-field[name="time"] button[time-field]'), + ).toBeEnabled(); + if (enabled) { + await box.check(); + await expect(box).toBeChecked(); + await expect( + details.locator('time-field, duration-field'), + ).toHaveCount(0); + await box.uncheck(); + await expect( + details.locator('duration-field button[duration-field]'), + ).toBeEnabled(); + } + await untouchedDesk(staffPage); + }, + ); +} +test('desks.allow_all_day false overrides bookings.allow_all_day true and removes the desk all-day option', async ({ + staffPage, + audit, +}) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.allow_all_day': false, + 'app.bookings.allow_all_day': true, + }); + await expect( + details.locator('mat-checkbox input[type="checkbox"]'), + ).toHaveCount(0); + await expect( + details.locator('time-field[name="time"] button[time-field]'), + ).toBeEnabled(); + await expect( + details.locator('duration-field button[duration-field]'), + ).toBeEnabled(); + await untouchedDesk(staffPage); +}); diff --git a/apps/workplace/e2e/settings/bookings/bookings.bookable-hours.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.bookable-hours.spec.ts new file mode 100644 index 00000000000..eac9b7e9dbc --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.bookable-hours.spec.ts @@ -0,0 +1,41 @@ +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { InviteVisitorForm } from '../../../../../e2e/support/visitor/invite-form.page'; +import { test, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +/** Public time-field options; calendar helper selects tomorrow without submitting. */ +async function visitorTimes(page: Page, form: InviteVisitorForm) { + const tomorrow = new Date(); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); tomorrow.setUTCHours(12, 0, 0, 0); + await form.setDate(tomorrow.valueOf()); + await form.root.locator('a-time-field[name="time"] button[time-field]').click(); + const options = page.locator('.cdk-overlay-container button[data-time]'); + await expect(options.first()).toBeVisible(); + const values = await options.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-time')!)); + await page.keyboard.press('Escape'); + return values.map((value) => { + const [h, m] = value.split(':').map(Number); + return h * 60 + m; + }); +} + +for (const enabled of [false, true]) { + test('bookings.bookable_hours ' + enabled + ' restricts visitor start-time choices through the fallback', + async ({ staffPage, audit }) => { + const form = await visitor(staffPage, audit, { + 'app.visitors.bookable_hours': null, + 'app.bookings.bookable_hours': enabled ? { start: 9, end: 17 } : null, + 'app.visitors.min_duration': 30, + }); + const times = await visitorTimes(staffPage, form); + expect(times.length).toBeGreaterThan(0); + if (enabled) { + expect(Math.min(...times)).toBe(9 * 60); + expect(Math.max(...times)).toBeLessThan(17 * 60); + } else { + expect(Math.min(...times)).toBeLessThan(9 * 60); + expect(Math.max(...times)).toBeGreaterThan(17 * 60); + } + await untouchedVisitor(form); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.can-book-for-anyone.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.can-book-for-anyone.spec.ts new file mode 100644 index 00000000000..745fb98b42b --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.can-book-for-anyone.spec.ts @@ -0,0 +1,20 @@ + +import { expect } from '../../../../../e2e/support/fixtures'; +import { test, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +for (const enabled of [false, true]) { + test('bookings.can_book_for_anyone ' + enabled + ' supplies an unset visitor host-search preference', + async ({ staffPage, audit }) => { + const form = await visitor(staffPage, audit, { + 'app.visitors.can_book_for_anyone': null, 'app.visitors.can_book_for_others': null, + 'app.bookings.can_book_for_anyone': enabled, 'app.bookings.can_book_for_others': true, + }); + await expect(form.root.locator('a-user-search-field')).toHaveCount(enabled ? 1 : 0); + await expect(form.root.locator('host-select-field')).toHaveCount(enabled ? 0 : 1); + if (enabled) await expect(form.root.locator('a-user-search-field input')).toBeVisible(); + else await expect(form.root.locator('host-select-field mat-select')).toBeVisible(); + await untouchedVisitor(form); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.can-book-for-others.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.can-book-for-others.spec.ts new file mode 100644 index 00000000000..35f91b9ab08 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.can-book-for-others.spec.ts @@ -0,0 +1,20 @@ + +import { expect } from '../../../../../e2e/support/fixtures'; +import { test, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +for (const enabled of [false, true]) { + test('bookings.can_book_for_others ' + enabled + ' supplies an unset visitor delegation preference', + async ({ staffPage, audit }) => { + const form = await visitor(staffPage, audit, { + 'app.visitors.can_book_for_anyone': null, 'app.visitors.can_book_for_others': null, + 'app.bookings.can_book_for_others': enabled, + }); + await expect(form.root.locator('host-select-field')).toHaveCount(enabled ? 1 : 0); + await expect(form.root.locator('a-user-search-field')).toHaveCount(0); + if (enabled) await expect(form.root.locator('host-select-field mat-select')).toBeVisible(); + await expect(form.visitorName).toBeVisible(); + await untouchedVisitor(form); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.custom-duration-options.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.custom-duration-options.spec.ts new file mode 100644 index 00000000000..169d07e65c4 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.custom-duration-options.spec.ts @@ -0,0 +1,34 @@ + +import { expect } from '../../../../../e2e/support/fixtures'; +import { durationOptions, futureDuration, test, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +for (const configured of [false, true]) { + test('booking settings: bookings.custom_duration_options ' + configured + ' supplies visitor duration exceptions', + async ({ staffPage, audit }) => { + const form = await visitor(staffPage, audit, { + 'app.visitors.custom_duration_options': null, + 'app.bookings.custom_duration_options': configured ? [45, 150] : [], + 'app.visitors.min_duration': 90, 'app.visitors.max_duration': 120, + 'app.visitors.duration_step': 30, + }); + await futureDuration(staffPage, form); + await durationOptions(staffPage, form); + await staffPage.locator('.cdk-overlay-container button[data-duration="90"]').click(); + // Rebuild around an in-range current duration, so no stale current + // value can be mistaken for a configured custom exception. + await form.root.locator('a-time-field[name="time"] button[time-field]').click(); + await staffPage.locator('.cdk-overlay-container button[data-time="11:00"]').click(); + const options = await durationOptions(staffPage, form); + expect(await options.evaluateAll((rows) => rows.map((row) => Number(row.getAttribute('data-duration'))))) + .toEqual(configured ? [45, 90, 120, 150] : [90, 120]); + if (configured) { + await staffPage.locator('.cdk-overlay-container button[data-duration="45"]').click(); + await expect(form.root.locator('a-duration-field button[duration-field]')).toContainText('45 minutes'); + await durationOptions(staffPage, form); + await staffPage.locator('.cdk-overlay-container button[data-duration="150"]').click(); + await expect(form.root.locator('a-duration-field button[duration-field]')).toContainText('2 hours 30 minutes'); + } else await staffPage.keyboard.press('Escape'); + await untouchedVisitor(form); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.duration-bounds.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.duration-bounds.spec.ts new file mode 100644 index 00000000000..4f54ab49e60 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.duration-bounds.spec.ts @@ -0,0 +1,36 @@ + +import { expect } from '../../../../../e2e/support/fixtures'; +import { durationOptions, futureDuration, test, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// VisitorFlowDetailsComponent: visitors.min/max_duration || bookings.min/max_duration. +// Test each global key independently; current/default duration is put on-grid first. +// Tested values avoid the code defaults (bookings.min_duration 30, max_duration 180), +// so an ignored key changes the list, and no two cases share the same overrides. +for (const [key,value,expected] of [ + ['min_duration',60,[60,90,120]], ['min_duration',90,[90,120]], + ['max_duration',120,[30,60,90,120]], ['max_duration',150,[30,60,90,120,150]], +] as const) { + test('booking settings: bookings.'+key+' '+value+' bounds visitor fallback duration options', async ({staffPage,audit}) => { + const form=await visitor(staffPage,audit,{ + 'app.visitor.min_duration':null, 'app.visitor.max_duration':null, + 'app.visitors.min_duration':null, 'app.visitors.max_duration':null, + 'app.bookings.min_duration':key==='min_duration'?value:30, + 'app.bookings.max_duration':key==='max_duration'?value:120, + 'app.visitors.duration_step':30, + 'app.visitor.custom_duration_options':null, 'app.visitors.custom_duration_options':null, + 'app.bookings.custom_duration_options':[], + }); + await futureDuration(staffPage,form); + await durationOptions(staffPage,form); + const length=expected[0]; + await staffPage.locator('.cdk-overlay-container button[data-duration="'+length+'"]').click(); + await form.root.locator('a-time-field[name="time"] button[time-field]').click(); + await staffPage.locator('.cdk-overlay-container button[data-time="11:00"]').click(); + const options=await durationOptions(staffPage,form); + expect(await options.evaluateAll(rows=>rows.map(row=>Number(row.getAttribute('data-duration'))))).toEqual([...expected]); + await staffPage.keyboard.press('Escape'); + await expect(form.root.locator('a-duration-field button[duration-field]')).toBeEnabled(); + await untouchedVisitor(form); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.duration-step.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.duration-step.spec.ts new file mode 100644 index 00000000000..ba2308bdf63 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.duration-step.spec.ts @@ -0,0 +1,27 @@ + +import { expect } from '../../../../../e2e/support/fixtures'; +import { durationOptions, futureDuration, test, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +for (const step of [30, 45]) { + test('booking settings: bookings.duration_step ' + step + ' controls visitor fallback duration increments', + async ({ staffPage, audit }) => { + const form = await visitor(staffPage, audit, { + 'app.visitors.duration_step': null, 'app.bookings.duration_step': step, + 'app.visitors.min_duration': 30, 'app.visitors.max_duration': 180, + 'app.visitors.custom_duration_options': null, 'app.bookings.custom_duration_options': [], + }); + await futureDuration(staffPage, form); + let options = await durationOptions(staffPage, form); + // Put the current duration on-grid before rebuilding the menu. + await staffPage.locator('.cdk-overlay-container button[data-duration="30"]').click(); + await form.root.locator('a-time-field[name="time"] button[time-field]').click(); + await staffPage.locator('.cdk-overlay-container button[data-time="11:00"]').click(); + options = await durationOptions(staffPage, form); + const values = await options.evaluateAll((rows) => rows.map((row) => Number(row.getAttribute('data-duration')))); + expect(values).toEqual(step === 45 ? [30, 75, 120, 165] : [30, 60, 90, 120, 150, 180]); + await staffPage.keyboard.press('Escape'); + await expect(form.root.locator('a-duration-field button[duration-field]')).toBeVisible(); + await untouchedVisitor(form); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.has-assets.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.has-assets.spec.ts new file mode 100644 index 00000000000..e5620f2e856 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.has-assets.spec.ts @@ -0,0 +1,117 @@ +// Coverage: 1 unique setting, 4 scenarios. +// Setting: bookings.has_assets (docs/settings/workplace.md; source: +// apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts). +// +// The desk flow reads `desks.has_assets ?? bookings.has_assets`, so an explicit +// desks.has_assets overrides the generic key. With desks.has_assets null the +// generic key alone gates the asset request field; with desks.has_assets set, +// its value wins over bookings.has_assets. No asset is requested, attached or +// submitted; the form is never confirmed. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { prepareSettings, type Settings, test } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const BASE: Settings = { + ...READ_ONLY_SETTINGS, + 'app.features': ['desks'], + 'app.feature_groups': {}, + '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.basic_user_search': true, + // Expose the desktop details form without selecting a desk or confirming. + 'app.desk.auto_allocation': true, + 'app.desks.auto_allocation': true, + 'app.desks.hide_title': false, + 'app.desks.allow_groups': false, + 'app.desks.can_book_for_others': false, + 'app.bookings.can_book_for_others': false, + 'app.desks.can_book_lockers': false, + 'app.desks.use_assets': true, + 'app.desks.allow_recurrence': false, + 'app.desks.allow_time_changes': true, + 'app.desks.allow_all_day': false, + 'app.bookings.allow_all_day': false, + 'app.desk.all_day_default': false, + 'app.desks.all_day_default': false, + 'app.bookings.all_day_default': false, + 'app.desks.use_building_timezone': false, + 'app.bookings.use_building_timezone': false, + 'app.desks.available_period': 30, + 'app.desks.bookable_hours': null, + 'app.desk.min_duration': null, + 'app.desk.max_duration': null, + 'app.desk.duration_step': null, + 'app.desk.custom_duration_options': null, + 'app.desks.min_duration': 30, + 'app.desks.max_duration': 180, + 'app.desks.duration_step': 30, + 'app.desks.custom_duration_options': [], + 'app.bookings.min_duration': 30, + 'app.bookings.max_duration': 180, + 'app.bookings.duration_step': 30, + 'app.bookings.custom_duration_options': [], +}; + +async function prepare(page: Page, desks: boolean | null, generic: boolean) { + const fixture = await prepareSettings( + page, + { 'app.desks.has_assets': desks, 'app.bookings.has_assets': generic }, + { + base: BASE, + hidden: 'e2e-booking-assets', + landingRooms: false, + identity: false, + }, + ); + await page.goto('/#/book/desk/form'); + await fixture.ready({ + loading: false, + keys: ['app.features', 'app.desks.has_assets', 'app.bookings.has_assets'], + }); + const details = page.locator('desk-flow-new desk-flow-details'); + await expect(details.locator('input[name$=".title"]')).toBeEditable(); + await expect(details.locator('date-field button').first()).toBeEnabled(); + return details; +} + +// One asset request field and label when the effective flag is on, none when +// it is off. +const SCENARIOS = [ + { desks: null, generic: false, fields: 0, effect: 'hides' }, + { desks: null, generic: true, fields: 1, effect: 'shows' }, + { desks: false, generic: true, fields: 0, effect: 'hides' }, + { desks: true, generic: false, fields: 1, effect: 'shows' }, +]; + +for (const scenario of SCENARIOS) { + test(`bookings.has_assets ${scenario.generic} with desks.has_assets ${scenario.desks} ${scenario.effect} the desk asset request field`, async ({ + staffPage, + }) => { + const details = await prepare( + staffPage, + scenario.desks, + scenario.generic, + ); + await expect(details.locator('asset-list-field')).toHaveCount( + scenario.fields, + ); + await expect(details.locator('label[for="assets"]')).toHaveCount( + scenario.fields, + ); + // No asset is ever requested. With the field absent this also holds, + // so it is asserted in every scenario. + await expect(details.locator('asset-list-field [request]')).toHaveCount( + 0, + ); + await expect(staffPage).toHaveURL(/#\/book\/desk\/form$/); + await expect(staffPage.locator('desk-flow-success')).toHaveCount(0); + }); +} diff --git a/apps/workplace/e2e/settings/bookings/bookings.use-building-timezone.spec.ts b/apps/workplace/e2e/settings/bookings/bookings.use-building-timezone.spec.ts new file mode 100644 index 00000000000..d36dca6bc81 --- /dev/null +++ b/apps/workplace/e2e/settings/bookings/bookings.use-building-timezone.spec.ts @@ -0,0 +1,26 @@ + +import { expect } from '../../../../../e2e/support/fixtures'; +import { type Debug, test, timezoneBuildings, untouchedVisitor, visitor } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +for (const enabled of [false, true]) { + test('bookings.use_building_timezone ' + enabled + ' gates the visitor secondary timezone label', + async ({ staffPage, audit }) => { + await timezoneBuildings(staffPage, audit); + const form = await visitor(staffPage, audit, { + 'app.bookings.use_building_timezone': enabled, 'app.visitors.use_building_timezone': false, + }); + expect(audit.fixtures['timezone buildings'] ?? 0).toBeGreaterThan(0); + const field = form.root.locator('a-time-field[name="time"]'); + await expect(field.locator('button[time-field]')).toBeVisible(); + await expect.poll(() => field.evaluate((element) => { + const c = (window as Debug).ng?.getComponent(element) as { timezone(): string } | undefined; + return c?.timezone(); + })).toBe(enabled ? 'Pacific/Honolulu' : ''); + const secondary = field.locator('button[time-field] div.truncate.text-xs'); + await expect(secondary).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(secondary).toBeVisible(); + await untouchedVisitor(form); + }); +} 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..777d22b5daa --- /dev/null +++ b/apps/workplace/e2e/settings/chat/chat.enabled.spec.ts @@ -0,0 +1,178 @@ +import type { 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 { jsonBody } from '../../../../../e2e/support/teardown'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + CHAT_HINT_FIXTURE, + image, + isolateChatHint, + test, +} from '../support/settings-harness'; + +// Setting: chat.enabled (docs/settings/workplace.md). +// Coverage: 1 unique setting, 2 scenarios. +// 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 LIGHT = image('white'); +const DARK_A = image('navy'); + +// Every PlaceOS WebSocket fails the test, the shared systems/control endpoint +// included. +test.use({ auditSockets: 'block-all' }); + +// An isolateChatHint handler can still be inside route.fetch when the body +// ends. This waits for running page route handlers before the audit closes +// the context. +test.afterEach(async ({ staffPage }) => { + await staffPage.unrouteAll({ behavior: 'wait' }); +}); + +async function openBranding( + page: Page, + overrides: Record, + audit: Audit, +) { + 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 = jsonBody(response); + }); + 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.fixtures[CHAT_HINT_FIXTURE] ?? 0, { + 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(`chat.enabled ${enabled} gates the chat launcher`, async ({ + staffPage, + audit, + }) => { + await openBranding(staffPage, { 'app.chat.enabled': enabled }, audit); + 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/desk-booking/desk-booking.allow-all-day.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-all-day.spec.ts new file mode 100644 index 00000000000..ef56b103c6b --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-all-day.spec.ts @@ -0,0 +1,42 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// allow_all_day = allow_time_changes && (desks.allow_all_day ?? bookings.allow_all_day). +for (const enabled of [false, true]) { + test( + 'desk settings: desks.allow_all_day ' + + enabled + + ' gates the all-day checkbox', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.allow_all_day': enabled, + }); + const checkbox = details.locator( + 'mat-checkbox input[type="checkbox"]', + ); + await expect(checkbox).toHaveCount(enabled ? 1 : 0); + await expect( + details.locator('time-field[name="time"] button[time-field]'), + ).toBeEnabled(); + await expect( + details.locator('duration-field button[duration-field]'), + ).toBeEnabled(); + if (enabled) { + await expect(checkbox).not.toBeChecked(); + await checkbox.check(); + await expect(checkbox).toBeChecked(); + await expect( + details.locator('time-field, duration-field'), + ).toHaveCount(0); + await checkbox.uncheck(); + await expect( + details.locator( + 'time-field[name="time"] button[time-field]', + ), + ).toBeEnabled(); + } + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-groups.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-groups.spec.ts new file mode 100644 index 00000000000..fe30f258190 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-groups.spec.ts @@ -0,0 +1,38 @@ +import { DeskFlow } from '../../../../../e2e/support/desk/desk-flow.page'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +for (const enabled of [false, true]) { + test( + 'desk settings: desks.allow_groups ' + + enabled + + ' gates group mode and local member entry', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.allow_groups': enabled, + }); + const group = new DeskFlow(staffPage).groupModeButton; + await expect(group).toHaveCount(enabled ? 1 : 0); + await expect(details.locator('a-user-list-field')).toHaveCount(0); + if (enabled) { + await group.click(); + await expect( + details.locator( + 'a-user-list-field input[name="user_email"]', + ), + ).toBeVisible(); + await expect( + details.locator('a-user-list-field mat-chip-row'), + ).toHaveCount(0); + await details + .getByRole('button', { name: 'person Single', exact: true }) + .click(); + await expect(details.locator('a-user-list-field')).toHaveCount( + 0, + ); + } + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-multiple.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-multiple.spec.ts new file mode 100644 index 00000000000..40935e3e848 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-multiple.spec.ts @@ -0,0 +1,207 @@ +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { + type Audit, + type Debug, + getOnly, + openDesk, + organisationRoutes, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const REGION = 'e2e-settings-region'; +const BUILDING_A = 'e2e-settings-building-a'; +const BUILDING_B = 'e2e-settings-building-b'; +const LEVEL_A = 'e2e-settings-level-a'; +const LEVEL_B = 'e2e-settings-level-b'; +const LEVELS = [ + { + id: LEVEL_A, + parent_id: BUILDING_A, + name: 'E2E Level A', + display_name: 'E2E Level A', + tags: ['level'], + }, + { + id: LEVEL_B, + parent_id: BUILDING_B, + name: 'E2E Level B', + display_name: 'E2E Level B', + tags: ['level'], + }, +]; +const DESK = 'E2E browser-only desk'; + +async function organisationFixture(page: Page, audit: Audit) { + await organisationRoutes(page, { + audit, + prefix: 'e2e-settings-', + region: { id: REGION, name: 'E2E Region' }, + regionParent: true, + buildings: [ + { + id: BUILDING_A, + parent_id: REGION, + name: 'E2E Building A', + display_name: 'E2E Building A', + tags: ['building'], + timezone: 'UTC', + location: '0,0', + }, + { + id: BUILDING_B, + parent_id: REGION, + name: 'E2E Building B', + display_name: 'E2E Building B', + tags: ['building'], + timezone: 'UTC', + location: '40,40', + }, + ], + levels: LEVELS, + metadata: (name) => + name.endsWith('_booking_rules') || name === 'map_regions' ? [] : {}, + desks: (level) => [ + { + id: 'e2e-settings-desk-' + level.id, + name: DESK + ' ' + level.id, + map_id: 'e2e-settings-desk-shape', + bookable: true, + assigned_to: '', + groups: [], + features: [], + images: [], + }, + ], + deskBookings: true, + }); +} +// BookingFormService._computeAvailableResources -> bookedResourceList uses +// GET /bookings/booked with exactly these five parameters (bookings.fn.ts). +// A JSON string[] is the source-supported response; [] means no booked IDs. +async function bookedDeskResourcesFixture(page: Page, audit: Audit) { + const zones = new Set([ + REGION, + BUILDING_A, + BUILDING_B, + ...LEVELS.map((level) => level.id), + ]); + const keys = ['type', 'zones', 'period_start', 'period_end', 'limit']; + await getOnly( + page, + audit, + 'empty booked desk resources', + (url) => { + const query = url.searchParams; + const start = query.get('period_start') ?? ''; + const end = query.get('period_end') ?? ''; + return ( + url.pathname === '/api/staff/v1/bookings/booked' && + [...query.keys()].length === keys.length && + keys.every((key) => query.getAll(key).length === 1) && + query.get('type') === 'desk' && + zones.has(query.get('zones') ?? '') && + query.get('limit') === '200' && + /^\d+$/.test(start) && + /^\d+$/.test(end) && + Number.isSafeInteger(Number(start)) && + Number(start) > 0 && + Number.isSafeInteger(Number(end)) && + Number(end) > Number(start) + ); + }, + () => [], + ); +} +// DeskFlowSelect.use_region selects region vs building controls and level scope. +// DeskFlowSelect.toggleDesk: desks.allow_multiple accumulates resources; otherwise each pick replaces the last. +// Selection is local form state only. Never Continue, Confirm or save. +const MULTI = [ + { id: 'e2e-settings-multi-desk-1', name: 'E2E multi desk one' }, + { id: 'e2e-settings-multi-desk-2', name: 'E2E multi desk two' }, +]; +for (const multiple of [false, true]) { + test( + 'desks.allow_multiple ' + + multiple + + ' accumulates or replaces local desk picks', + async ({ staffPage, audit }) => { + await organisationFixture(staffPage, audit); + await bookedDeskResourcesFixture(staffPage, audit); + // Registered after organisationFixture, so it answers building A's desk metadata with two desks on level A. + await getOnly( + staffPage, + audit, + 'two desk metadata', + (url) => + url.pathname === + '/api/engine/v2/metadata/' + BUILDING_A + '/children' && + url.searchParams.get('name') === 'desks', + () => [ + { + zone: LEVELS[0], + metadata: { + desks: { + name: 'desks', + details: MULTI.map((d) => ({ + ...d, + map_id: d.id + '-shape', + bookable: true, + assigned_to: '', + groups: [], + features: [], + images: [], + })), + }, + }, + }, + ], + ); + await openDesk( + staffPage, + audit, + { + 'app.desk.auto_allocation': null, + 'app.desks.auto_allocation': false, + 'app.desks.use_assets': false, + 'app.default_building': BUILDING_A, + 'app.use_region': false, + 'app.desks.allow_multiple': multiple, + }, + { dateReady: false }, + ); + const list = staffPage.locator('desk-flow-select-list'); + await expect(list).toBeVisible(); + await expect(list.locator('mat-spinner')).toHaveCount(0); + // BookingFormService only starts the desk load once _requests_ready() holds and a 300 ms debounce elapses; + // loading() is '' before that, so "no spinner" does not prove the metadata GET has been answered yet. + await expect + .poll(() => audit.fixtures['two desk metadata'] ?? 0) + .toBeGreaterThan(0); + const pick = (name: string) => + list + .locator('li[item]') + .filter({ hasText: name }) + .locator('button[name="select-item"]'); + await expect(pick(MULTI[0].name)).toHaveCount(1); + await expect(pick(MULTI[1].name)).toHaveCount(1); + await pick(MULTI[0].name).click(); + await pick(MULTI[1].name).click(); + const picker = staffPage.locator('desk-flow-select'); + await expect + .poll(() => + picker.evaluate((el) => { + const c = (window as Debug).ng?.getComponent(el) as + | { model(): { resources?: { id: string }[] } } + | undefined; + return c?.model().resources?.map((r) => r.id); + }), + ) + .toEqual(multiple ? [MULTI[0].id, MULTI[1].id] : [MULTI[1].id]); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-recurrence.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-recurrence.spec.ts new file mode 100644 index 00000000000..6c17deba374 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-recurrence.spec.ts @@ -0,0 +1,24 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +for (const enabled of [false, true]) { + test( + 'desk settings: desks.allow_recurrence ' + + enabled + + ' gates the unsaved recurrence field', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.allow_recurrence': enabled, + }); + await expect(details.locator('recurrence-field')).toHaveCount( + enabled ? 1 : 0, + ); + if (enabled) + await expect( + details.locator('recurrence-field mat-select'), + ).toBeVisible(); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-time-changes.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-time-changes.spec.ts new file mode 100644 index 00000000000..ced1baf1b7a --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.allow-time-changes.spec.ts @@ -0,0 +1,30 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// allow_all_day = allow_time_changes && (desks.allow_all_day ?? bookings.allow_all_day). +// DeskFlowDetails renders the time and duration fields only while +// allow_time_changes is true (PPT-2862). +for (const allowed of [true, false]) { + test( + 'desk settings: desks.allow_time_changes ' + + allowed + + ' gates the all-day checkbox, time and duration', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.allow_all_day': true, + 'app.desks.allow_time_changes': allowed, + }); + await expect( + details.locator('mat-checkbox input[type="checkbox"]'), + ).toHaveCount(allowed ? 1 : 0); + await expect( + details.locator('time-field[name="time"]'), + ).toHaveCount(allowed ? 1 : 0); + await expect(details.locator('duration-field')).toHaveCount( + allowed ? 1 : 0, + ); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.allowed-daily-desk-count.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.allowed-daily-desk-count.spec.ts new file mode 100644 index 00000000000..0addd420253 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.allowed-daily-desk-count.spec.ts @@ -0,0 +1,113 @@ +// Setting: bookings.allowed_daily_desk_count (docs/settings/workplace.md). +// Coverage: 1 unique Setting / 2 scenarios. +// +// Source: libs/bookings/src/lib/booking-form.service.ts _checkResourceAvailable, +// run by postForm before anything is saved. It reads the user's desk bookings +// for the booking period (GET /api/staff/v1/bookings with period_start, +// period_end, type=desk, email=, limit=1000), ignores declined, +// cancelled, ended and rejected ones, and refuses with +// BOOKINGS.CLASH_CURRENT_USER when the user already has +// app.bookings.allowed_daily_desk_count (default 1; 0 = no limit) or more. +// +// Both scenarios answer that one GET in the browser with ONE active booking for +// the signed-in user in the requested period, on a different desk id, so only +// the count decides (the earlier same-desk check cannot fire). The new desk +// flow auto-allocates a real seeded desk (level desk metadata, read-only). +// 1. limit 1: Confirm is refused with CLASH_CURRENT_USER; no POST is sent and +// the form stays open. +// 2. limit 2: the check passes; exactly one desk booking POST is sent, caught +// and answered in the browser (armBookingPost), and the success view opens. +// Not tested here: the default (unset = 1) and 0 (no limit). +// e2e/local/bookings-limit.spec.ts (YB-12) covers list paging, not this limit. +// +// Safety: the read-only context guard is unchanged and fails any write. Only +// scenario 2 arms a single booking POST, answered locally; nothing is saved. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { + armBookingPost, + bookingPosts, + clashCurrentUser, + type Debug, + oneDailyBooking, + openDesk, + test, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const OTHER_DESK = 'e2e-daily-limit-other-desk'; + +/** The desk flow's form asset (the auto-allocated desk), read-only. */ +function formAssetId(page: Page) { + return page.locator('desk-flow-new').evaluate((element) => { + const flow = (window as Debug).ng?.getComponent(element) as + | { form_value?: () => { asset_id?: string } } + | undefined; + if (!flow?.form_value) throw new Error('the desk flow form is unavailable'); + return flow.form_value().asset_id ?? ''; + }); +} + +const SCENARIOS = [ + { label: '1 refuses a second desk booking that day', limit: 1, allowed: false }, + { label: '2 allows a second desk booking that day', limit: 2, allowed: true }, +]; + +for (const scenario of SCENARIOS) { + test('desk settings: bookings.allowed_daily_desk_count ' + scenario.label, async ({ staffPage, audit }) => { + const { fixture } = await openDesk(staffPage, audit, { + 'app.bookings.allowed_daily_desk_count': scenario.limit, + // Seeded desks live in level desk metadata. + 'app.desks.use_assets': false, + }); + const { email } = await fixture.user(); + // The user's existing desk bookings for the requested period: one active + // booking on another desk, built from the request's own period. + const served = await oneDailyBooking(staffPage, audit, { type: 'desk', email, asset_id: OTHER_DESK }); + // Every booking POST attempt is counted, armed or not. + const posts = bookingPosts(staffPage); + const armed = scenario.allowed + ? await armBookingPost(staffPage, (body) => + body.booking_type === 'desk' && body.user_email === email && + typeof body.asset_id === 'string' && body.asset_id !== OTHER_DESK) + : undefined; + + const confirm = staffPage.locator('desk-flow-new button[btn]').filter({ + has: staffPage.locator('icon', { hasText: /^\s*task_alt\s*$/ }), + }); + await expect(confirm).toHaveCount(1); + await confirm.click(); + + // The fake booking was read for this user's booking period. + await expect.poll(() => served.length, { message: 'existing desk bookings were read' }).toBeGreaterThan(0); + expect(served[0].period_end).toBeGreaterThan(served[0].period_start); + if (!scenario.allowed) { + await expect(staffPage.locator('mat-snack-bar-container')).toContainText(clashCurrentUser(email)); + expect(posts, 'no booking POST is sent').toEqual([]); + await expect(staffPage).toHaveURL(/#\/book\/desk\/form$/); + await expect(staffPage.locator('desk-flow-new desk-flow-details input[name$=".title"]')).toBeEditable(); + await expect(staffPage.locator('desk-flow-success')).toHaveCount(0); + // The auto-allocated desk differs from the existing booking's desk. + const asset = await formAssetId(staffPage); + expect(asset).toBeTruthy(); + expect(asset).not.toBe(OTHER_DESK); + } else { + await expect.poll(() => armed!.completed, { message: 'the armed booking POST was answered' }).toBe(true); + expect(posts, 'exactly one booking POST').toHaveLength(1); + expect(armed!.bodies).toHaveLength(1); + expect(armed!.bodies[0].asset_id).not.toBe(OTHER_DESK); + await expect(staffPage).toHaveURL(/#\/book\/desk\/success$/); + // The success host has no box of its own (its content is absolutely + // positioned), so check its visible heading and continue link. + const success = staffPage.locator('desk-flow-success'); + await expect(success.locator('h2')).toBeVisible(); + await expect(success.locator('h2')).toContainText( + String(armed!.bodies[0].asset_name), + ); + await expect( + success.locator('a[name="desk-confirm-continue"]'), + ).toBeVisible(); + } + }); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.assigned-resource-booking.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.assigned-resource-booking.spec.ts new file mode 100644 index 00000000000..0f134491cca --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.assigned-resource-booking.spec.ts @@ -0,0 +1,188 @@ +// Setting: bookings.assigned_resource_booking (docs/settings/workplace.md). +// Coverage: 1 unique setting, 6 scenarios. +// +// Legacy desk form (3 scenarios): only the legacy desk form shows the +// reserved-desk overlay +// (apps/workplace/src/app/book/desk-flow/desk-flow-form.component.ts: +// has_assigned_desk() && assignedResourceBooking('desk') === 'deny'); the routed +// new flow (desk-flow-new) has no such overlay, so every scenario sets +// app.use_legacy_flows true. desk.* and desks.* are null, so the generic +// bookings.* value is the one that decides. +// The user is given an assigned desk by rewriting the real level desk metadata +// GET in the browser (assigned_to = the signed-in user). Nothing is saved; the +// desk picker is never opened and no desk is selected. The parent read-only +// guard fails any write. +// +// New desk flow, at submission (3 scenarios): BookingFormService +// _checkResourceAvailable refuses a desk booking when the user's bookings for +// the period include one tagged extension_data.is_assigned, unless the setting +// is 'allow' (unset or unknown values act as 'other_only'). The existing-bookings +// GET is answered in the browser with ONE such booking on another desk, and +// bookings.allowed_daily_desk_count is 2 so the daily limit cannot refuse. +// allow: exactly one desk booking POST, answered in the browser +// (armBookingPost); the success view opens. +// other_only, deny: refused with the assigned-desk message; no POST. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL } from '../../../../../e2e/support/env'; +import { + armBookingPost, + type Audit, + bookingPosts, + type DebugWindow, + getOnly, + openDesk, + prepare, + test, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +/** Give the signed-in user one assigned desk on every real level (browser-only GET rewrite). */ +async function assignedDesk(page: Page, audit: Audit, user: () => Promise<{ email: string }>) { + await page.route((url) => ORIGINS.has(url.origin) && + /^\/api\/engine\/v2\/metadata\/[^/]+\/children$/.test(url.pathname) && + url.searchParams.get('name') === 'desks', async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + try { + const { email } = await user(); + const response = await route.fetch({ maxRedirects: 0, maxRetries: 0 }); + if (!response.ok()) return await route.fulfill({ response }); + const rows: { zone?: { id?: string }; metadata?: Record }[] = await response.json(); + if (!Array.isArray(rows)) throw new Error('children metadata must be an array'); + const json = rows.map((row) => !row.zone?.id ? row : { ...row, metadata: { ...row.metadata, desks: { + name: 'desks', details: [{ + id: 'e2e-assigned-desk-' + row.zone.id, name: 'E2E assigned desk', + map_id: 'e2e-assigned-desk-map-' + row.zone.id, bookable: true, + assigned_to: email, groups: [], features: [], images: [], + }] } } }); + audit.fixtures['assigned desk levels'] = (audit.fixtures['assigned desk levels'] ?? 0) + + rows.filter((row) => row.zone?.id).length; + await route.fulfill({ response, json }); + } catch (error) { + audit.monitoring_errors.push('assigned desk fixture: ' + String(error)); + await route.fallback().catch(() => undefined); + } + }); +} + +/** The legacy form's has_assigned_desk() (read-only). */ +function hasAssignedDesk(page: Page) { + return page.locator('desk-flow-form').evaluate((element) => { + const form = (window as DebugWindow).ng?.getComponent(element) as + | { _state?: { has_assigned_desk?: () => boolean } } + | undefined; + if (!form?._state?.has_assigned_desk) throw new Error('the legacy desk form state is unavailable'); + return form._state.has_assigned_desk(); + }); +} + +for (const policy of ['deny', 'other_only', 'allow'] as const) { + test('desk settings: bookings.assigned_resource_booking ' + policy + ' controls the legacy assigned-desk overlay', async ({ staffPage, audit }) => { + const fixture = await prepare(staffPage, audit, { + 'app.use_legacy_flows': true, + 'app.features': ['desks'], + 'app.feature_groups': {}, + 'app.desk.auto_allocation': false, + 'app.desks.auto_allocation': false, + 'app.desks.allow_groups': false, + 'app.desks.use_assets': false, + 'app.desk.assigned_resource_booking': null, + 'app.desks.assigned_resource_booking': null, + 'app.bookings.assigned_resource_booking': policy, + }); + await assignedDesk(staffPage, audit, fixture.user); + await staffPage.goto('/#/book/desk/form'); + await fixture.ready(); + const form = staffPage.locator('desk-flow-form'); + await expect(form).toBeVisible(); + await expect(staffPage.locator('desk-flow-new')).toHaveCount(0); + await expect.poll(() => audit.fixtures['assigned desk levels'] ?? 0, + { message: 'real level desk metadata is needed for the assigned desk' }).toBeGreaterThan(0); + // The assigned desk is detected in both scenarios, so only the policy + // decides the overlay; an absent overlay cannot pass on a slow lookup. + await expect.poll(() => hasAssignedDesk(staffPage), { message: 'assigned desk detected' }).toBe(true); + const overlay = form.locator('[name="reserved-desk-overlay"]'); + if (policy === 'deny') { + await expect(overlay).toBeVisible(); + } else { + await expect(overlay).toHaveCount(0); + await expect(staffPage.locator('button[name="add-desk"]')).toBeEnabled(); + } + await expect(form.locator('desk-list-field div[desk]')).toHaveCount(0); + await expect(staffPage.locator('desk-flow-confirm, desk-flow-success')).toHaveCount(0); + }); +} + +const OTHER_DESK = 'e2e-assigned-other-desk'; +const ASSIGNED_REFUSAL = 'You have an assigned desk and cannot book another desk.'; + +/** + * Answer the daily-limit check of _checkResourceAvailable (GET + * /api/staff/v1/bookings with exactly email, limit=1000, period_end, + * period_start and type=desk) with ONE active desk booking for `email` on + * OTHER_DESK, tagged extension_data.is_assigned. Returns the periods served. + */ +async function assignedDeskBooking(page: Page, audit: Audit, email: string) { + const served: { period_start: number; period_end: number }[] = []; + await getOnly(page, audit, 'existing assigned desk booking', (url) => + url.pathname === '/api/staff/v1/bookings' && + [...url.searchParams.keys()].sort().join() === 'email,limit,period_end,period_start,type' && + url.searchParams.get('type') === 'desk' && + url.searchParams.get('email') === email && + url.searchParams.get('limit') === '1000', (url) => { + const period_start = Number(url.searchParams.get('period_start')); + const period_end = Number(url.searchParams.get('period_end')); + served.push({ period_start, period_end }); + return [{ + id: 'e2e-assigned-existing-desk', booking_type: 'desk', asset_id: OTHER_DESK, + asset_name: 'E2E assigned other desk', user_email: email, + booking_start: period_start, booking_end: period_end, + approved: true, rejected: false, deleted: false, + extension_data: { is_assigned: true }, + }]; + }); + return served; +} + +for (const policy of ['allow', 'other_only', 'deny'] as const) { + test('desk settings: bookings.assigned_resource_booking ' + policy + ' decides a new-flow booking beside an assigned one', async ({ staffPage, audit }) => { + const { fixture } = await openDesk(staffPage, audit, { + 'app.desk.assigned_resource_booking': null, + 'app.desks.assigned_resource_booking': null, + 'app.bookings.assigned_resource_booking': policy, + 'app.bookings.allowed_daily_desk_count': 2, + // Seeded desks live in level desk metadata. + 'app.desks.use_assets': false, + }); + const { email } = await fixture.user(); + const served = await assignedDeskBooking(staffPage, audit, email); + const posts = bookingPosts(staffPage); + const armed = policy === 'allow' + ? await armBookingPost(staffPage, (body) => + body.booking_type === 'desk' && body.user_email === email && + typeof body.asset_id === 'string' && body.asset_id !== OTHER_DESK) + : undefined; + + const confirm = staffPage.locator('desk-flow-new button[btn]').filter({ + has: staffPage.locator('icon', { hasText: /^\s*task_alt\s*$/ }), + }); + await expect(confirm).toHaveCount(1); + await confirm.click(); + + await expect.poll(() => served.length, { message: 'existing desk bookings were read' }).toBeGreaterThan(0); + if (policy !== 'allow') { + await expect(staffPage.locator('mat-snack-bar-container')).toContainText(ASSIGNED_REFUSAL); + expect(posts, 'no booking POST is sent').toEqual([]); + await expect(staffPage).toHaveURL(/#\/book\/desk\/form$/); + await expect(staffPage.locator('desk-flow-success')).toHaveCount(0); + } else { + await expect.poll(() => armed!.completed, { message: 'the armed booking POST was answered' }).toBe(true); + expect(posts, 'exactly one booking POST').toHaveLength(1); + expect(armed!.bodies[0].asset_id).not.toBe(OTHER_DESK); + await expect(staffPage).toHaveURL(/#\/book\/desk\/success$/); + await expect(staffPage.locator('desk-flow-success h2')).toBeVisible(); + } + }); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.auto-allocation.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.auto-allocation.spec.ts new file mode 100644 index 00000000000..268c97511b5 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.auto-allocation.spec.ts @@ -0,0 +1,44 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +// DeskFlowNewComponent: auto-allocation replaces manual desk selection with an info panel. +for (const enabled of [false, true]) { + test( + 'desks.auto_allocation ' + + enabled + + ' switches automatic versus manual selection layout', + async ({ staffPage, audit }) => { + const { details } = await openDesk( + staffPage, + audit, + { + 'app.desk.auto_allocation': null, + 'app.desks.auto_allocation': enabled, + }, + { dateReady: false }, + ); + const flow = staffPage.locator('desk-flow-new'); + await expect(flow.locator('desk-flow-select')).toHaveCount( + enabled ? 0 : 1, + ); + if (enabled) + await expect( + flow.locator('p[class~="bg-info/10"]'), + ).toBeVisible(); + else { + await expect(flow.locator('desk-flow-select')).toBeVisible(); + // Current DeskFlowSelect defaults to list; never open its map view. + await expect(flow.locator('desk-flow-select-map')).toHaveCount( + 0, + ); + } + // Source hides the details date controls on desktop during manual selection. + await expect( + details.locator('date-field button').first(), + ).toBeVisible({ visible: enabled }); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.auto-checkin.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.auto-checkin.spec.ts new file mode 100644 index 00000000000..6f54e1c012c --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.auto-checkin.spec.ts @@ -0,0 +1,237 @@ +// Setting: desks.auto_checkin (docs/settings/workplace.md). +// Coverage: 1 unique setting, 2 scenarios. +// Held setting controls are listed in CONTROLS. The harness audit runs with +// auditHttp 'local-only': Google Fonts css2 is answered with empty CSS and any +// other non-local request fails the test. The browser clock is frozen (see +// frozenInstant). +// +// Exact setting key(s): app.desks.auto_checkin +// (legacy app.desk.auto_checkin is held false as a +// control, so only the current key can hide the control) +// Unique settings: 1 +// Scenarios: 2 +// 1. app.desks.auto_checkin false: a near-start desk booking still offers +// the check-in control in the booking details modal. +// 2. app.desks.auto_checkin true: the same booking offers no check-in +// control, because a detected person is already treated as checked in. +// +// Omitted scenario (not source-supported by GET fixtures here): +// The Explore-map half of the setting (an in-use desk painted pending +// `#ffb300` with the setting off, busy `#e53935` with it on) is NOT covered. +// `ExploreDesksService` only loads bookings when `options.use_api` is true or +// the map has a future date; the Workplace Explore page never sets either, so +// in-use ids arrive solely from the AreaManagement driver binding +// (`processBindingChange`). A browser-only HTTP fixture cannot populate that +// binding without a live driver, and the Explore page is not opened here. +// +// Preconditions/fixtures: +// - Read-only local stack (harness `test`, GET-only route stubs, no writes). +// - `/your-bookings?view=list` with one synthetic, owned, approved desk +// booking starting five minutes from now. `asset_id` uses the desk shape id +// convention (`e2e-auto-checkin-desk-shape`) so the same booking would be +// the in-use row if the map path were reachable. +// - `desks.hide_checkin`, `desk.hide_checkin` and `bookings.hide_checkin` are +// explicitly false so the only thing gating the control is auto_checkin. +// - Parking bootstrap GETs are answered locally (every booking card injects +// ParkingService), and no parking assets are listed. +// - Browser clock is frozen so the booking stays in its "upcoming" window. +// - Check-in is never clicked; the modal is closed with Escape. +// +// Expected behaviour (source): +// BookingDetailsModal renders the check-in button only when `auto_checkin()` +// is false. That computed reads `desks.auto_checkin`, falling back to the +// legacy `desk.auto_checkin`, and is true when the booking type's key is true. +// +// Source references: +// libs/bookings/src/lib/booking-details-modal.component.ts +// libs/explore/src/lib/explore-desks.service.ts +// --------------------------------------------------------------------------- +import type { Page } from '@playwright/test'; +import { SchedulePage } from '../../../../../e2e/support/bookings/schedule.page'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + CONTROLS, + fixtureHits, + getOnly, + type Identity, + prepareSettings, + type Settings, + test, +} from '../support/settings-harness'; + +test.describe.configure({ timeout: 120_000 }); + +test.use({ readOnlyTimezone: 'Etc/UTC', auditHttp: 'local-only' }); + +const BOOKING_ID = 9400101; +const DESK_ASSET = 'e2e-auto-checkin-desk-shape'; + +const BASE: Settings = { + ...READ_ONLY_SETTINGS, + 'app.features': ['desks'], + 'app.chat.enabled': false, + 'app.global_search': false, + 'app.use_region': false, + 'app.use_geolocation': false, + 'app.allow_dark_mode': false, + 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], + 'app.events.use_bookings': true, + 'app.basic_user_search': true, + 'app.desks.auto_allocation': false, + 'app.desks.can_book_for_others': false, + 'app.bookings.can_book_for_others': false, + 'app.desks.allow_groups': false, + 'app.desks.has_assets': false, + 'app.bookings.has_assets': false, + 'app.desks.use_assets': false, + 'app.desk.auto_checkin': false, + 'app.desks.hide_checkin': false, + 'app.desk.hide_checkin': false, + 'app.bookings.hide_checkin': false, +}; + +/** + * prepareSettings with BASE and CONTROLS, en-US. Every booking card injects + * ParkingService, whose constructor resolves the hidden asset categories and + * types; the 'e2e-auto-checkin' catalogue answers them locally. + */ +async function preparePage(page: Page, audit: Audit, overrides: Settings) { + return prepareSettings(page, overrides, { + audit, + base: BASE, + controls: CONTROLS, + hidden: 'e2e-auto-checkin', + locale: 'en-US', + landingRooms: false, + }); +} + +async function deskBooking( + fixture: { user: () => Promise }, + start: number, + end: number, +) { + const user = await fixture.user(); + return { + id: BOOKING_ID, + booking_type: 'desk', + type: 'desk', + title: 'Settings auto check-in booking', + asset_id: DESK_ASSET, + asset_name: 'Settings auto check-in desk', + booking_start: start, + booking_end: end, + 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: {}, + }; +} + +/** + * Freeze Date.now (timers keep running) so the near-start window holds. + * Frozen at the real time and never ahead of it, so the minted token stays + * valid; held at least two hours before UTC midnight (the browser runs in + * Etc/UTC) so the booking stays inside today's listing. + */ +function frozenInstant() { + const midnight = new Date(); + midnight.setUTCHours(24, 0, 0, 0); + return new Date(Math.min(Date.now(), midnight.valueOf() - 2 * 3_600_000)); +} + +/** The schedule's desk listing request. */ +function isDeskListing(url: URL) { + return ( + url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'desk' + ); +} + +/** The schedule lists one local day for the desk type only. */ +function deskListing(fixture: { user: () => Promise }, now: number) { + return async (url: 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('the desk listing must carry a valid period'); + const booking = await deskBooking(fixture, now + 300, now + 3900); + return booking.booking_start < end && booking.booking_end > start + ? [booking] + : []; + }; +} + +// One visible check-in control without auto_checkin, none with it. +const SCENARIOS = [ + { + auto_checkin: false, + verb: 'shows', + controls: 1, + message: 'without auto_checkin the check-in control is offered', + }, + { + auto_checkin: true, + verb: 'hides', + controls: 0, + message: 'auto_checkin treats the desk as already checked in', + }, +]; + +for (const scenario of SCENARIOS) { + test(`desks.auto_checkin ${scenario.auto_checkin} ${scenario.verb} the booking check-in control`, async ({ + staffPage, + audit, + }) => { + const fixture = await preparePage(staffPage, audit, { + 'app.desks.auto_checkin': scenario.auto_checkin, + }); + + const instant = frozenInstant(); + await staffPage.clock.setFixedTime(instant); + const now = Math.floor(instant.valueOf() / 1000); + + await getOnly( + staffPage, + audit, + 'desk booking listing', + isDeskListing, + deskListing(fixture, now), + { onError: 'throw' }, + ); + + await staffPage.goto('/#/your-bookings?view=list'); + const schedule = new SchedulePage(staffPage); + await fixture.ready(); + await expect(schedule.card(BOOKING_ID)).toBeVisible(); + expect( + fixtureHits(audit, 'desk booking listing'), + 'the desk booking must be read from the GET fixture', + ).toBeGreaterThan(0); + + const modal = await schedule.openDetails(BOOKING_ID); + await expect(modal).toContainText('Settings auto check-in booking'); + await expect(modal.locator('status-pill')).toBeVisible(); + const control = schedule.checkInButton(modal); + // The count of matching controls, then of visible ones. + await expect(control, scenario.message).toHaveCount(scenario.controls); + await expect( + control.filter({ visible: true }), + scenario.message, + ).toHaveCount(scenario.controls); + + // Never click check-in, checkout, edit, cancel or the overflow menu. + await staffPage.keyboard.press('Escape'); + await expect(modal).toHaveCount(0); + }); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.available-period.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.available-period.spec.ts new file mode 100644 index 00000000000..6b22ff6d645 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.available-period.spec.ts @@ -0,0 +1,66 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { + type Debug, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +for (const days of [7, 14]) { + test( + 'desk settings: desks.available_period ' + + days + + ' gates the calendar horizon', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.available_period': days, + }); + await details.locator('date-field button').first().click(); + const calendar = staffPage.locator( + '.cdk-overlay-container date-calendar', + ); + await expect(calendar).toBeVisible(); + const target = new Date(); + target.setUTCDate(target.getUTCDate() + days); + target.setUTCHours(0, 0, 0, 0); + const indices = () => + calendar.evaluate((el, stamp) => { + const c = (window as Debug).ng?.getComponent(el) as + | { displayed_dates(): { id: number }[] } + | undefined; + if (!c) throw new Error('Calendar debug API required'); + return { + last: c + .displayed_dates() + .findIndex((d) => d.id === stamp), + next: c + .displayed_dates() + .findIndex((d) => d.id === stamp + 86400000), + }; + }, target.valueOf()); + let grid = await indices(); + // Button names are reversed in date-calendar.component.ts: schedule-previous-month calls changeMonth(1) (forward). + if (grid.last < 0 || grid.next < 0) { + await calendar + .locator('button[name="schedule-previous-month"]') + .click(); + await expect + .poll(async () => { + const g = await indices(); + return g.last >= 0 && g.next >= 0; + }) + .toBe(true); + grid = await indices(); + } + expect(grid.last).toBeGreaterThanOrEqual(0); + expect(grid.next).toBeGreaterThanOrEqual(0); + const cells = calendar.locator('button[name="schedule-set-date"]'); + await expect(cells.nth(grid.last)).toBeEnabled(); + await expect(cells.nth(grid.next)).toBeDisabled(); + await cells.nth(grid.last).click(); + await expect(calendar).toHaveCount(0); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.bookable-hours.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.bookable-hours.spec.ts new file mode 100644 index 00000000000..07ac4202cf8 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.bookable-hours.spec.ts @@ -0,0 +1,79 @@ +import type { Locator, Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { pickCalendarDay } from '../../../../../e2e/support/visitor/calendar'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +async function tomorrowStartTimes(page: Page, details: Locator) { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + 1); + date.setUTCHours(12, 0, 0, 0); + await details.locator('date-field button').first().click(); + await pickCalendarDay( + page, + page.locator('.cdk-overlay-container date-calendar'), + date.valueOf(), + { closes_on_pick: true }, + ); + await details.locator('time-field[name="time"] button[time-field]').click(); + const options = page.locator('.cdk-overlay-container button[data-time]'); + await expect(options.first()).toBeVisible(); + const times = await options.evaluateAll((rows) => + rows.map((r) => { + const [h, m] = r.getAttribute('data-time')!.split(':').map(Number); + return h * 60 + m; + }), + ); + await page.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + return times; +} +// DeskFlowDetails passes its shortest duration (desks.min_duration or the +// shortest custom option) to TimeField as min_duration, so the last start offered +// is range.end minus that duration, rounded down to the 15-minute step +// (time-field.component.ts). +const STEP = 15; +const MIN_DURATION = 30; +const durations = { + 'app.desks.min_duration': MIN_DURATION, + 'app.desks.custom_duration_options': [], +}; +const lastStart = (end: number) => + Math.floor((end * 60 - MIN_DURATION) / STEP) * STEP; +for (const range of [null, { start: 6, end: 11 }, { start: 9, end: 17 }]) { + test( + 'desk settings: desks.bookable_hours ' + + (range ? range.start + '-' + range.end : 'unset') + + ' bounds tomorrow time options', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.bookable_hours': range, + ...durations, + }); + const times = await tomorrowStartTimes(staffPage, details); + expect(times.length).toBeGreaterThan(0); + if (range) { + expect(Math.min(...times)).toBe(range.start * 60); + expect(Math.max(...times)).toBe(lastStart(range.end)); + } else { + expect(Math.min(...times)).toBe(0); + expect(Math.max(...times)).toBeGreaterThan(17 * 60); + } + await untouchedDesk(staffPage); + }, + ); +} +// PPT-2872 +test('desk settings: desks.bookable_hours 9-17 does not offer a start at closing time, when no duration fits', async ({ + staffPage, + audit, +}) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.bookable_hours': { start: 9, end: 17 }, + ...durations, + }); + const times = await tomorrowStartTimes(staffPage, details); + expect(times).not.toContain(17 * 60); + expect(Math.max(...times)).toBe(lastStart(17)); + await untouchedDesk(staffPage); +}); diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.can-book-for-others.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.can-book-for-others.spec.ts new file mode 100644 index 00000000000..bdecff81d47 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.can-book-for-others.spec.ts @@ -0,0 +1,65 @@ +import { DeskFlow } from '../../../../../e2e/support/desk/desk-flow.page'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// DeskFlowDetailsComponent can_book_for_others = desks.can_book_for_others ?? bookings.can_book_for_others. +for (const enabled of [false, true]) { + test( + 'desk settings: desks.can_book_for_others ' + + enabled + + ' gates delegate mode with an empty host selector', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.can_book_for_others': enabled, + 'app.basic_user_search': true, + }); + const other = new DeskFlow(staffPage).bookForOtherButton; + await expect(other).toHaveCount(enabled ? 1 : 0); + await expect(details.locator('a-user-search-field')).toHaveCount(0); + if (enabled) { + await other.click(); + // setActiveForm('other') sets the user to EMPTY_USER; no real delegate is searched or selected. + await expect( + details.locator('a-user-search-field input'), + ).toBeVisible(); + await expect( + details.locator('a-user-search-field input'), + ).toHaveValue(''); + await details + .getByRole('button', { name: 'person Single', exact: true }) + .click(); + await expect( + details.locator('a-user-search-field'), + ).toHaveCount(0); + } + await untouchedDesk(staffPage); + }, + ); +} +test('desk settings: desks.can_book_for_others false overrides bookings.can_book_for_others true and hides delegate mode', async ({ + staffPage, + audit, +}) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.can_book_for_others': false, + 'app.bookings.can_book_for_others': true, + 'app.basic_user_search': true, + }); + await expect(new DeskFlow(staffPage).bookForOtherButton).toHaveCount(0); + await expect(details.locator('a-user-search-field')).toHaveCount(0); + await untouchedDesk(staffPage); +}); +test('desk settings: desks.can_book_for_others unset falls back to bookings.can_book_for_others true', async ({ + staffPage, + audit, +}) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.can_book_for_others': null, + 'app.bookings.can_book_for_others': true, + 'app.basic_user_search': true, + }); + await expect(new DeskFlow(staffPage).bookForOtherButton).toHaveCount(1); + await expect(details.locator('a-user-search-field')).toHaveCount(0); + await untouchedDesk(staffPage); +}); diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.can-book-lockers.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.can-book-lockers.spec.ts new file mode 100644 index 00000000000..40017967613 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.can-book-lockers.spec.ts @@ -0,0 +1,54 @@ +import { DeskFlow } from '../../../../../e2e/support/desk/desk-flow.page'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { + type Debug, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// DeskFlowDetailsComponent can_book_lockers gates the secondary-resource checkbox. +for (const enabled of [false, true]) { + test( + 'desks.can_book_lockers ' + + enabled + + ' gates the local secondary locker option', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.can_book_lockers': enabled, + 'app.desks.allow_all_day': false, + }); + const box = new DeskFlow(staffPage).requireLocker; + await expect(box).toHaveCount(enabled ? 1 : 0); + if (enabled) { + await expect(box).not.toBeChecked(); + await box.check(); + await expect(box).toBeChecked(); + await expect + .poll(() => + details.evaluate((el) => { + const c = (window as Debug).ng?.getComponent(el) as + | { model(): { secondary_resource?: string } } + | undefined; + return c?.model().secondary_resource; + }), + ) + .toBe('locker'); + await box.uncheck(); + await expect + .poll(() => + details.evaluate((el) => { + const c = (window as Debug).ng?.getComponent(el) as + | { model(): { secondary_resource?: string } } + | undefined; + return c?.model().secondary_resource; + }), + ) + .toBe(''); + } + // Never open a locker selector or confirm the secondary resource. + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.custom-duration-options.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.custom-duration-options.spec.ts new file mode 100644 index 00000000000..dcd59b33dbc --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.custom-duration-options.spec.ts @@ -0,0 +1,75 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { + DESK_DISTINCT_FALLBACKS, + durationMenu, + futureDesk, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +// DeskFlowDetailsComponent reads desks.custom_duration_options || +// bookings.custom_duration_options; the bookings fallback is [75]. +for (const c of [ + { + value: '[]', + min: 90, + max: 120, + step: 30, + custom: [], + expected: [90, 120], + }, + { + value: '[45,150]', + min: 90, + max: 120, + step: 30, + custom: [45, 150], + expected: [45, 90, 120, 150], + }, +]) { + test( + 'desk settings: desks.custom_duration_options ' + + c.value + + ' changes exact duration choices', + async ({ staffPage, audit }) => { + const { details } = await openDesk( + staffPage, + audit, + { + 'app.desks.min_duration': c.min, + 'app.desks.max_duration': c.max, + 'app.desks.duration_step': c.step, + 'app.desks.custom_duration_options': c.custom, + }, + { fallbacks: DESK_DISTINCT_FALLBACKS }, + ); + await futureDesk(staffPage, details); + await durationMenu(staffPage, details); + await staffPage + .locator( + '.cdk-overlay-container button[data-duration="' + + c.min + + '"]', + ) + .click(); + await details + .locator('time-field[name="time"] button[time-field]') + .click(); + await staffPage + .locator('.cdk-overlay-container button[data-time="11:00"]') + .click(); + const options = await durationMenu(staffPage, details); + expect( + await options.evaluateAll((rows) => + rows.map((r) => Number(r.getAttribute('data-duration'))), + ), + ).toEqual(c.expected); + await staffPage.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.default-duration.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.default-duration.spec.ts new file mode 100644 index 00000000000..ce3ffa93796 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.default-duration.spec.ts @@ -0,0 +1,45 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { + type Debug, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// DeskFlowDetails.allow_all_day uses desks.allow_all_day ?? bookings.allow_all_day. +// BookingFormService.loadForm: a new booking (no id) takes setting('default_duration'), +// resolved as app.desk.* ?? app.desks.* ?? app.bookings.*. Both values sit on the pinned 30-minute grid. +for (const [minutes, label] of [ + [90, /1 hour 30 minutes/], + [150, /2 hours 30 minutes/], +] as const) { + test( + 'desks.default_duration ' + + minutes + + ' sets the new desk booking length', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desk.default_duration': null, + 'app.bookings.default_duration': null, + 'app.desks.default_duration': minutes, + }); + await expect + .poll(() => + details.evaluate((el) => { + const c = (window as Debug).ng?.getComponent(el) as + | { model(): { duration?: number } } + | undefined; + return c?.model().duration; + }), + ) + .toBe(minutes); + const button = details.locator( + 'duration-field button[duration-field]', + ); + await expect(button).toBeEnabled(); + await expect(button).toContainText(label); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.default-select-as-map.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.default-select-as-map.spec.ts new file mode 100644 index 00000000000..36adaf42b84 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.default-select-as-map.spec.ts @@ -0,0 +1,58 @@ +// Setting: desks.default_select_as_map (docs/settings/workplace.md), legacy +// desk form. Coverage: 1 unique setting, 2 scenarios. +// Only the legacy desk picker reads this setting +// (libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts sets +// its first view to 'map' when true, 'list' when false). The routed new flow +// (desk-flow-new desk-flow-select) always starts in list view, so every +// scenario sets app.use_legacy_flows true. The picker is opened from add-desk +// and closed with its close button; no desk is selected and nothing is sent. +import type { Locator, Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { prepare, test } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +/** The picker's list/map toggle, matched on its icon ligature. */ +function viewToggle(page: Page, picker: Locator, name: 'list' | 'map') { + return picker.locator('header button').filter({ + has: page.locator('icon', { hasText: new RegExp('^\\s*' + name + '\\s*$') }), + }); +} + +for (const enabled of [false, true]) { + test('desk settings: desks.default_select_as_map ' + enabled + ' sets the legacy desk picker first view', async ({ staffPage, audit }) => { + const fixture = await prepare(staffPage, audit, { + 'app.use_legacy_flows': true, + 'app.features': ['desks'], + 'app.feature_groups': {}, + 'app.desk.auto_allocation': false, + 'app.desks.auto_allocation': false, + 'app.desks.allow_groups': false, + 'app.desks.default_select_as_map': enabled, + }); + await staffPage.goto('/#/book/desk/form'); + await fixture.ready(); + const form = staffPage.locator('desk-flow-form'); + await expect(form).toBeVisible(); + await expect(staffPage.locator('desk-flow-new')).toHaveCount(0); + const add = staffPage.locator('button[name="add-desk"]'); + await expect(add, 'manual desk selection must be offered').toBeEnabled(); + await expect(form.locator('desk-list-field div[desk]'), 'no desk pre-selected').toHaveCount(0); + + await add.click(); + const picker = staffPage.locator('desk-select-modal'); + await expect(picker).toHaveCount(1); + await expect(picker).toBeVisible(); + const [shown, hidden] = enabled ? ['map', 'list'] as const : ['list', 'map'] as const; + await expect(picker.locator('desk-' + shown), shown + ' view renders first').toHaveCount(1); + await expect(picker.locator('desk-' + hidden), hidden + ' view not rendered').toHaveCount(0); + await expect(viewToggle(staffPage, picker, shown)).toHaveClass(/\bbg-secondary\b/); + await expect(viewToggle(staffPage, picker, hidden)).not.toHaveClass(/\bbg-secondary\b/); + + // Close without a result; the form keeps no desk. + await picker.locator('header button[mat-dialog-close]').click(); + await expect(picker).toHaveCount(0); + await expect(form.locator('desk-list-field div[desk]')).toHaveCount(0); + await expect(staffPage.locator('desk-flow-confirm, desk-flow-success')).toHaveCount(0); + }); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.duration-step.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.duration-step.spec.ts new file mode 100644 index 00000000000..3694fed2fcb --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.duration-step.spec.ts @@ -0,0 +1,67 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { + DESK_DISTINCT_FALLBACKS, + durationMenu, + futureDesk, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +// DeskFlowDetailsComponent reads desks.duration_step || bookings.duration_step. +// step 45 gives 30, 75, 120, 165; the bookings fallback step is 15. +for (const c of [ + { + value: 45, + min: 30, + max: 180, + step: 45, + custom: [], + expected: [30, 75, 120, 165], + }, +]) { + test( + 'desk settings: desks.duration_step ' + + c.value + + ' changes exact duration choices', + async ({ staffPage, audit }) => { + const { details } = await openDesk( + staffPage, + audit, + { + 'app.desks.min_duration': c.min, + 'app.desks.max_duration': c.max, + 'app.desks.duration_step': c.step, + 'app.desks.custom_duration_options': c.custom, + }, + { fallbacks: DESK_DISTINCT_FALLBACKS }, + ); + await futureDesk(staffPage, details); + await durationMenu(staffPage, details); + await staffPage + .locator( + '.cdk-overlay-container button[data-duration="' + + c.min + + '"]', + ) + .click(); + await details + .locator('time-field[name="time"] button[time-field]') + .click(); + await staffPage + .locator('.cdk-overlay-container button[data-time="11:00"]') + .click(); + const options = await durationMenu(staffPage, details); + expect( + await options.evaluateAll((rows) => + rows.map((r) => Number(r.getAttribute('data-duration'))), + ), + ).toEqual(c.expected); + await staffPage.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.force-current-user-for-booking-rules.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.force-current-user-for-booking-rules.spec.ts new file mode 100644 index 00000000000..25942a59f61 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.force-current-user-for-booking-rules.spec.ts @@ -0,0 +1,283 @@ +import type { Page } from '@playwright/test'; +import { DeskFlow } from '../../../../../e2e/support/desk/desk-flow.page'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { + type Audit, + type Debug, + getOnly, + organisationRoutes, + prepare, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +const REGION = 'e2e-settings-region'; +const BUILDING_A = 'e2e-settings-building-a'; +const BUILDING_B = 'e2e-settings-building-b'; +const LEVEL_A = 'e2e-settings-level-a'; +const LEVEL_B = 'e2e-settings-level-b'; +const LEVELS = [ + { + id: LEVEL_A, + parent_id: BUILDING_A, + name: 'E2E Level A', + display_name: 'E2E Level A', + tags: ['level'], + }, + { + id: LEVEL_B, + parent_id: BUILDING_B, + name: 'E2E Level B', + display_name: 'E2E Level B', + tags: ['level'], + }, +]; +const GROUP = 'e2e-settings-restricted-host'; +type AvailabilityProbe = { + _form_value_debounced: { + status(): string; + value(): { user?: { email?: string } } | null; + }; + _available_resource: { status(): string }; + available_resources(): { id: string }[]; +}; +const DESK = 'E2E browser-only desk'; + +async function organisationFixture(page: Page, audit: Audit) { + await organisationRoutes(page, { + audit, + prefix: 'e2e-settings-', + region: { id: REGION, name: 'E2E Region' }, + regionParent: true, + buildings: [ + { + id: BUILDING_A, + parent_id: REGION, + name: 'E2E Building A', + display_name: 'E2E Building A', + tags: ['building'], + timezone: 'UTC', + location: '0,0', + }, + { + id: BUILDING_B, + parent_id: REGION, + name: 'E2E Building B', + display_name: 'E2E Building B', + tags: ['building'], + timezone: 'UTC', + location: '40,40', + }, + ], + levels: LEVELS, + metadata: (name) => + name.endsWith('_booking_rules') || name === 'map_regions' ? [] : {}, + desks: (level) => [ + { + id: 'e2e-settings-desk-' + level.id, + name: DESK + ' ' + level.id, + map_id: 'e2e-settings-desk-shape', + bookable: true, + assigned_to: '', + groups: [], + features: [], + images: [], + }, + ], + deskBookings: true, + }); +} +// BookingFormService._computeAvailableResources -> bookedResourceList uses +// GET /bookings/booked with exactly these five parameters (bookings.fn.ts). +// A JSON string[] is the source-supported response; [] means no booked IDs. +async function bookedDeskResourcesFixture(page: Page, audit: Audit) { + const zones = new Set([ + REGION, + BUILDING_A, + BUILDING_B, + ...LEVELS.map((level) => level.id), + ]); + const keys = ['type', 'zones', 'period_start', 'period_end', 'limit']; + await getOnly( + page, + audit, + 'empty booked desk resources', + (url) => { + const query = url.searchParams; + const start = query.get('period_start') ?? ''; + const end = query.get('period_end') ?? ''; + return ( + url.pathname === '/api/staff/v1/bookings/booked' && + [...query.keys()].length === keys.length && + keys.every((key) => query.getAll(key).length === 1) && + query.get('type') === 'desk' && + zones.has(query.get('zones') ?? '') && + query.get('limit') === '200' && + /^\d+$/.test(start) && + /^\d+$/.test(end) && + Number.isSafeInteger(Number(start)) && + Number(start) > 0 && + Number.isSafeInteger(Number(end)) && + Number(end) > Number(start) + ); + }, + () => [], + ); +} +// BookingFormService._bookingRulesHost picks current user or selected delegate +// during availability/rules evaluation. Never submit or select a resource. +for (const enabled of [false, true]) { + test( + 'bookings.force_current_user_for_booking_rules ' + + enabled + + ' selects rules host groups', + async ({ staffPage, audit }) => { + await organisationFixture(staffPage, audit); + await bookedDeskResourcesFixture(staffPage, audit); + const prepared = await prepare(staffPage, audit, { + 'app.features': ['desks'], + 'app.desks.use_assets': false, + 'app.desk.auto_allocation': null, + 'app.desks.auto_allocation': false, + 'app.default_building': BUILDING_A, + 'app.desks.can_book_for_others': true, + 'app.basic_user_search': true, + 'app.bookings.force_current_user_for_booking_rules': enabled, + }); + await getOnly( + staffPage, + audit, + 'host-sensitive desk rules', + (url) => + /^\/api\/engine\/v2\/metadata\/e2e-settings-building-[ab]$/.test( + url.pathname, + ) && url.searchParams.get('name') === 'desk_booking_rules', + (url) => ({ + desk_booking_rules: { + id: url.pathname.split('/')[5], + name: 'desk_booking_rules', + details: [ + { + zone: '*', + conditions: { groups: [GROUP] }, + rules: { hidden: true }, + }, + ], + }, + }), + ); + const delegate = { + id: 'e2e-settings-delegate', + name: 'E2E Delegate', + email: 'e2e-delegate@example.invalid', + groups: [GROUP], + }; + await getOnly( + staffPage, + audit, + 'delegated host search', + (url) => + url.pathname === '/api/engine/v2/users' && + url.searchParams.get('q') === delegate.email, + () => [delegate], + ); + await staffPage.goto('/#/book/desk/form'); + await prepared.ready(); + expect((await prepared.user()).groups ?? []).not.toContain(GROUP); + const details = staffPage.locator('desk-flow-details'); + // Establish positive current-staff availability before changing the host. + await expect( + staffPage.locator('desk-flow-select-list li[item]'), + ).toHaveCount(1); + await expect( + staffPage.locator('desk-flow-select-list'), + ).toContainText(DESK); + await new DeskFlow(staffPage).bookForOtherButton.click(); + // UserSearchField (focus)=selectInputText() runs input.select() in a setTimeout, so per-key typing can + // overwrite the first characters and send a truncated q. Focus first, then set the full term in one input event. + const host = details.locator('a-user-search-field input'); + await expect(host).toBeEditable(); + await host.click(); + await expect(host).toBeFocused(); + await host.fill(delegate.email); + await expect(host).toHaveValue(delegate.email); + await expect + .poll(() => audit.fixtures['delegated host search'] ?? 0) + .toBeGreaterThan(0); + const option = staffPage + .getByRole('option') + .filter({ hasText: delegate.email }); + await expect(option).toHaveCount(1); + await expect(option).toBeVisible(); + await option.click(); + await expect + .poll(() => + details.evaluate((el) => { + const c = (window as Debug).ng?.getComponent(el) as + | { model(): { user?: { email: string } } } + | undefined; + return c?.model().user?.email; + }), + ) + .toBe(delegate.email); + // BookingFormService debounces the form value by 500 ms before availability + // is recomputed. Wait until the debounced value carries the delegate and the + // availability resource has resolved for it. + await expect + .poll( + () => + details.evaluate((el) => { + const c = (window as Debug).ng?.getComponent(el) as + | { _booking_form?: AvailabilityProbe } + | undefined; + const s = c?._booking_form; + if (!s) + throw new Error( + 'desk booking form service is unavailable', + ); + return { + user: + s._form_value_debounced.status() === + 'resolved' + ? s._form_value_debounced.value()?.user + ?.email + : undefined, + availability: s._available_resource.status(), + desks: + s._available_resource.status() === + 'resolved' + ? s + .available_resources() + .map((r) => r.id) + : undefined, + }; + }), + { + message: + 'availability evaluated with the delegate on the form', + }, + ) + .toEqual({ + user: delegate.email, + availability: 'resolved', + desks: enabled ? ['e2e-settings-desk-' + LEVEL_A] : [], + }); + const list = staffPage.locator('desk-flow-select-list'); + await expect(list).toBeVisible(); + await expect + .poll(() => audit.fixtures['host-sensitive desk rules'] ?? 0) + .toBeGreaterThan(0); + await expect + .poll(() => audit.fixtures['synthetic desk metadata'] ?? 0) + .toBeGreaterThan(0); + await expect + .poll(() => audit.fixtures['empty booked desk resources'] ?? 0) + .toBeGreaterThan(0); + await expect(list.locator('mat-spinner')).toHaveCount(0); + await expect(list.locator('li[item]')).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(list).toContainText(DESK); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.has-assets.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.has-assets.spec.ts new file mode 100644 index 00000000000..7e2e6a91df3 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.has-assets.spec.ts @@ -0,0 +1,27 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { openDesk, test, untouchedDesk } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +// DeskFlowDetailsComponent has_assets = desks.has_assets ?? bookings.has_assets. +for (const enabled of [false, true]) { + test( + 'desks.has_assets ' + + enabled + + ' gates the asset request field with generic flag false', + async ({ staffPage, audit }) => { + const { details } = await openDesk(staffPage, audit, { + 'app.desks.has_assets': enabled, + 'app.bookings.has_assets': false, + }); + await expect(details.locator('asset-list-field')).toHaveCount( + enabled ? 1 : 0, + ); + await expect(details.locator('label[for="assets"]')).toHaveCount( + enabled ? 1 : 0, + ); + if (enabled) + await expect(details.locator('asset-list-field')).toBeVisible(); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.height-enabled.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.height-enabled.spec.ts new file mode 100644 index 00000000000..bfb8f421d1d --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.height-enabled.spec.ts @@ -0,0 +1,94 @@ +// Coverage: 1 unique setting, 2 scenarios. +// Setting: desks.height_enabled (docs/settings/workplace.md). +// Consumer: libs/components/src/lib/user-controls.component.ts. +// These scenarios cover the user-menu desk-height control. The preset panel +// is inspected only: sliders are never changed and Save is never clicked. +import type { Locator, Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL, BACKEND_URL, assertLocalOnly } from '../../../../../e2e/support/env'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { type Settings, stubBootstrap, test } from '../support/settings-harness'; + +// The strict audit aborts and fails every write, every non-local /api read and +// every calendars or events provider read. + +test.describe.configure({ timeout: 120_000 }); + +test.use({ auditHttp: 'strict' }); + +const BASE: Settings = { + ...READ_ONLY_SETTINGS, + 'app.features': [], + 'app.chat.enabled': false, + 'app.global_search': false, + 'app.show_quick_links': false, + 'app.show_quick_book': false, + 'app.hide_landing_customise': true, + 'app.hide_landing_widgets': ['available_now', 'quick_actions', 'desk_week', 'favourites', 'colleagues', 'focus_timer', 'world_clock'], + 'app.use_region': false, + 'app.use_geolocation': false, + 'app.allow_dark_mode': false, + 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], + 'app.events.use_bookings': true, + 'app.feature_groups': {}, + 'app.virtual_concierge.url': '', + 'app.virtual_concierge_url': '', +}; + +async function openUserControls(page: Page, overrides: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + await page.setViewportSize({ width: 1440, height: 1100 }); + await stubBootstrap(page); + const settings = { ...BASE, ...overrides }; + await useSettings(page, settings); + await page.addInitScript(() => localStorage.setItem('APP.locale', 'en-AU')); + await page.goto('/#/landing'); + 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 Window & { setting?: (key: string) => unknown }).setting?.(name), key), + { message: 'effective setting ' + key }).toEqual(value); + } + + await expect( + page.locator('topbar button[name="user-controls"]'), + ).toBeVisible(); + await page.locator('topbar button[name="user-controls"]').click(); + const controls = page.locator('user-controls-sidebar user-controls'); + await expect(controls).toBeVisible(); + return controls; +} + +const deskButton = (page: Page, controls: Locator) => + controls.locator('button').filter({ + has: page.locator('icon', { hasText: /^\s*desk\s*$/ }), + }); + +test( + 'desks.height_enabled false does not add the desk-height control', + async ({ staffPage }) => { + const controls = await openUserControls(staffPage, { + 'app.desks.height_enabled': false, + }); + await expect(deskButton(staffPage, controls)).toHaveCount(0); + }, +); + +test( + 'desks.height_enabled true adds the desk-height control and presets', + async ({ staffPage }) => { + const controls = await openUserControls(staffPage, { + 'app.desks.height_enabled': true, + }); + const button = deskButton(staffPage, controls); + await expect(button).toHaveCount(1); + await expect(button).toBeVisible(); + await button.click(); + await expect( + staffPage.locator('desk-height-presets'), + ).toBeVisible(); + }, +); 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..1f5cda7926f --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.hide-checkin.spec.ts @@ -0,0 +1,109 @@ +// Setting: bookings.hide_checkin (docs/settings/workplace.md). +// Coverage: 1 unique setting, 2 scenarios. +// Read-only: the desk booking is a browser-only GET fixture; check-in is never clicked. +// BookingDetailsModal.can_checkin is false when s.hide_checkin, +// .hide_checkin or bookings.hide_checkin is true; the desk keys are held +// false and auto_checkin is off, so only bookings.hide_checkin can hide it. +// The browser clock is frozen (see frozenInstant) so the synthetic booking +// stays in its upcoming window inside today's listing. +import { SchedulePage } from '../../../../../e2e/support/bookings/schedule.page'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { getOnly, hiddenAssets, prepareSettings, type Settings, test } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const ID = 9100047; + +const HIDDEN = hiddenAssets('e2e-settings'); +const PARKING_CATEGORY = HIDDEN.categories.find((category) => category.name === '_PARKING_')!.id; + +const BASE: 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, +}; + +/** + * The real time, never ahead of it (the minted token stays valid), held at + * least two hours before UTC midnight (the browser runs in Etc/UTC) so a + * booking 5 to 65 minutes ahead stays inside today's listing. + */ +function frozenInstant() { + const midnight = new Date(); + midnight.setUTCHours(24, 0, 0, 0); + return new Date(Math.min(Date.now(), midnight.valueOf() - 2 * 3_600_000)); +} + +for (const hidden of [false, true]) { + test(`bookings.hide_checkin ${hidden} gates the control on a synthetic owned desk booking`, async ({ staffPage, audit }) => { + // Every booking-card injects ParkingService, whose constructor effects + // resolve the hidden asset categories and types (parking-assets.fn.ts); + // prepareSettings answers those GETs so nothing is created; the + // 'parking types' route counts the parking category's type GETs only. + const fixture = await prepareSettings(staffPage, { + 'app.features': ['desks'], 'app.bookings.hide_checkin': hidden, + 'app.desks.hide_checkin': false, 'app.desk.hide_checkin': false, + 'app.desk.auto_checkin': false, 'app.desks.auto_checkin': false, + }, { + audit, base: BASE, locale: 'en-US', landingRooms: false, + routes: () => getOnly(staffPage, audit, 'parking types', (url) => + url.pathname === '/api/engine/v2/asset_types' && + url.searchParams.get('category_id') === PARKING_CATEGORY && url.searchParams.get('limit') === '500', + () => HIDDEN.type_rows[PARKING_CATEGORY].map((row) => ({ + ...row, category_id: PARKING_CATEGORY, brand: 'PlaceOS', + }))), + }); + // Freeze Date.now (timers keep running) so the near-start window holds. + const instant = frozenInstant(); + await staffPage.clock.setFixedTime(instant); + const now = Math.floor(instant.valueOf() / 1000); + const booking = async () => { + const user = await fixture.user(); + return { + id: ID, booking_type: 'desk', type: 'desk', title: 'Settings check-in visibility', + asset_id: 'e2e-settings-readonly-desk', asset_name: 'Settings 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 getOnly(staffPage, audit, '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(audit.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(() => audit.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('Settings 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/desk-booking/desk-booking.max-duration.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.max-duration.spec.ts new file mode 100644 index 00000000000..713755a46ca --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.max-duration.spec.ts @@ -0,0 +1,67 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { + DESK_DISTINCT_FALLBACKS, + durationMenu, + futureDesk, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +// DeskFlowDetailsComponent reads desks.max_duration || bookings.max_duration. +// max 180 ends the list at 180; the bookings fallback (480) would run past it. +for (const c of [ + { + value: 180, + min: 30, + max: 180, + step: 30, + custom: [], + expected: [30, 60, 90, 120, 150, 180], + }, +]) { + test( + 'desk settings: desks.max_duration ' + + c.value + + ' changes exact duration choices', + async ({ staffPage, audit }) => { + const { details } = await openDesk( + staffPage, + audit, + { + 'app.desks.min_duration': c.min, + 'app.desks.max_duration': c.max, + 'app.desks.duration_step': c.step, + 'app.desks.custom_duration_options': c.custom, + }, + { fallbacks: DESK_DISTINCT_FALLBACKS }, + ); + await futureDesk(staffPage, details); + await durationMenu(staffPage, details); + await staffPage + .locator( + '.cdk-overlay-container button[data-duration="' + + c.min + + '"]', + ) + .click(); + await details + .locator('time-field[name="time"] button[time-field]') + .click(); + await staffPage + .locator('.cdk-overlay-container button[data-time="11:00"]') + .click(); + const options = await durationMenu(staffPage, details); + expect( + await options.evaluateAll((rows) => + rows.map((r) => Number(r.getAttribute('data-duration'))), + ), + ).toEqual(c.expected); + await staffPage.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.min-duration.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.min-duration.spec.ts new file mode 100644 index 00000000000..dc8d8b75b53 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.min-duration.spec.ts @@ -0,0 +1,67 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { + DESK_DISTINCT_FALLBACKS, + durationMenu, + futureDesk, + openDesk, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +// DeskFlowDetailsComponent reads desks.min_duration || bookings.min_duration. +// min 30 starts the list at 30; the bookings fallback (15) would start it at 15. +for (const c of [ + { + value: 30, + min: 30, + max: 120, + step: 30, + custom: [], + expected: [30, 60, 90, 120], + }, +]) { + test( + 'desk settings: desks.min_duration ' + + c.value + + ' changes exact duration choices', + async ({ staffPage, audit }) => { + const { details } = await openDesk( + staffPage, + audit, + { + 'app.desks.min_duration': c.min, + 'app.desks.max_duration': c.max, + 'app.desks.duration_step': c.step, + 'app.desks.custom_duration_options': c.custom, + }, + { fallbacks: DESK_DISTINCT_FALLBACKS }, + ); + await futureDesk(staffPage, details); + await durationMenu(staffPage, details); + await staffPage + .locator( + '.cdk-overlay-container button[data-duration="' + + c.min + + '"]', + ) + .click(); + await details + .locator('time-field[name="time"] button[time-field]') + .click(); + await staffPage + .locator('.cdk-overlay-container button[data-time="11:00"]') + .click(); + const options = await durationMenu(staffPage, details); + expect( + await options.evaluateAll((rows) => + rows.map((r) => Number(r.getAttribute('data-duration'))), + ), + ).toEqual(c.expected); + await staffPage.keyboard.press('Escape'); + await expect(options).toHaveCount(0); + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.show-calendar-links.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.show-calendar-links.spec.ts new file mode 100644 index 00000000000..c7cbeba1479 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.show-calendar-links.spec.ts @@ -0,0 +1,78 @@ +import { expect } from '../../../../../e2e/support/fixtures'; +import { prepare, test } from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +// BookingFormService reads PLACEOS.last_booked_booking into Booking; success +// uses that state to generate links. Browser-only data: never save or look up this ID. +for (const enabled of [false, true]) { + test( + 'desks.show_calendar_links ' + + enabled + + ' gates links for browser-only success state', + async ({ staffPage, audit }) => { + const fixture = await prepare(staffPage, audit, { + 'app.features': ['desks'], + 'app.desks.show_calendar_links': enabled, + }); + await staffPage.addInitScript(() => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + 1); + date.setUTCHours(10, 0, 0, 0); + sessionStorage.setItem( + 'PLACEOS.last_booked_booking', + JSON.stringify({ + id: 99009501, + booking_type: 'desk', + title: 'Prepared calendar preview', + asset_id: 'prepared-calendar-desk', + asset_name: 'Prepared calendar desk', + date: date.valueOf(), + duration: 60, + all_day: false, + booking_start: Math.floor(date.valueOf() / 1000), + booking_end: Math.floor(date.valueOf() / 1000) + 3600, + user_email: 'prepared-calendar@example.invalid', + user_name: 'Prepared calendar user', + zones: [], + extension_data: {}, + }), + ); + sessionStorage.setItem('PLACEOS.last_booked_count', '1'); + }); + await staffPage.goto('/#/book/desk/success'); + await fixture.ready(); + const panel = staffPage.locator('desk-flow-success'); + await expect(panel).toHaveCount(1); + await expect(panel.locator('main')).toBeVisible(); + await expect(panel.locator('h2')).toContainText( + 'Prepared calendar desk', + ); + for (const name of ['outlook', 'google', 'ical']) + await expect( + panel.locator('a[name="desk-' + name + '-link"]'), + ).toHaveCount(enabled ? 1 : 0); + if (enabled) { + const outlook = panel.locator('a[name="desk-outlook-link"]'); + const google = panel.locator('a[name="desk-google-link"]'); + await expect(outlook).toBeVisible(); + await expect(google).toBeVisible(); + await expect(outlook).toHaveAttribute( + 'href', + /^https:\/\/outlook\.office\.com\/calendar\/deeplink\/compose\?/, + ); + await expect(google).toHaveAttribute( + 'href', + /^https:\/\/calendar\.google\.com\/calendar\/render\?/, + ); + await expect( + panel.locator('a[name="desk-ical-link"]'), + ).toBeVisible(); + await expect( + panel.locator('a[name="desk-ical-link"]'), + ).toHaveAttribute('href', /^(?:blob:|data:text\/calendar)/); + } + // No calendar links, booking actions, check-in or external integrations clicked. + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.show-users.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.show-users.spec.ts new file mode 100644 index 00000000000..d5b8175533a --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.show-users.spec.ts @@ -0,0 +1,361 @@ +// Coverage: 1 unique setting, 3 scenarios. +// Setting: desks.show_users (docs/settings/workplace.md; source: +// apps/workplace/src/app/book/desk-flow-new/desk-flow-select-map.component.ts; +// libs/explore/src/lib/explore-desk-info.component.ts). +// +// The desk map only requests desk bookings and builds the per-desk user map +// when desks.show_users is true. The organisation, map asset, desk metadata and +// booking row are browser-only GET fixtures; no desk is selected, no booking is +// created and no backend record is changed. +import type { Locator, Page } from '@playwright/test'; +import { DeskFlow } from '../../../../../e2e/support/desk/desk-flow.page'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + type DebugWindow, + fixtureHits, + getOnly, + organisationRoutes, + PIXEL, + prepareSettings, + type Settings, + test, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const BASE: Settings = { + ...READ_ONLY_SETTINGS, + 'app.features': ['desks'], + '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.logo_light': PIXEL, + 'app.basic_user_search': true, + 'app.explore.cisco_maps.enabled': false, +}; + +const REGION = 'e2e-settings-region'; +const BUILDING_A = 'e2e-settings-building-a'; +const LEVEL_A = 'e2e-settings-level-a'; +const MAP = new URL('/e2e-settings-map.svg', APP_URL).href; +const DESK = 'E2E browser-only desk'; +const DESK_ID = 'e2e-settings-desk-' + LEVEL_A; +const LEVELS = [ + { + id: LEVEL_A, + parent_id: BUILDING_A, + name: 'E2E Level A', + display_name: 'E2E Level A', + tags: ['level'], + map_id: MAP, + }, +]; +const BOOKING = { + id: 'e2e-show-users-booking', + booking_type: 'desk', + user_email: 'prepared-show-users@example.invalid', + user_name: 'Prepared Show Users', + booked_by_name: 'Prepared Show Users', + asset_id: DESK_ID, + asset_ids: [DESK_ID], + asset_name: DESK + ' ' + LEVEL_A, + date: Date.now(), + date_end: Date.now() + 60 * 60 * 1000, + duration: 60, + status: 'approved', + zones: [LEVEL_A], + extension_data: {}, +}; + +async function organisationFixture(page: Page, audit: Audit) { + await organisationRoutes(page, { + audit, + prefix: 'e2e-settings-', + region: { id: REGION, name: 'E2E Region' }, + regionParent: true, + buildings: [ + { + id: BUILDING_A, + parent_id: REGION, + name: 'E2E Building A', + display_name: 'E2E Building A', + tags: ['building'], + timezone: 'UTC', + location: '0,0', + }, + ], + levels: LEVELS, + metadata: (name) => + name.endsWith('_booking_rules') || name === 'map_regions' ? [] : {}, + desks: (level) => [ + { + id: 'e2e-settings-desk-' + level.id, + name: DESK + ' ' + level.id, + map_id: 'e2e-settings-desk-shape', + bookable: true, + assigned_to: '', + groups: [], + features: [], + images: [], + }, + ], + }); + // The same booking row in every scenario, so only the setting decides + // whether the map requests and labels it. + await getOnly( + page, + audit, + 'desk bookings', + (url) => + url.pathname === '/api/staff/v1/bookings' && + url.searchParams.get('type') === 'desk' && + url.searchParams.has('period_start') && + url.searchParams.has('period_end'), + (url) => { + const start = Number(url.searchParams.get('period_start')) * 1000; + return [ + { + ...BOOKING, + date: start + 3600 * 1000, + date_end: start + 7200 * 1000, + }, + ]; + }, + { onError: 'throw' }, + ); + await getOnly( + page, + audit, + 'empty booked desk resources', + (url) => + url.pathname === '/api/staff/v1/bookings/booked' && + url.searchParams.get('type') === 'desk' && + url.searchParams.get('limit') === '200' && + url.searchParams.has('zones') && + url.searchParams.has('period_start') && + url.searchParams.has('period_end'), + () => [], + { onError: 'throw' }, + ); + await page.route( + (url) => url.href === MAP, + async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + audit.fixtures['synthetic SVG'] = + (audit.fixtures['synthetic SVG'] ?? 0) + 1; + await route.fulfill({ + contentType: 'image/svg+xml', + body: + '' + + '', + }); + }, + ); +} + +async function prepare(page: Page, audit: Audit, show_users: boolean | null) { + const fixture = await prepareSettings( + page, + { + 'app.default_building': BUILDING_A, + 'app.desk.auto_allocation': null, + 'app.desks.auto_allocation': false, + 'app.desks.can_book_for_others': false, + 'app.bookings.can_book_for_others': false, + 'app.desks.allow_groups': false, + 'app.desks.has_assets': false, + 'app.bookings.has_assets': false, + 'app.desks.can_book_lockers': false, + 'app.desks.use_assets': false, + 'app.desks.bookable_hours': null, + 'app.desks.allow_recurrence': false, + 'app.desks.allow_multiple': false, + 'app.desks.show_users': show_users, + }, + { + audit, + base: BASE, + hidden: 'e2e-show-users', + locale: 'en-US', + landingRooms: false, + routes: () => organisationFixture(page, audit), + }, + ); + await page.goto('/#/book/desk/form'); + await fixture.ready({ + keys: ['app.features', 'app.desks.show_users', 'app.default_building'], + }); + return fixture.settings; +} + +async function deskMap(page: Page, audit: Audit) { + const flow = new DeskFlow(page); + await expect(flow.title).toBeEditable(); + await expect + .poll(() => fixtureHits(audit, 'synthetic buildings')) + .toBeGreaterThan(0); + await expect + .poll(() => fixtureHits(audit, 'synthetic levels')) + .toBeGreaterThan(0); + await expect + .poll(() => fixtureHits(audit, 'synthetic desk metadata')) + .toBeGreaterThan(0); + const list = page.locator('desk-flow-select-list'); + await expect(list).toBeVisible(); + await expect(list.locator('mat-spinner')).toHaveCount(0); + await expect( + list.locator('li[item]').filter({ hasText: DESK + ' ' + LEVEL_A }), + ).toHaveCount(1); + const select = page.locator('desk-flow-select'); + await expect(select).toBeVisible(); + await expect(select.locator('desk-flow-select-map')).toHaveCount(0); + // The view toggle's icon ligature is lowercase `map` + // (desk-flow-select.component.ts); the visible "Map" label is a separate div. + await select + .locator('button', { + has: page.locator('icon', { hasText: /^\s*map\s*$/ }), + }) + .click(); + const map = select.locator('desk-flow-select-map'); + await expect(map).toHaveCount(1); + await expect(map.locator('interactive-map')).toHaveCount(1); + await expect + .poll(() => fixtureHits(audit, 'synthetic SVG')) + .toBeGreaterThan(0); + // The form model selects the only fixture level on init, which gives the + // map's booking query its zone; the level select is never opened. + await expect(map.locator('mat-select')).toContainText('E2E Level A'); + await expect + .poll(() => map.locator('div[track-id^="desk:hover:"]').count()) + .toBeGreaterThan(0); + return { flow, map }; +} + +type MapQueryState = { + zone: string | null; + has_date: boolean; + query: boolean; + debounced: boolean; +}; + +/** + * Read-only probe of the map's own desk-booking query + * (desk-flow-select-map.component.ts). Desk bookings are requested + * only by the `_level_bookings` resource, whose params are + * `_booking_query_debounced`. `_booking_query` is undefined unless + * desks.show_users is on AND a level and a form date exist. Seeing the level + * and date present with the query undefined therefore proves the request + * cannot be issued; it is not inferred from waiting. Fails closed if the + * signals are unavailable. + */ +function mapQueryState(map: Locator): Promise { + return map.evaluate((element) => { + const component = (window as DebugWindow).ng?.getComponent(element) as + | { + level?: () => { id?: string } | null; + form_value?: () => { date?: number } | null; + _booking_query?: () => unknown; + _booking_query_debounced?: { value: () => unknown }; + } + | undefined; + if ( + !component?.level || + !component.form_value || + !component._booking_query || + !component._booking_query_debounced + ) { + throw new Error('desk map booking query signals are unavailable'); + } + return { + zone: component.level()?.id ?? null, + has_date: (component.form_value()?.date ?? 0) > 0, + query: component._booking_query() !== undefined, + debounced: component._booking_query_debounced.value() !== undefined, + }; + }); +} + +/** The user label of the first desk feature on the map. */ +function deskUserLabel(map: Locator) { + return map + .locator('div[track-id^="desk:hover:"] explore-desk-info') + .first() + .evaluate((element) => { + const component = (window as DebugWindow).ng?.getComponent( + element, + ) as { display_user?: () => string } | undefined; + return component?.display_user?.() ?? ''; + }); +} + +// With show_users on, the map builds a booking query, requests desk bookings +// and labels the desk with the booker; with it off, none of that happens. +// Unset (null) falls back to on: the map reads +// settings.signal('desks.show_users', true), and a null override resolves to +// that default while keeping any stack zone value out. +const SCENARIOS = [ + { + show_users: false, + shown: false, + user_label: '', + message: 'show_users false must not request desk bookings', + }, + { + show_users: true, + shown: true, + user_label: 'Prepared Show Users', + message: 'show_users must request desk bookings', + }, + { + show_users: null, + shown: true, + user_label: 'Prepared Show Users', + message: 'unset show_users must fall back to requesting desk bookings', + }, +]; + +for (const scenario of SCENARIOS) { + test(`desks.show_users ${scenario.show_users} gates desk booking user data on the map`, async ({ + staffPage, + audit, + }) => { + await prepare(staffPage, audit, scenario.show_users); + const { flow, map } = await deskMap(staffPage, audit); + // Level and date are present in both scenarios, so the only thing + // deciding whether the booking query exists is the setting. + await expect + .poll(() => mapQueryState(map), { + message: 'the desk map booking query state', + }) + .toEqual({ + zone: LEVEL_A, + has_date: true, + query: scenario.shown, + debounced: scenario.shown, + }); + await expect + .poll(() => fixtureHits(audit, 'desk bookings') > 0, { + message: scenario.message, + }) + .toBe(scenario.shown); + await expect + .poll(() => deskUserLabel(map), { + message: 'the desk feature user label', + }) + .toBe(scenario.user_label); + // Re-checked after the label settled: still no request when off. + expect(fixtureHits(audit, 'desk bookings') > 0, scenario.message).toBe( + scenario.shown, + ); + await expect(staffPage).toHaveURL(/#\/book\/desk\/form$/); + await expect(staffPage.locator('desk-flow-success')).toHaveCount(0); + await expect(flow.chosenDesks).toHaveCount(0); + }); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.use-assets.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.use-assets.spec.ts new file mode 100644 index 00000000000..877115705de --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.use-assets.spec.ts @@ -0,0 +1,271 @@ +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { + type Audit, + ORIGINS, + prepare, + test, + untouchedDesk, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); +const CATEGORY = 'e2e-readonly-desks-category'; +const TYPE = 'e2e-readonly-desks-type'; +const METADATA_NAME = 'E2E metadata source desk'; +const ASSET_NAME = 'E2E assets source desk'; +const ENGINE = '/api/engine/v2/'; +function exactQuery(url: URL, expected: Record) { + return ( + [...url.searchParams].length === Object.keys(expected).length && + Object.entries(expected).every( + ([key, value]) => url.searchParams.get(key) === value, + ) + ); +} + +/** The zone filter of an exact desk asset query, or undefined for any other query. */ +function assetScope( + url: URL, +): { key: 'zone_id' | 'zones'; zone: string } | undefined { + for (const key of ['zone_id', 'zones'] as const) { + const zone = url.searchParams.get(key); + if ( + zone && + exactQuery(url, { [key]: zone, type_id: TYPE, limit: '500' }) + ) + return { key, zone }; + } + return undefined; +} + +type LevelRow = { zone?: { id?: string }; metadata?: Record }; + +async function prepareSources(page: Page, audit: Audit) { + const seen = { metadata: 0, categories: 0, types: 0, assets: 0 }; + // Page routes run before the context's existing read-only guard. Fallback + // (never continue) keeps that guard authoritative for all unmatched traffic. + // Fixture problems are recorded in the audit (failing the test in teardown) + // and the request is aborted, never passed on to the real backend. + await page.route('**/api/**', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() !== 'GET' || !ORIGINS.has(url.origin)) { + await route.fallback(); + return; + } + let body: unknown; + try { + if ( + url.pathname === `${ENGINE}asset_categories` && + exactQuery(url, { hidden: 'true', limit: '500' }) + ) { + seen.categories++; + body = [ + { + id: CATEGORY, + name: '_DESKS_', + hidden: true, + created_at: 1, + }, + ]; + } else if ( + url.pathname === `${ENGINE}asset_types` && + exactQuery(url, { category_id: CATEGORY, limit: '500' }) + ) { + seen.types++; + body = [ + { + id: TYPE, + category_id: CATEGORY, + name: '_DESKS_', + brand: 'PlaceOS', + created_at: 1, + }, + ]; + } else if (url.pathname === `${ENGINE}assets` && assetScope(url)) { + // One level (zone_id=), or every level of a building or + // region (zones=), which is how the desk booking list asks. + const scope = assetScope(url)!; + let levels = [scope.zone]; + if (scope.key === 'zones') { + // A GET to the same origin-checked backend, so it cannot reach an + // unchecked origin or change data. + const levelsUrl = new URL(`${ENGINE}zones`, url.origin); + levelsUrl.search = new URLSearchParams({ + parent_id: scope.zone, + tags: 'level', + limit: '500', + }).toString(); + const response = await route.fetch({ + url: levelsUrl.toString(), + method: 'GET', + maxRedirects: 0, + maxRetries: 0, + }); + const rows: unknown = response.ok() + ? await response.json() + : undefined; + const list = Array.isArray(rows) + ? rows + : (rows as { results?: unknown[] } | undefined) + ?.results; + levels = (list ?? []) + .map((zone) => (zone as { id?: string })?.id) + .filter((id): id is string => !!id); + if (!levels.length) { + audit.monitoring_errors.push( + 'desk assets fixture: ' + + (response.ok() + ? 'the zone has no levels' + : 'levels GET must succeed (status ' + + response.status() + + ')'), + ); + await route.abort('failed'); + return; + } + } + seen.assets++; + body = levels.map((zone_id) => ({ + id: `e2e-readonly-asset-${zone_id}`, + asset_type_id: TYPE, + zone_id, + zones: + scope.key === 'zones' + ? [scope.zone, zone_id] + : [zone_id], + map_id: `e2e-readonly-asset-map-${zone_id}`, + identifier: ASSET_NAME, + name: ASSET_NAME, + bookable: true, + assigned_to: '', + place_groups: [], + features: [], + images: [], + security_system_groups: [], + })); + } else if ( + /^\/api\/engine\/v2\/metadata\/[^/]+\/children$/.test( + url.pathname, + ) && + exactQuery(url, { name: 'desks' }) + ) { + // Preserve real level identities from the app's own GET. Replace + // only desk details in this browser response; never save metadata. + // The same GET to the same origin-checked URL, with no redirect or + // retry, so it cannot reach an unchecked origin or change data. + const response = await route.fetch({ + method: 'GET', + maxRedirects: 0, + maxRetries: 0, + }); + const rows: unknown = response.ok() + ? await response.json() + : undefined; + const problem = !response.ok() + ? 'level metadata GET must succeed (status ' + + response.status() + + ')' + : !Array.isArray(rows) + ? 'child metadata must be an array' + : rows.length === 0 + ? 'fixture requires existing level metadata' + : (rows as LevelRow[]).some((row) => !row?.zone?.id) + ? 'metadata must identify an existing level' + : ''; + if (problem) { + audit.monitoring_errors.push( + 'desk metadata fixture: ' + problem, + ); + await route.abort('failed'); + return; + } + body = (rows as LevelRow[]).map((row) => ({ + ...row, + metadata: { + ...row.metadata, + desks: { + name: 'desks', + details: [ + { + id: `e2e-readonly-metadata-${row.zone!.id}`, + map_id: `e2e-readonly-metadata-map-${row.zone!.id}`, + name: METADATA_NAME, + bookable: true, + assigned_to: '', + groups: [], + features: [], + images: [], + security: '', + }, + ], + }, + }, + })); + seen.metadata++; + } else { + await route.fallback(); + return; + } + } catch (error) { + audit.monitoring_errors.push( + 'desk source fixture: ' + String(error), + ); + await route.abort('failed').catch(() => undefined); + return; + } + audit.fixtures['desk sources'] = + (audit.fixtures['desk sources'] ?? 0) + 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + }); + return seen; +} + +for (const enabled of [false, true]) { + test( + 'desks.use_assets ' + + enabled + + ' selects assets API versus level metadata', + async ({ staffPage, audit }) => { + // Register source fixtures after bootstrap helpers, before navigation. + const prepared = await prepare(staffPage, audit, { + 'app.features': ['desks'], + 'app.desks.use_assets': enabled, + 'app.desk.auto_allocation': null, + 'app.desks.auto_allocation': false, + 'app.desks.can_book_lockers': false, + }); + const seen = await prepareSources(staffPage, audit); + await staffPage.goto('/#/book/desk/form'); + await prepared.ready(); + const list = staffPage.locator('desk-flow-select-list'); + await expect(list).toBeVisible(); + await expect(staffPage.locator('desk-flow-select-map')).toHaveCount( + 0, + ); + const expected = enabled ? ASSET_NAME : METADATA_NAME; + const other = enabled ? METADATA_NAME : ASSET_NAME; + await expect( + list.locator('li[item]').filter({ hasText: expected }).first(), + ).toBeVisible(); + await expect( + list.locator('li[item]').filter({ hasText: other }), + ).toHaveCount(0); + if (enabled) { + expect(seen.categories).toBeGreaterThan(0); + expect(seen.types).toBeGreaterThan(0); + expect(seen.assets).toBeGreaterThan(0); + } else { + expect(seen.metadata).toBeGreaterThan(0); + expect(seen.categories).toBe(0); + expect(seen.types).toBe(0); + expect(seen.assets).toBe(0); + } + await untouchedDesk(staffPage); + }, + ); +} diff --git a/apps/workplace/e2e/settings/desk-booking/desk-booking.use-building-timezone.spec.ts b/apps/workplace/e2e/settings/desk-booking/desk-booking.use-building-timezone.spec.ts new file mode 100644 index 00000000000..73bdea8c397 --- /dev/null +++ b/apps/workplace/e2e/settings/desk-booking/desk-booking.use-building-timezone.spec.ts @@ -0,0 +1,155 @@ +// Coverage: 1 unique setting, 3 scenarios. +// Setting: desks.use_building_timezone (docs/settings/workplace.md; source: +// apps/workplace/src/app/book/desk-flow-new/desk-flow-details.component.ts, +// rendered by libs/form-fields/src/lib/duration-field.component.ts). +// +// The details duration field appends a second time line when the desk flow is +// configured to use the active building's timezone. The browser is pinned to +// UTC and the real building rows are rewritten to Pacific/Honolulu, so the +// offset is deterministic without changing any backend record. No desk is +// selected, no asset is attached and no booking is confirmed. +// +// DeskFlowDetails reads desks.use_building_timezone ?? bookings.use_building_timezone +// (PPT-2871), so an explicit desks false overrides bookings true. +import type { Locator, Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + type Debug, + futureDesk, + prepareSettings, + type Settings, + test, + timezoneBuildings, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const DESK: Settings = { + ...READ_ONLY_SETTINGS, + 'app.features': ['desks'], + 'app.feature_groups': {}, + '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, + 'app.basic_user_search': true, + 'app.desk.auto_allocation': true, + 'app.desks.auto_allocation': true, + 'app.desks.hide_title': false, + 'app.desks.allow_groups': false, + 'app.desks.can_book_for_others': false, + 'app.bookings.can_book_for_others': false, + 'app.desks.can_book_lockers': false, + 'app.desks.has_assets': false, + 'app.bookings.has_assets': false, + 'app.desks.use_assets': false, + 'app.desks.allow_recurrence': false, + 'app.desks.allow_time_changes': true, + 'app.desks.allow_all_day': false, + 'app.bookings.allow_all_day': false, + 'app.desk.all_day_default': false, + 'app.desks.all_day_default': false, + 'app.bookings.all_day_default': false, + 'app.bookings.use_building_timezone': false, + 'app.desks.available_period': 30, + 'app.desks.bookable_hours': null, + 'app.desk.min_duration': null, + 'app.desk.max_duration': null, + 'app.desk.duration_step': null, + 'app.desk.custom_duration_options': null, + 'app.desks.min_duration': 30, + 'app.desks.max_duration': 180, + 'app.desks.duration_step': 30, + 'app.desks.custom_duration_options': [], + 'app.bookings.min_duration': 30, + 'app.bookings.max_duration': 180, + 'app.bookings.duration_step': 30, + 'app.bookings.custom_duration_options': [], +}; + +async function prepare( + page: Page, + audit: Audit, + enabled: boolean, + bookings: boolean = false, +) { + // Real building rows, rewritten to Pacific/Honolulu, which differs from the + // browser's pinned Etc/UTC. This is a browser GET response only. + await timezoneBuildings(page, audit); + const fixture = await prepareSettings(page, { + 'app.desks.use_building_timezone': enabled, + 'app.bookings.use_building_timezone': bookings, + }, { audit, base: DESK, hidden: 'e2e-desk-timezone', landingRooms: false }); + await page.goto('/#/book/desk/form'); + await fixture.ready({ + keys: ['app.features', 'app.desks.use_building_timezone', 'app.bookings.use_building_timezone'], + }); + const details = page.locator('desk-flow-new desk-flow-details'); + await expect(details.locator('input[name$=".title"]')).toBeEditable(); + await expect(details.locator('date-field button').first()).toBeEnabled(); + return details; +} + +/** The effective value of a setting, as the app reads it (unset is null). */ +const effective = (page: Page, key: string) => + page.evaluate((name) => (window as Debug).setting?.(name) ?? null, key); + +/** The duration field's bound timezone ('' when the building time is off). */ +function durationTimezone(details: Locator) { + return details.locator('duration-field').evaluate((element) => { + const field = (window as Debug).ng?.getComponent(element) as { timezone(): string } | undefined; + return field?.timezone(); + }); +} + +/** Tomorrow at 10:00 with the first duration offered. */ +async function futureDeskWithDuration(page: Page, details: Locator) { + await futureDesk(page, details); + await details.locator('duration-field button[duration-field]').click(); + await page.locator('.cdk-overlay-container button[data-duration]').first().click(); +} + +for (const enabled of [false, true]) { + test(`desks.use_building_timezone ${enabled} gates the duration secondary time line`, + async ({ staffPage, audit }) => { + const details = await prepare(staffPage, audit, enabled); + expect(audit.fixtures['timezone buildings'] ?? 0, 'building-timezone rows served').toBeGreaterThan(0); + await futureDeskWithDuration(staffPage, details); + const secondary = details.locator('duration-field div.opacity-30'); + await expect(secondary).toHaveCount(enabled ? 1 : 0); + if (enabled) { + await expect(secondary).toBeVisible(); + await expect(secondary).toContainText(/GMT-10/); + } + await expect(staffPage).toHaveURL(/#\/book\/desk\/form$/); + await expect(staffPage.locator('desk-flow-success')).toHaveCount(0); + }); +} + +// PPT-2871 +test('desks.use_building_timezone false overrides bookings.use_building_timezone true and hides the duration secondary time line', + async ({ staffPage, audit }) => { + const details = await prepare(staffPage, audit, false, true); + expect(audit.fixtures['timezone buildings'] ?? 0, 'building-timezone rows served').toBeGreaterThan(0); + await expect + .poll(() => effective(staffPage, 'app.bookings.use_building_timezone')) + .toBe(true); + await expect + .poll(() => effective(staffPage, 'app.desks.use_building_timezone')) + .toBe(false); + await futureDeskWithDuration(staffPage, details); + // The bound timezone proves the effective setting, so the count below + // is not inferred from waiting. + await expect + .poll(() => durationTimezone(details), { message: 'desk duration-field timezone' }) + .toBe(''); + await expect(details.locator('duration-field div.opacity-30')).toHaveCount(0); + await expect(staffPage).toHaveURL(/#\/book\/desk\/form$/); + await expect(staffPage.locator('desk-flow-success')).toHaveCount(0); + }); 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..bd5d6161b24 --- /dev/null +++ b/apps/workplace/e2e/settings/directory/directory.min-search-length.spec.ts @@ -0,0 +1,148 @@ +import type { 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 } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + test, +} from '../support/settings-harness'; + +// Setting: users.min_search_length (docs/settings/workplace.md). +// Coverage: 1 unique setting, 2 scenarios. +// 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-settings-directory-person', + name: 'Settings Directory Person', + email: 'settings-directory@example.invalid', +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +// DirectoryUserList, Topbar and FooterMenu require HTTP only. Chat is disabled +// and user controls are never opened, so every PlaceOS WebSocket fails the test. +test.use({ auditSockets: 'block-all' }); + +async function openDirectory( + page: Page, + overrides: Record, + audit: Audit, +) { + 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); + // The only input in the directory search field (user-list.component.ts). + // The placeholder is translated and its wording changed, so it is not used. + const input = root.locator('mat-form-field[overlay] input'); + 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(`users.min_search_length ${minimum} gates directory lookup at its boundary`, async ({ + staffPage, + audit, + }) => { + await staffPage.clock.install(); + const { root, input, requests } = await openDirectory( + staffPage, + { 'app.users.min_search_length': minimum }, + audit, + ); + 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); + // Below-minimum empty state: the template shows arrow_upward only when + // the search is shorter than min_search_length (close otherwise). + await expect( + root.locator('main icon', { hasText: /^\s*arrow_upward\s*$/ }), + ).toHaveCount(1); + await expect( + root.locator('main icon', { hasText: /^\s*close\s*$/ }), + ).toHaveCount(0); + 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..555ea50eb82 --- /dev/null +++ b/apps/workplace/e2e/settings/directory/directory.show-avatars.spec.ts @@ -0,0 +1,137 @@ +import type { 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 } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + test, +} from '../support/settings-harness'; + +// Setting: users.show_avatars (docs/settings/workplace.md). +// Coverage: 1 unique setting, 2 scenarios. +// 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-settings-directory-person', + name: 'Settings Directory Person', + email: 'settings-directory@example.invalid', +}; +const ORIGINS = new Set([new URL(APP_URL).origin, new URL(BACKEND_URL).origin]); + +// DirectoryUserList, Topbar and FooterMenu require HTTP only. Chat is disabled +// and user controls are never opened, so every PlaceOS WebSocket fails the test. +test.use({ auditSockets: 'block-all' }); + +async function openDirectory( + page: Page, + overrides: Record, + audit: Audit, +) { + 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); + // The only input in the directory search field (user-list.component.ts). + // The placeholder is translated and its wording changed, so it is not used. + const input = root.locator('mat-form-field[overlay] input'); + 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(`users.show_avatars ${enabled} changes directory results without losing identity`, async ({ + staffPage, + audit, + }) => { + const { root, input } = await openDirectory( + staffPage, + { 'app.users.show_avatars': enabled }, + audit, + ); + 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.area-count-key.browser.spec.ts b/apps/workplace/e2e/settings/explore/explore.area-count-key.browser.spec.ts new file mode 100644 index 00000000000..997dbde3112 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.area-count-key.browser.spec.ts @@ -0,0 +1,90 @@ +/** + * Setting: explore.area_count_key + * Exact runtime key: app.explore.area_count_key + * Coverage: 1 unique Setting / 2 scenarios + * Independent contexts use identical AreaManagement data: capacity=10, + * count=2, people_count=8. Only the global count key differs. + * ExploreZonesService.parseData chooses metadata key > global key > 'count'; + * ratios <0.4 are free, <0.75 pending, otherwise busy. There is no metadata + * count-key override, room match or at_location override in this fixture. + * Assert actual polygon pixels; labels do not reflect this setting. + * + * Safety: settings-harness browserOnlyTest. Fresh empty storage, synthetic + * staff and auth (fake bearer), every API/auth GET answered in the browser, + * no request or socket forwarded to a backend. Only exact AreaManagement + * bind/unbind tuples are accepted; commands, mutations, redirects and + * unexpected requests fail the strict teardown audit. + */ +import { expect, type Page } from '@playwright/test'; +import { + browserOnlyTest, + type ControlAudit, + controlAudit, + controlSocket, + exploreValues, + openSyntheticExplore, + pixel, + type Settings, + syntheticExplore, +} from '../support/settings-harness'; + +const FAKE_BEARER = 'browser-only-zone-settings-not-a-valid-credential'; +const EXPLORE = syntheticExplore('e2e-area-count-key-'); +const { LEVEL, TARGET, CONTROL, COLOUR } = EXPLORE; +// Payloads are the values passed to StatusVariable callbacks, not GeoJSON. +const LIVE: Record = { + [LEVEL + ':areas']: { value: [ + { area_id: TARGET, map_id: TARGET, capacity: 10, count: 2, people_count: 8 }, + { area_id: CONTROL, map_id: CONTROL, count: 1 }, + ] }, + [LEVEL]: { value: [] }, + [LEVEL + ':desk_bookings']: {}, + // LandingStateService.init binds AreaManagement overview (level occupancy). + overview: {}, +}; +const BASE: Settings = { + ...EXPLORE.BASE, + 'app.events.has_assets': false, 'app.events.can_book_for_others': false, + 'app.events.bookable_hours': null, 'app.events.booking_unavailable': false, + 'app.explore.colors': { 'zone-free': COLOUR, 'zone-busy': '#654321' }, +}; + +const test = browserOnlyTest({ + audit: controlAudit, + socket: controlSocket({ + bearer: FAKE_BEARER, + values: exploreValues(EXPLORE, LIVE), + key: (r) => r.name, + }), +}); + +async function openExplore(page: Page, audit: ControlAudit, override: Settings) { + return openSyntheticExplore(page, audit, EXPLORE, { + settings: { ...BASE, ...override }, + bearer: FAKE_BEARER, + delivered: Object.keys(LIVE), + }); +} + +for (const scenario of [ + { key: 'count', pixel: [18, 52, 86, 128] }, + { key: 'people_count', pixel: [101, 67, 33, 128] }, +]) { + test('explore.area_count_key=' + scenario.key + ' paints the selected occupancy status', + async ({ isolated: { page, audit } }) => { + await openExplore(page, audit, { 'app.explore.area_count_key': scenario.key }); + // openExplore has already proved control polygon rendering, metadata + // and the latest socket's complete live deliveries. + await expect.poll(async () => { + const actual = await pixel(page, 0.25, 0.25); + // Canvas alpha premultiplication can round RGB channels. + return actual[3] === scenario.pixel[3] && + actual.slice(0, 3).every((channel, index) => + Math.abs(channel - scenario.pixel[index]) <= 2); + }, { + message: 'target polygon uses the count selected by the setting', + }).toBe(true); + await expect(page.locator('explore-map-view [map-canvas] canvas')).toBeVisible(); + }); +} + diff --git a/apps/workplace/e2e/settings/explore/explore.colors.spec.ts b/apps/workplace/e2e/settings/explore/explore.colors.spec.ts new file mode 100644 index 00000000000..0f89322b240 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.colors.spec.ts @@ -0,0 +1,200 @@ +// Setting: explore.colors on the desk booking map (docs/settings/workplace.md). +// Consumer: apps/workplace/src/app/book/desk-flow-new/desk-flow-select-map.component.ts +// styles(): colours['space-' + status] || colours[status]. The Explore map's desk +// layer reads 'desk-' + status instead (libs/explore/src/lib/explore-desks.service.ts) +// and is not covered here. +// Scenarios: space-free alone, free alone, and both keys with space-free winning. +// No desk is selected and the details form is never confirmed. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { APP_URL } from '../../../../../e2e/support/env'; +import { DeskFlow } from '../../../../../e2e/support/desk/desk-flow.page'; +import { + type Audit, + type Debug, + getOnly, + organisationRoutes, + prepareSettings, + type Settings, + test, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC', auditHttp: 'strict' }); + +const REGION = 'e2e-settings-region'; +const BUILDING_A = 'e2e-settings-building-a'; +const BUILDING_B = 'e2e-settings-building-b'; +const LEVEL_A = 'e2e-settings-level-a'; +const LEVEL_B = 'e2e-settings-level-b'; +const MAP = new URL('/e2e-settings-map.svg', APP_URL).href; +const LEVELS = [ + { id: LEVEL_A, parent_id: BUILDING_A, name: 'E2E Level A', display_name: 'E2E Level A', + tags: ['level'], map_id: MAP }, + { id: LEVEL_B, parent_id: BUILDING_B, name: 'E2E Level B', display_name: 'E2E Level B', + tags: ['level'], map_id: MAP }, +]; +const DESK = 'E2E browser-only desk'; + +const DESK_DETAILS = (level: { id: string }) => [{ + id: 'e2e-settings-desk-' + level.id, name: DESK + ' ' + level.id, + map_id: 'e2e-settings-desk-shape', bookable: true, assigned_to: '', + groups: [], features: [], images: [], +}]; + +async function organisationFixture(page: Page, audit: Audit) { + await organisationRoutes(page, { + audit, + prefix: 'e2e-settings-', + region: { id: REGION, name: 'E2E Region' }, + regionParent: true, + buildings: [ + { id: BUILDING_A, parent_id: REGION, name: 'E2E Building A', display_name: 'E2E Building A', + tags: ['building'], timezone: 'UTC', location: '0,0' }, + { id: BUILDING_B, parent_id: REGION, name: 'E2E Building B', display_name: 'E2E Building B', + tags: ['building'], timezone: 'UTC', location: '40,40' }, + ], + levels: LEVELS, + metadata: (name) => (name.endsWith('_booking_rules') || name === 'map_regions' ? [] : {}), + desks: DESK_DETAILS, + deskBookings: true, + }); + await page.route((url) => url.href === MAP, async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + audit.fixtures['synthetic SVG'] = (audit.fixtures['synthetic SVG'] ?? 0) + 1; + await route.fulfill({ contentType: 'image/svg+xml', + body: '' + + '' }); + }); +} +// BookingFormService._computeAvailableResources -> bookedResourceList uses +// GET /bookings/booked with exactly these five parameters (bookings.fn.ts). +// A JSON string[] is the source-supported response; [] means no booked IDs. +async function bookedDeskResourcesFixture(page: Page, audit: Audit) { + const zones = new Set([REGION, BUILDING_A, BUILDING_B, ...LEVELS.map((level) => level.id)]); + const keys = ['type', 'zones', 'period_start', 'period_end', 'limit']; + await getOnly(page, audit, 'empty booked desk resources', (url) => { + const query = url.searchParams; + const start = query.get('period_start') ?? ''; + const end = query.get('period_end') ?? ''; + return url.pathname === '/api/staff/v1/bookings/booked' && + [...query.keys()].length === keys.length && + keys.every((key) => query.getAll(key).length === 1) && + query.get('type') === 'desk' && zones.has(query.get('zones') ?? '') && + query.get('limit') === '200' && /^\d+$/.test(start) && /^\d+$/.test(end) && + Number.isSafeInteger(Number(start)) && Number(start) > 0 && + Number.isSafeInteger(Number(end)) && Number(end) > Number(start); + }, () => []); +} +const DESK_SETTINGS: Settings = { + 'app.features': ['desks'], 'app.default_building': BUILDING_A, + 'app.desk.auto_allocation': null, 'app.desks.auto_allocation': false, + 'app.desks.can_book_for_others': false, 'app.bookings.can_book_for_others': false, + 'app.desks.allow_groups': false, 'app.desks.has_assets': false, + 'app.bookings.has_assets': false, 'app.desks.can_book_lockers': false, + 'app.desks.use_assets': false, 'app.use_region': false, + 'app.basic_user_search': true, 'app.desks.bookable_hours': null, + 'app.maps_people.keys': {}, 'app.maps_people.use_zones': [], + 'app.explore.cisco_maps.enabled': false, + 'app.desks.allow_recurrence': false, 'app.desks.allow_multiple': false, +}; +// Runtime: desk-flow-new/desk-flow-select(-map).component.ts. The +// desk map is reached through the List/Map toggle; no desk is selected and the +// details form is never confirmed. +async function deskForm(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepareSettings(page, { ...DESK_SETTINGS, ...overrides }, { + audit, landingRooms: false, locale: 'en-US', + }); + await organisationFixture(page, audit); + await bookedDeskResourcesFixture(page, audit); + const flow = new DeskFlow(page); + await page.goto('/#/book/desk/form'); + await fixture.ready(); + await expect(flow.title).toBeEditable(); + await expect.poll(() => audit.fixtures['synthetic buildings'] ?? 0).toBeGreaterThan(0); + await expect.poll(() => audit.fixtures['synthetic levels'] ?? 0).toBeGreaterThan(0); + // setMapView() only offers levels holding a loaded bookable desk, so wait + // for the debounced desk load before switching views. + await expect.poll(() => audit.fixtures['synthetic desk metadata'] ?? 0).toBeGreaterThan(0); + const list = page.locator('desk-flow-select-list'); + await expect(list).toBeVisible(); + await expect(list.locator('mat-spinner')).toHaveCount(0); + await expect(list.locator('li[item]').filter({ hasText: DESK + ' ' + LEVEL_A })).toHaveCount(1); + await expect(flow.chosenDesks).toHaveCount(0); + return { flow, fixture }; +} +async function deskMap(page: Page) { + const select = page.locator('desk-flow-select'); + await expect(select).toBeVisible(); + await expect(select.locator('desk-flow-select-map')).toHaveCount(0); + // The view toggle's icon ligature is lowercase `map` (desk-flow-select.component.ts). + await select.locator('button', { has: page.locator('icon', { hasText: /^\s*map\s*$/ }) }).click(); + // The map host has no box of its own (its content is absolutely positioned), + // so presence rather than visibility proves the map view rendered. + const map = select.locator('desk-flow-select-map'); + await expect(map).toHaveCount(1); + await expect(map.locator('interactive-map')).toHaveCount(1); + return map; +} +async function untouchedDesk(page: Page, flow: DeskFlow) { + await expect(page).toHaveURL(/#\/book\/desk\/form$/); + await expect(page.locator('desk-flow-success')).toHaveCount(0); + await expect(flow.chosenDesks).toHaveCount(0); +} + +for (const { name, colors, colour } of [ + { name: 'space-free', colors: { 'space-free': '#123456' }, colour: '#123456' }, + { name: 'free', colors: { free: '#654321' }, colour: '#654321' }, + { name: 'space-free over free', colors: { 'space-free': '#123456', free: '#654321' }, colour: '#123456' }, +]) { + test('explore.colors ' + name + ' paints the free desk ' + colour + ' on the desk booking map', + async ({ staffPage, audit }) => { + const { flow } = await deskForm(staffPage, audit, { + 'app.explore.colors': colors, + }); + const map = await deskMap(staffPage); + await expect.poll(() => audit.fixtures['synthetic SVG'] ?? 0).toBeGreaterThan(0); + await expect.poll(() => audit.fixtures['empty booked desk resources'] ?? 0).toBeGreaterThan(0); + // The renderer is canvas-based, not a DOM SVG. The map component + // builds the per-desk style map from app.explore.colors and passes + // it to interactive-map; read its own styles() for the free fixture desk. + const fill = () => map.evaluate((element) => { + const c = (window as Debug).ng?.getComponent(element) as { + styles(): Record; + } | undefined; + return c?.styles()['#e2e-settings-desk-shape']?.fill; + }); + await expect.poll(fill, { message: 'the fixture desk must be styled on the map' }).toBeTruthy(); + await expect.poll(fill, { message: 'explore.colors ' + name + ' must reach the free desk style' }).toBe(colour); + // Rendered canvas pixels: the SVG holds one grey rectangle, so + // neither custom colour comes from the fixture asset. + const canvas = map.locator('interactive-map dynamic-map canvas'); + await expect(canvas).toHaveCount(1); + await expect(canvas).toBeVisible(); + await expect(map.locator('dynamic-map mat-spinner')).toHaveCount(0); + const colours = ['#123456', '#654321']; + const pixels = () => canvas.evaluate((element, values) => { + const canvas = element as HTMLCanvasElement; + const context = canvas.getContext('2d'); + if (!context || !canvas.width || !canvas.height) return [0, 0]; + const data = context.getImageData(0, 0, canvas.width, canvas.height).data; + return values.map((hex) => { + const rgb = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16)); + let count = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i] === rgb[0] && data[i + 1] === rgb[1] && + data[i + 2] === rgb[2] && data[i + 3] === 255) count++; + } + return count; + }); + }, colours); + const expected = colours.indexOf(colour); + await expect.poll(async () => (await pixels())[expected], { + message: 'the rendered desk must contain the configured opaque colour', + }).toBeGreaterThan(100); + await expect.poll(async () => (await pixels())[1 - expected], { + message: 'the other configured colour must be absent', + }).toBe(0); + expect(audit.blocked_requests, 'zero attempted writes or provider requests').toEqual([]); + await untouchedDesk(staffPage, flow); + }); +} diff --git a/apps/workplace/e2e/settings/explore/explore.disable-layers.spec.ts b/apps/workplace/e2e/settings/explore/explore.disable-layers.spec.ts new file mode 100644 index 00000000000..260ef4c3423 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.disable-layers.spec.ts @@ -0,0 +1,393 @@ +// Coverage: 3 unique Settings. Exact keys: app.explore.disable_features, +// app.explore.disable_actions, app.explore.disable_styles. +// Scenarios: 6. +// 1. baseline (all three empty): desk hover feature, four desk actions, the +// desk fill style and, with Areas on, the zones-canvas feature are shown. +// 2. disable_features ['desks']: only the desk hover feature is dropped. +// 3. disable_features 'desks' (comma string): same result as the array form. +// 4. disable_actions ['desks']: only the four desk actions are dropped. +// 5. disable_styles ['desks']: only the desk fill is dropped; the built-in +// `text: { display: 'none' }` style remains. +// 6. disable_features ['zones']: zones-canvas stays hidden with Areas on. +// Scope: the features/actions/styles the Explore map view receives for one +// synthetic desk. No desk is clicked and nothing is booked. +// Source: libs/explore/src/lib/explore-state.service.ts filters each +// group through _disabledGroups, which merges the live map options +// with app.explore.disable_, accepting an array or a comma string. +// map_features also drops `zones-canvas` while `zones` is disabled; map_styles +// always keeps `text.display = 'none'`. explore-desks.service.ts +// publishes the `desks` feature, action and style groups; explore-zones.service.ts +// registers the `zones-canvas` feature. reset() disables +// `zones` until the Areas toggle (explore-map-view.component.ts) +// turns it on, so every scenario turns Areas on and the zones-canvas result is +// part of every expectation. Each scenario first waits until the raw, unfiltered +// groups hold the desk feature, actions and style, and (after Areas is on) the +// zones-canvas feature, so an absence is never read before the data exists. +// +// Authentication: guardedStaffTest (settings-harness). +// +// Network policy: every /api request must match one exact synthetic GET +// fixture below, except the shared staff identity GET (users/current), which +// the readOnlyTest identity guard observes and is never mocked. Any other /api +// request, any non-GET request to a local non-API path (including /auth/) and +// any external request is recorded and aborted, and fails the scenario. +import type { Page, Route } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly, staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + clearOrganisationCache, + type DebugWindow, + expectSafe, + getOnly, + LIST_METADATA, + ORIGINS, + protectNetwork, + type Settings, + syntheticOrg, + syntheticOrgRoutes, + guardedStaffTest as test, + validPeriod, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const ORG = syntheticOrg('e2e-disable-layers-', { params: 'zones' }); +const { PREFIX, ORGANISATION, BUILDING, LEVEL, MAP_PATH, hasParams } = ORG; +const ZONE_METADATA: Record = { + [BUILDING]: [ + 'parking_booking_rules', + 'desk_booking_rules', + 'room_booking_rules', + ], + [LEVEL]: ['desks', 'map_regions'], + [ORGANISATION]: [ + 'locale_en-AU', + 'locale_en-US', + 'room_alerts', + 'points-of-interest', + ], +}; + +const DESK_SHAPE = PREFIX + 'desk-shape'; +const DESK_TRACK = 'desk:hover:' + DESK_SHAPE; +const DESKS = [ + { + id: PREFIX + 'desk', + name: 'Disable Layers Desk', + map_id: DESK_SHAPE, + bookable: true, + assigned_to: '', + groups: [], + features: [], + images: [], + }, +]; + +// Controls, not covered settings. hide_zones=false shows the Areas toggle; +// desks.enable_maps=true makes the desk actions exist; app.explore.disable is +// empty so it cannot hide a group on its own. +const CONTROLS: Settings = { + 'app.has_assets': false, + 'app.global_search': false, + 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], + 'app.desks.use_assets': false, + 'app.desks.enable_maps': true, + 'app.explore.show_legend': false, + 'app.explore.legend': [], + 'app.explore.disable': [], + 'app.explore.disable_labels': [], + 'app.explore.hide_zones': false, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': true, + 'app.explore.cisco_maps.enabled': false, + 'app.maps_people.keys': {}, + 'app.maps_people.use_zones': [], +}; + +/** Exact synthetic GET fixtures for one desk level on the Explore map. */ +async function syntheticGets(page: Page) { + await syntheticOrgRoutes(page, ORG, { + label: 'Disable Layers', + zone_metadata: ZONE_METADATA, + // The level's `desks` list holds the one desk. + zone_details: (id, name) => + id === LEVEL && name === 'desks' + ? DESKS + : LIST_METADATA.includes(name) + ? [] + : {}, + user_metadata: ['settings', 'contacts'], + level: { map_id: new URL(MAP_PATH, APP_URL).href }, + }); + await getOnly( + page, + (url) => + url.pathname === '/api/engine/v2/systems' && + [ORGANISATION, LEVEL].some((zone_id) => + hasParams(url, { zone_id, limit: '50' }), + ), + () => [], + ); + // LandingStateService loads the active building's rooms even on Explore. + await getOnly( + page, + (url) => + url.pathname === '/api/engine/v2/systems' && + hasParams(url, { + zone_id: BUILDING, + limit: '500', + signage: 'false', + }), + () => [], + ); + // Explore level availability, own-day list, or ParkingService's day list. + await getOnly( + page, + (url) => + url.pathname === '/api/staff/v1/bookings' && + validPeriod(url, { strict: false }) && + ORG.parkingQuery(url, staffEmail(test.info().parallelIndex)), + () => [], + ); + await page.route( + (url) => ORIGINS.has(url.origin) && url.pathname === MAP_PATH, + async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + await route.fulfill({ + contentType: 'image/svg+xml', + body: + '' + + `` + + '', + }); + }, + ); +} + +async function openExplore(page: Page, overrides: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + const audit = await protectNetwork(page); + await page.setViewportSize({ width: 1440, height: 1000 }); + await clearOrganisationCache(page); + await useSettings(page, { + ...READ_ONLY_SETTINGS, + ...CONTROLS, + 'app.features': ['explore'], + 'app.use_region': false, + 'app.use_geolocation': false, + 'app.allow_dark_mode': false, + 'app.default_building': BUILDING, + 'app.explore.disable_features': [], + 'app.explore.disable_actions': [], + 'app.explore.disable_styles': [], + ...overrides, + }); + await syntheticGets(page); + await page.goto('/#/explore'); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect( + page.locator('global-loading [initialisation-error]'), + ).toHaveCount(0); + await expect(page.locator('explore-map-view')).toHaveCount(1); + return audit; +} + +function layerSettings(page: Page) { + return page.evaluate(() => { + const setting = (window as DebugWindow).setting; + return { + features: setting?.('app.explore.disable_features'), + actions: setting?.('app.explore.disable_actions'), + styles: setting?.('app.explore.disable_styles'), + disable: setting?.('app.explore.disable'), + }; + }); +} + +type MapState = { + raw: { feature: boolean; actions: number; style: boolean }; + shown: { + desk_feature: boolean; + desk_actions: number; + desk_style: boolean; + text_display: string; + zones_canvas: boolean; + }; + zones_disabled: boolean; + /** zones-canvas is registered in the unfiltered groups (any group). */ + zones_registered: boolean; +}; + +/** + * Read-only probe of the map view's inputs and of the unfiltered groups held + * by ExploreStateService (explore-state.service.ts). Throws if any signal is + * unavailable. + */ +function mapState(page: Page): Promise { + return page.locator('explore-map-view').evaluate( + (element, desk) => { + type Group = Record; + const view = (window as DebugWindow).ng?.getComponent(element) as + | { + features?: () => { track_id?: string }[]; + actions?: () => { id?: string }[]; + styles?: () => Record; + options?: () => { disable?: string[] }; + _state?: { + _features?: () => Group; + _actions?: () => Group; + _styles?: () => Record; + }; + } + | undefined; + const state = view?._state; + if ( + !view?.features || + !view.actions || + !view.styles || + !view.options || + !state?._features || + !state._actions || + !state._styles + ) { + throw new Error('explore map signals are unavailable'); + } + const raw_features = (state._features().desks ?? []) as { + track_id?: string; + }[]; + const raw_styles = (state._styles().desks ?? {}) as object; + const tracks = view.features().map((item) => item.track_id ?? ''); + const styles = view.styles(); + return { + raw: { + feature: raw_features.some( + (item) => item.track_id === desk.track, + ), + actions: (state._actions().desks ?? []).length, + style: '#' + desk.shape in raw_styles, + }, + shown: { + desk_feature: tracks.includes(desk.track), + desk_actions: view + .actions() + .filter((item) => item.id === desk.shape).length, + desk_style: '#' + desk.shape in styles, + text_display: styles.text?.display ?? '', + zones_canvas: tracks.includes('zones-canvas'), + }, + zones_disabled: !!view.options().disable?.includes('zones'), + zones_registered: Object.values(state._features()) + .flat() + .some((item) => (item as { track_id?: string })?.track_id === 'zones-canvas'), + }; + }, + { track: DESK_TRACK, shape: DESK_SHAPE }, + ); +} + +const areasToggle = (page: Page) => + page + .locator('explore-map-view settings-toggle') + .filter({ hasText: 'Areas' }) + .locator('button') + .first(); + +const BASELINE = { + desk_feature: true, + desk_actions: 4, + desk_style: true, + text_display: 'none', + zones_canvas: true, +}; + +// Each scenario changes one key; the baseline is the positive control for +// every absence below. +const SCENARIOS = [ + { + label: 'baseline (all empty)', + overrides: {}, + shown: BASELINE, + }, + { + label: "disable_features ['desks']", + overrides: { 'app.explore.disable_features': ['desks'] }, + shown: { ...BASELINE, desk_feature: false }, + }, + { + label: "disable_features 'desks' (comma string)", + overrides: { 'app.explore.disable_features': 'desks' }, + shown: { ...BASELINE, desk_feature: false }, + }, + { + label: "disable_actions ['desks']", + overrides: { 'app.explore.disable_actions': ['desks'] }, + shown: { ...BASELINE, desk_actions: 0 }, + }, + { + label: "disable_styles ['desks']", + overrides: { 'app.explore.disable_styles': ['desks'] }, + shown: { ...BASELINE, desk_style: false }, + }, + { + label: "disable_features ['zones']", + overrides: { 'app.explore.disable_features': ['zones'] }, + shown: { ...BASELINE, zones_canvas: false }, + }, +]; + +for (const scenario of SCENARIOS) { + test(`explore.${scenario.label} filters the Explore map groups`, async ({ + staffPage, + }) => { + const audit = await openExplore(staffPage, scenario.overrides); + await expect + .poll(() => layerSettings(staffPage), { + message: 'effective explore layer settings', + }) + .toStrictEqual({ + features: [], + actions: [], + styles: [], + disable: [], + ...Object.fromEntries( + Object.entries(scenario.overrides).map(([key, value]) => [ + key.replace('app.explore.disable_', ''), + value, + ]), + ), + }); + // Raw desk groups first: one feature, four actions and one style. + await expect + .poll(async () => (await mapState(staffPage)).raw, { + message: 'unfiltered desk feature, actions and style', + }) + .toStrictEqual({ feature: true, actions: 4, style: true }); + // Turn Areas on, so `zones` leaves the live disabled options. + const areas = areasToggle(staffPage); + await expect(areas).toHaveCount(1); + await expect(areas).toHaveAttribute('aria-checked', 'false'); + await areas.click(); + await expect(areas).toHaveAttribute('aria-checked', 'true'); + await expect + .poll(async () => (await mapState(staffPage)).zones_disabled, { + message: 'Areas toggle removed zones from the map options', + }) + .toBe(false); + // The zones layer must exist before its presence or absence is read. + await expect + .poll(async () => (await mapState(staffPage)).zones_registered, { + message: 'zones-canvas registered in the unfiltered groups', + }) + .toBe(true); + await expect + .poll(async () => (await mapState(staffPage)).shown, { + message: 'groups the map view receives', + }) + .toStrictEqual(scenario.shown); + await expectSafe(audit); + }); +} diff --git a/apps/workplace/e2e/settings/explore/explore.enable-maps.spec.ts b/apps/workplace/e2e/settings/explore/explore.enable-maps.spec.ts new file mode 100644 index 00000000000..16beac4c738 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.enable-maps.spec.ts @@ -0,0 +1,333 @@ +// Coverage: 2 unique Settings. Exact keys: app.desks.enable_maps, +// app.parking.enable_maps. +// Scenarios: 4. +// 1. desks.enable_maps false: the desk hover feature stays, no desk action. +// 2. desks.enable_maps true: the same desk publishes four click actions. +// 3. parking.enable_maps false: the bay hover card stays, no parking action. +// 4. parking.enable_maps true: the free bay publishes one click action. +// Scope: the features/actions the Explore map view receives. No overlay is +// clicked, no booking form is opened and nothing is booked. +// Docs: docs/settings/workplace.md. +// +// Source: libs/explore/src/lib/explore-desks.service.ts always +// publishes `desk:hover:` features and publishes four actions per +// bookable desk (mousedown, touchstart, mouseup, touchend) only while +// app.desks.enable_maps !== false. explore-parking.service.ts +// always publishes one hover feature per bay and one action per available bay +// only while app.parking.enable_maps !== false. The desk scenarios use a level +// without the `parking` tag and the parking scenarios a level with it and no +// desks, so the two action groups cannot be confused. +// +// Authentication: guardedStaffTest (settings-harness). +// +// Network policy: every /api request must match one exact synthetic GET +// fixture below, except the shared staff identity GET (users/current), which +// the readOnlyTest identity guard observes and is never mocked. Any other /api +// request, any non-GET request to a local non-API path (including /auth/) and +// any external request is recorded and aborted, and fails the scenario. +import type { Page, Route } from '@playwright/test'; +import { useSettings } from '../../../../../e2e/support/desk/desk.settings'; +import { APP_URL, BACKEND_URL, assertLocalOnly, staffEmail } from '../../../../../e2e/support/env'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + clearOrganisationCache, + type DebugWindow, + expectSafe, + getOnly, + LIST_METADATA, + ORIGINS, + protectNetwork, + type Settings, + syntheticOrg, + syntheticOrgRoutes, + guardedStaffTest as test, + validPeriod, +} from '../support/settings-harness'; +type MapView = { + features(): { track_id?: string }[]; + actions(): { id?: string; action?: string }[]; +}; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +type Mode = 'desk' | 'parking'; + +const ORG = syntheticOrg('e2e-enable-maps-', { params: 'zones' }); +const { PREFIX, ORGANISATION, BUILDING, LEVEL, MAP_PATH, hasParams } = ORG; +const SPACES_TYPE = PREFIX + 'type-_PARKING_SPACES_'; +const ZONE_METADATA: Record = { + [BUILDING]: [ + 'parking_booking_rules', + 'desk_booking_rules', + 'room_booking_rules', + ], + [LEVEL]: ['desks', 'map_regions'], + [ORGANISATION]: [ + 'locale_en-AU', + 'locale_en-US', + 'room_alerts', + 'points-of-interest', + ], +}; + +const DESK_SHAPE = PREFIX + 'desk-shape'; +const DESK_TRACK = 'desk:hover:' + DESK_SHAPE; +const DESKS = [ + { + id: PREFIX + 'desk', + name: 'Enable Maps Desk', + map_id: DESK_SHAPE, + bookable: true, + assigned_to: '', + groups: [], + features: [], + images: [], + }, +]; +const BAY = { + id: PREFIX + 'bay', + name: 'Enable Maps Bay', + shape: PREFIX + 'bay-shape', +}; +const SPACES = [ + { + id: BAY.id, + identifier: BAY.name, + name: BAY.name, + zone_id: LEVEL, + map_id: BAY.shape, + bookable: true, + assigned_to: '', + }, +]; + +// Controls, not covered settings. show_status_details is pinned true so the +// bay hover card shows "free" whichever enable_maps value is under test. +const CONTROLS: Settings = { + 'app.has_assets': false, + 'app.global_search': false, + 'app.chat.enabled': false, + 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], + 'app.desks.use_assets': false, + 'app.parking.show_status_details': true, + 'app.explore.show_legend': false, + 'app.explore.legend': [], + 'app.explore.disable': [], + 'app.explore.disable_features': [], + 'app.explore.disable_actions': [], + 'app.explore.disable_styles': [], + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': true, + 'app.explore.cisco_maps.enabled': false, + 'app.maps_people.keys': {}, + 'app.maps_people.use_zones': [], +}; + +async function syntheticGets(page: Page, mode: Mode) { + let desk_list_requests = 0; + await syntheticOrgRoutes(page, ORG, { + label: 'Enable Maps', + zone_metadata: ZONE_METADATA, + // The level's `desks` list holds the one desk in the desk scenarios + // and is empty in the parking scenarios. + zone_details: (id, name) => { + const desks = id === LEVEL && name === 'desks'; + desk_list_requests += Number(desks); + return desks && mode === 'desk' + ? DESKS + : LIST_METADATA.includes(name) + ? [] + : {}; + }, + user_metadata: ['settings', 'contacts'], + level: { + tags: mode === 'parking' ? ['level', 'parking'] : ['level'], + map_id: new URL(MAP_PATH, APP_URL).href, + }, + }); + await getOnly( + page, + (url) => + url.pathname === '/api/engine/v2/systems' && + [ORGANISATION, LEVEL].some((zone_id) => + hasParams(url, { zone_id, limit: '50' }), + ), + () => [], + ); + // LandingStateService loads the active building's rooms even on Explore. + await getOnly( + page, + (url) => + url.pathname === '/api/engine/v2/systems' && + hasParams(url, { + zone_id: BUILDING, + limit: '500', + signage: 'false', + }), + () => [], + ); + // Only the parking scenarios' level returns the bay (parking-assets.fn.ts); + // syntheticOrgRoutes answers every other hidden asset list with []. + if (mode === 'parking') + await getOnly( + page, + (url) => + url.pathname === '/api/engine/v2/assets' && + ORG.hiddenAssetQuery(url) && + hasParams(url, { + zone_id: LEVEL, + type_id: SPACES_TYPE, + limit: '500', + }), + () => SPACES, + ); + // Level and user parking bookings: none, so the bay is available. + await getOnly( + page, + (url) => + url.pathname === '/api/staff/v1/bookings' && + validPeriod(url, { strict: false }) && + ORG.parkingQuery(url, staffEmail(test.info().parallelIndex)), + () => [], + ); + const shape = mode === 'parking' ? BAY.shape : DESK_SHAPE; + await page.route( + (url) => ORIGINS.has(url.origin) && url.pathname === MAP_PATH, + async (route: Route) => { + if (route.request().method() !== 'GET') return route.fallback(); + await route.fulfill({ + contentType: 'image/svg+xml', + body: + '' + + `` + + '', + }); + }, + ); + return () => desk_list_requests; +} + +async function openExplore(page: Page, mode: Mode, overrides: Settings) { + assertLocalOnly(APP_URL, BACKEND_URL); + const audit = await protectNetwork(page); + await page.setViewportSize({ width: 1440, height: 1000 }); + await clearOrganisationCache(page); + await useSettings(page, { + ...READ_ONLY_SETTINGS, + ...CONTROLS, + 'app.features': ['explore'], + 'app.use_region': false, + 'app.use_geolocation': false, + 'app.allow_dark_mode': false, + 'app.default_building': BUILDING, + ...overrides, + }); + const deskLists = await syntheticGets(page, mode); + await page.goto('/#/explore'); + await expect(page.locator('global-loading [loader]')).toHaveCount(0); + await expect( + page.locator('global-loading [initialisation-error]'), + ).toHaveCount(0); + await expect(page.locator('explore-map-view')).toHaveCount(1); + return { audit, deskLists }; +} + +function setting(page: Page, key: string) { + return page.evaluate( + (name) => (window as DebugWindow).setting?.(name), + key, + ); +} + +/** Read-only probe of the map view's inputs. Throws if unavailable. */ +function mapState(page: Page) { + return page.locator('explore-map-view').evaluate((element) => { + const view = (window as DebugWindow).ng?.getComponent(element) as + | MapView + | undefined; + if (!view?.features || !view.actions) { + throw new Error('explore-map-view signals are unavailable'); + } + return { + tracks: view.features().map((feature) => feature.track_id ?? ''), + actions: view.actions().map((action) => action.id ?? ''), + }; + }); +} + +const bayCard = (page: Page) => + page + .locator('explore-map-view div.map-overlay-hover explore-parking-info') + .filter({ hasText: BAY.name }); + +// Each enabled scenario is the positive control for its disabled one: the +// same fixture, the same probe, and the actions do arrive. +// Desk actions are counted: the source fixes four events per bookable desk, +// not the action ids. +const DESK_SCENARIOS = [ + { enabled: false, actions: 0 }, + { enabled: true, actions: 4 }, +]; +const PARKING_SCENARIOS = [ + { enabled: false, actions: [] as string[] }, + { enabled: true, actions: [BAY.shape] }, +]; + +for (const scenario of DESK_SCENARIOS) { + test(`desks.enable_maps ${scenario.enabled} publishes ${scenario.actions} desk map actions`, async ({ + staffPage, + }) => { + const { audit, deskLists } = await openExplore(staffPage, 'desk', { + 'app.desks.enable_maps': scenario.enabled, + }); + await expect + .poll(() => setting(staffPage, 'app.desks.enable_maps'), { + message: 'effective app.desks.enable_maps', + }) + .toBe(scenario.enabled); + await expect + .poll(deskLists, { message: 'desk list read from level metadata' }) + .toBeGreaterThan(0); + // The hover feature is published either way; once it is there the + // action list for the same desk has been computed. + await expect + .poll( + async () => { + const state = await mapState(staffPage); + return { + hover: state.tracks.includes(DESK_TRACK), + actions: state.actions.length, + }; + }, + { message: 'desk hover feature and desk action count' }, + ) + .toEqual({ hover: true, actions: scenario.actions }); + await expectSafe(audit); + }); +} + +for (const scenario of PARKING_SCENARIOS) { + test(`parking.enable_maps ${scenario.enabled} publishes ${scenario.actions.length} parking map actions`, async ({ + staffPage, + }) => { + const { audit } = await openExplore(staffPage, 'parking', { + 'app.parking.enable_maps': scenario.enabled, + }); + await expect + .poll(() => setting(staffPage, 'app.parking.enable_maps'), { + message: 'effective app.parking.enable_maps', + }) + .toBe(scenario.enabled); + await expect(bayCard(staffPage), 'bay hover card').toHaveCount(1); + await expect(bayCard(staffPage)).toContainText('free'); + await expect + .poll(async () => (await mapState(staffPage)).actions, { + message: 'parking map actions', + }) + .toEqual(scenario.actions); + await expectSafe(audit); + }); +} 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..bc180c94713 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.legend.spec.ts @@ -0,0 +1,109 @@ +// Settings: explore.show_legend and explore.legend (docs/settings/workplace.md). +// Consumer: libs/explore/src/lib/explore-map-view.component.ts renders the +// [legend] panel when show_legend is true and legend has pairs; more than three +// pairs collapse behind a toggle. The Explore level has no rooms, so nothing +// binds. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + getOnly, + hiddenAssets, + prepareSettings, + type Settings, + test, +} from '../support/settings-harness'; + +const TYPE_IDS: Record = { + _LOCKER_BANKS_: 'e2e-settings-type-locker-banks', + _LOCKERS_: 'e2e-settings-type-lockers', + _PARKING_SPACES_: 'e2e-settings-type-parking-spaces', + _PARKING_USERS_: 'e2e-settings-type-parking-users', + _PARKING_FLEET_VEHICLES_: 'e2e-settings-type-parking-fleet', + _DESKS_: 'e2e-settings-type-desks', +}; +const HIDDEN = hiddenAssets({ + category: (category) => 'e2e-settings-cat-' + category, + type: (name) => TYPE_IDS[name], + order: ['lockers', 'parking', 'desks', 'catering'], +}); +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, +}; + +async function openExplore(page: Page, audit: Audit, overrides: Settings) { + const fixture = await prepareSettings(page, overrides, { + audit, + base: BASE, + hidden: HIDDEN, + landingRooms: false, + locale: 'en-US', + viewport: false, + bootstrap: true, + // ExploreStateService: querySystems({ zone_id, limit: 50 }) -> no rooms, so no room bindings. + routes: () => + getOnly( + 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 page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto('/#/explore'); + await fixture.ready(); + await expect(page.locator('explore-map-view')).toHaveCount(1); + return page.locator('explore-map-view [legend]'); +} + +const labels = (legend: ReturnType) => + legend.locator('#explore-map-legend-items > div > div:last-child'); + +const TWO: [string, string][] = [['E2E Free', '#123456'], ['E2E Busy', '#654321']]; +const FOUR: [string, string][] = [...TWO, ['E2E Pending', '#112233'], ['E2E Closed', '#445566']]; + +test('explore.show_legend false hides a configured legend', async ({ staffPage, audit }) => { + const legend = await openExplore(staffPage, audit, { 'app.explore.show_legend': false, 'app.explore.legend': TWO }); + await expect(legend).toHaveCount(0); +}); + +test('explore.show_legend true with explore.legend of two pairs renders labels and colours', async ({ staffPage, audit }) => { + const legend = await openExplore(staffPage, audit, { 'app.explore.show_legend': true, 'app.explore.legend': TWO }); + 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(labels(legend)).toHaveText(['E2E Free', 'E2E 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)'); +}); + +test('explore.legend of four pairs collapses behind a toggle', async ({ staffPage, audit }) => { + const legend = await openExplore(staffPage, audit, { 'app.explore.show_legend': true, 'app.explore.legend': FOUR }); + 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(labels(legend)).toHaveText(FOUR.map(([label]) => label)); +}); + +test('explore.legend empty renders no legend even when shown', async ({ staffPage, audit }) => { + const legend = await openExplore(staffPage, audit, { 'app.explore.show_legend': true, 'app.explore.legend': [] }); + await expect(legend).toHaveCount(0); +}); diff --git a/apps/workplace/e2e/settings/explore/explore.map-overlays.spec.ts b/apps/workplace/e2e/settings/explore/explore.map-overlays.spec.ts new file mode 100644 index 00000000000..5391f42f0d2 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.map-overlays.spec.ts @@ -0,0 +1,193 @@ +// Settings: explore.hide_zones, explore.hide_devices, explore.hide_sensors, +// explore.disable (docs/settings/workplace.md). +// Consumer: libs/explore/src/lib/explore-map-view.component.ts +// and ExploreStateService.setOptions. Visibility scenarios do not +// click overlays. A group in explore.disable has no overlay toggle and stays +// disabled in the map's applied options; without it, the Show Devices toggle +// turns devices on. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { type DebugWindow, getOnly, prepareSettings, type Settings, test } from '../support/settings-harness'; + +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, + 'app.explore.show_legend': false, + 'app.explore.legend': [], + 'app.explore.disable': [], + 'app.maps_people.keys': {}, + 'app.maps_people.use_zones': [], + 'app.explore.cisco_maps.enabled': false, +}; + +test.describe.configure({ timeout: 120_000 }); + +test.use({ readOnlyTimezone: 'Etc/UTC', auditHttp: 'strict' }); + +async function openExplore(page: Page, overrides: Settings) { + const fixture = await prepareSettings(page, overrides, { + base: BASE, + hidden: 'landing-readonly', + landingRooms: false, + identity: false, + viewport: false, + // ExploreStateService: querySystems({ zone_id, limit: 50 }) -> no rooms. + routes: () => + getOnly( + page, + (url) => + url.pathname === '/api/engine/v2/systems' && + [...url.searchParams.keys()].sort().join(',') === 'limit,zone_id' && + url.searchParams.get('limit') === '50', + () => [], + ), + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto('/#/explore'); + await fixture.ready({ loading: false }); + await expect(page.locator('explore-map-view')).toHaveCount(1); + return page.locator('explore-map-view'); +} + +const overlay = (page: Page, label: string) => + page + .locator('explore-map-view settings-toggle') + .filter({ hasText: label }); + +/** + * Whether the Explore map's applied options disable `devices` + * (ExploreStateService options, which merge app.explore.disable on reset and + * on every option change). Throws if the signal is unavailable. + */ +function devicesDisabled(page: Page) { + return page.locator('explore-map-view').evaluate((element) => { + const view = (window as DebugWindow).ng?.getComponent(element) as { options?: () => { disable?: string[] } } | undefined; + if (!view?.options) throw new Error('explore map options are unavailable'); + return !!view.options().disable?.includes('devices'); + }); +} + +test( + 'explore.hide_zones false shows the Areas overlay toggle', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': false, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': true, + }); + await expect(overlay(staffPage, 'Areas')).toHaveCount(1); + await expect(overlay(staffPage, 'Areas')).toBeVisible(); + }, +); + +test( + 'explore.hide_zones true removes the Areas overlay toggle', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': true, + }); + await expect(overlay(staffPage, 'Areas')).toHaveCount(0); + }, +); + +test( + 'explore.hide_devices false shows the Show Devices overlay toggle', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': false, + 'app.explore.hide_sensors': true, + }); + await expect(overlay(staffPage, 'Show Devices')).toHaveCount(1); + await expect(overlay(staffPage, 'Show Devices')).toBeVisible(); + }, +); + +test( + 'explore.hide_devices true removes the Show Devices overlay toggle', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': true, + }); + await expect(overlay(staffPage, 'Show Devices')).toHaveCount(0); + }, +); + +test( + 'explore.hide_sensors false shows the Show Sensors overlay toggle', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': false, + }); + await expect(overlay(staffPage, 'Show Sensors')).toHaveCount(1); + await expect(overlay(staffPage, 'Show Sensors')).toBeVisible(); + }, +); + +test( + 'explore.hide_sensors true removes the Show Sensors overlay toggle', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': true, + }); + await expect(overlay(staffPage, 'Show Sensors')).toHaveCount(0); + }, +); + +test( + 'explore.disable devices hides the Show Devices toggle and keeps devices disabled', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': false, + 'app.explore.hide_sensors': false, + 'app.explore.disable': ['devices'], + }); + // Show Sensors renders in the same overlay list, so the list is on screen. + await expect(overlay(staffPage, 'Show Sensors')).toBeVisible(); + await expect(overlay(staffPage, 'Show Devices')).toHaveCount(0); + await expect + .poll(() => devicesDisabled(staffPage), { message: 'devices disabled by explore.disable' }) + .toBe(true); + }, +); + +test( + 'explore.disable without devices allows the Show Devices toggle to turn on', + async ({ staffPage }) => { + await openExplore(staffPage, { + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': false, + 'app.explore.hide_sensors': true, + 'app.explore.disable': [], + }); + const toggle = overlay(staffPage, 'Show Devices').locator('button').first(); + await expect(toggle).toHaveCount(1); + // Devices start disabled (ExploreStateService.reset); the same click + // enables them here, which proves the click is handled. + await expect + .poll(() => devicesDisabled(staffPage), { message: 'devices disabled before the click' }) + .toBe(true); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + await expect + .poll(() => devicesDisabled(staffPage), { message: 'devices enabled after the click' }) + .toBe(false); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + }, +); diff --git a/apps/workplace/e2e/settings/explore/explore.show-booking-qr.browser.spec.ts b/apps/workplace/e2e/settings/explore/explore.show-booking-qr.browser.spec.ts new file mode 100644 index 00000000000..ae9988563be --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.show-booking-qr.browser.spec.ts @@ -0,0 +1,145 @@ +/** + * Setting: explore.show_booking_qr + * Exact runtime key: app.explore.show_booking_qr + * Coverage: 1 unique Setting / 2 scenarios + * ExploreSpacesService.bookSpace chooses ExploreBookQrComponent or + * ExploreBookingModalComponent after verifying the live room is free. + * Both paths call EventFormService.newForm, which starts room/rule/calendar + * and availability GETs. Native availability is pinned and fulfilled as []. + * A short real map click opens the selected dialog; Save is never clicked. + * True: loaded local QR image and selected room. False: editable title and + * selected room in the booking dialog. Both are dismissed using Escape. + * + * app.booking_qr_path is a held local control, not covered: the component + * concatenates origin + path and substitutes the room EMAIL for {{id}}. + * This does not verify the default path, scan a QR, or follow its destination. + * Escape avoids relying on the QR component's missing dialog-close import. + * + * Source: libs/explore/src/lib/explore-spaces.service.ts; + * libs/explore/src/lib/explore-book-qr.component.ts; + * libs/explore/src/lib/explore-booking-modal.component.ts:ngOnInit/save; + * libs/events/src/lib/event-form.service.ts; + * libs/bookings/src/lib/bookings.fn.ts. + * + * Safety: settings-harness browserOnlyTest. Fresh empty storage, synthetic + * staff and auth (fake bearer), every API/auth GET answered in the browser, + * no request or socket forwarded to a backend. Only exact AreaManagement/Bookings + * bind/unbind tuples are accepted; commands, mutations, redirects and + * unexpected requests fail the strict teardown audit. + */ +import { expect, type Page } from '@playwright/test'; +import { + browserGet, + browserOnlyTest, + type ControlAudit, + controlAudit, + controlSocket, + type DebugWindow, + exploreBindings, + exploreRoomReady, + exploreRoomValues, + exploreValues, + openSyntheticExplore, + roomState, + type Settings, + syntheticExplore, + validPeriod, +} from '../support/settings-harness'; + +const FAKE_BEARER = 'browser-only-zone-settings-not-a-valid-credential'; +const EXPLORE = syntheticExplore('e2e-show-booking-qr-'); +const { COLOUR, ROOM, ROOM_FILL, hasParams } = EXPLORE; +const LIVE = EXPLORE.LIVE; +const BASE: Settings = { + ...EXPLORE.BASE, + 'app.events.use_bookings': true, + 'app.booking_qr_path': '/#/explore?space={{id}}', + 'app.events.allow_all_day': false, + 'app.events.has_assets': false, 'app.events.can_book_for_others': false, + 'app.events.bookable_hours': null, 'app.events.booking_unavailable': false, + 'app.explore.colors': { 'zone-free': COLOUR, + 'space-free': ROOM_FILL, 'space-busy': ROOM_FILL }, +}; + +type Audit = ControlAudit & { room_values: Record }; +const test = browserOnlyTest({ + audit: () => ({ ...controlAudit(), room_values: exploreRoomValues() }), + socket: controlSocket({ + bearer: FAKE_BEARER, + values: exploreValues(EXPLORE, LIVE), + }), +}); + +async function openExplore(page: Page, audit: Audit, override: Settings) { + return openSyntheticExplore(page, audit, EXPLORE, { + settings: { ...BASE, ...override }, + bearer: FAKE_BEARER, + rooms: [ROOM], + // EventFormService.newForm() starts its native availability loader even + // when the selected dialog is the QR variant. No Save is ever clicked. + gets: () => browserGet(page, (url) => { + const period_start = url.searchParams.get('period_start') ?? ''; + const period_end = url.searchParams.get('period_end') ?? ''; + return url.pathname === '/api/staff/v1/bookings' && validPeriod(url) && + hasParams(url, { type: 'room', period_start, period_end }); + }, () => { + audit.fixtures['native room availability'] = + (audit.fixtures['native room availability'] ?? 0) + 1; + return []; + }), + delivered: exploreBindings(EXPLORE, LIVE, audit.room_values), + }); +} + +for (const enabled of [true, false]) { + test('explore.show_booking_qr=' + enabled + ' opens only the selected room dialog', + async ({ isolated: { page, audit } }) => { + await openExplore(page, audit, { 'app.explore.show_booking_qr': enabled }); + const feature = await exploreRoomReady(page, audit.room_values, EXPLORE); + await expect.poll(async () => (await roomState(page)).events).toHaveLength(0); + // The renderer must have installed this exact room's actions before + // clicking it; a visible room alone does not prove action readiness. + await expect.poll(() => page.locator('explore-map-view').evaluate((element, id) => { + const view = (window as DebugWindow).ng?.getComponent(element) as { + actions(): { id: string; action: string }[]; + } | undefined; + if (!view) throw new Error('Read-only map actions unavailable'); + return view.actions().filter((action) => action.id === id) + .map((action) => action.action).sort(); + }, ROOM.map_id)).toEqual(['mousedown', 'mouseup', 'touchend', 'touchstart']); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + // Down/up is shorter than the source's 300 ms panning threshold. + await feature.click({ position: { x: 10, y: 10 }, delay: 30 }); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toHaveCount(1); + await expect(dialog).toBeVisible(); + const qr_dialog = dialog.locator('explore-book-qr'); + const booking_dialog = dialog.locator('explore-booking-modal'); + if (enabled) { + await expect(qr_dialog).toBeVisible(); + await expect(qr_dialog.locator('h2')).toContainText(ROOM.name); + const image = qr_dialog.locator('main img'); + await expect(image).toHaveCount(1); + await expect(image).toBeVisible(); + await expect(image).toHaveAttribute('src', /^data:image\/svg\+xml,/); + await expect.poll(() => image.evaluate((element) => { + const image = element as HTMLImageElement; + return image.complete && image.naturalWidth > 0 && image.naturalHeight > 0; + })).toBe(true); + await expect(booking_dialog).toHaveCount(0); + } else { + await expect(booking_dialog).toBeVisible(); + await expect(booking_dialog.locator('[load]')).toHaveCount(0); + await expect(booking_dialog.locator('input#title')).toBeEditable(); + await expect(booking_dialog.locator('[name="space"]')).toHaveText(ROOM.name); + await expect(qr_dialog).toHaveCount(0); + } + await expect.poll(() => audit.fixtures['native room availability'] ?? 0) + .toBeGreaterThan(0); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(page).toHaveURL(/#\/explore$/); + await expect(page.locator('explore-map-view')).toBeVisible(); + }); +} + diff --git a/apps/workplace/e2e/settings/explore/explore.show-event-details.browser.spec.ts b/apps/workplace/e2e/settings/explore/explore.show-event-details.browser.spec.ts new file mode 100644 index 00000000000..9def9d49356 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.show-event-details.browser.spec.ts @@ -0,0 +1,115 @@ +/** + * Setting: explore.show_event_details + * Exact runtime key: app.explore.show_event_details + * Coverage: 1 unique Setting / 2 scenarios + * ExploreSpaceInfoComponent requires next() and explicit false privacy values + * before details can appear. Bookings are a direct event array; date is epoch + * milliseconds and duration is minutes (CalendarEvent constructor). + * Each context creates a current synthetic event. Both privacy values stay + * false in both scenarios; only this Setting changes. Hover only, never click. + * True displays title/host; false preserves room/status/event timing without + * title or host. The exact synthetic host GET is intercepted locally. + * Sources: libs/explore/src/lib/explore-space-info.component.ts; + * libs/common/src/lib/types/event.class.ts; libs/users/src/lib/staff.fn.ts. + * + * Safety: settings-harness browserOnlyTest. Fresh empty storage, synthetic + * staff and auth (fake bearer), every API/auth GET answered in the browser, + * no request or socket forwarded to a backend. Only exact AreaManagement/Bookings + * bind/unbind tuples are accepted; commands, mutations, redirects and + * unexpected requests fail the strict teardown audit. + */ +import { expect, type Page } from '@playwright/test'; +import { + browserGet, + browserOnlyTest, + type ControlAudit, + controlAudit, + controlSocket, + exploreBindings, + exploreRoomReady, + exploreValues, + openSyntheticExplore, + roomState, + type Settings, + syntheticExplore, +} from '../support/settings-harness'; + +const FAKE_BEARER = 'browser-only-zone-settings-not-a-valid-credential'; +const EXPLORE = syntheticExplore('e2e-show-event-details-'); +const { COLOUR, ROOM, ROOM_FILL, PREFIX, hasParams } = EXPLORE; +const LIVE = EXPLORE.LIVE; +/** Exact Bookings values; regenerated once per isolated browser context. */ +function roomValues(): Record { + return { bookings: [{ + id: PREFIX + 'event', title: 'Synthetic meeting', + host: 'meeting-host@example.invalid', date: Date.now(), duration: 30, + }], status: 'busy', presence: false, + hide_meeting_details: false, hide_meeting_title: false }; +} +const BASE: Settings = { + ...EXPLORE.BASE, + 'app.events.has_assets': false, 'app.events.can_book_for_others': false, + 'app.events.bookable_hours': null, 'app.events.booking_unavailable': false, + 'app.explore.colors': { 'zone-free': COLOUR, + 'space-free': ROOM_FILL, 'space-busy': ROOM_FILL }, +}; + +type Audit = ControlAudit & { room_values: Record }; +const test = browserOnlyTest({ + audit: () => ({ ...controlAudit(), room_values: roomValues() }), + socket: controlSocket({ + bearer: FAKE_BEARER, + values: exploreValues(EXPLORE, LIVE), + }), +}); + +async function openExplore(page: Page, audit: Audit, override: Settings) { + return openSyntheticExplore(page, audit, EXPLORE, { + settings: { ...BASE, ...override }, + bearer: FAKE_BEARER, + rooms: [ROOM], + // UserPipe -> showStaff uses this exact email-encoded staff endpoint. + gets: () => browserGet(page, (url) => + url.pathname === '/api/staff/v1/people/meeting-host%40example.invalid' && + hasParams(url, {}), + () => ({ id: PREFIX + 'meeting-host', name: 'Synthetic Meeting Host', + email: 'meeting-host@example.invalid' })), + delivered: exploreBindings(EXPLORE, LIVE, audit.room_values), + }); +} + +for (const enabled of [true, false]) { + test('explore.show_event_details=' + enabled + ' controls details in the live room tooltip', + async ({ isolated: { page, audit } }) => { + await openExplore(page, audit, { 'app.explore.show_event_details': enabled }); + const feature = await exploreRoomReady(page, audit.room_values, EXPLORE); + // Prove the same current event and both privacy permissions before + // hovering, so absence cannot pass because Bookings never arrived. + await expect.poll(async () => (await roomState(page)).events).toHaveLength(1); + await expect.poll(async () => (await roomState(page)).next).toMatchObject({ + title: 'Synthetic meeting', host: 'meeting-host@example.invalid', + }); + await feature.hover({ position: { x: 10, y: 10 } }); + const tooltip = page.locator('[custom-tooltip] [name="space-info"]'); + await expect(tooltip).toHaveCount(1); + await expect(tooltip).toBeVisible(); + await expect(tooltip.locator('h4')).toHaveText(ROOM.name); + await expect(tooltip.locator('[status]')).toHaveClass(/busy/); + await expect(tooltip).toContainText(/Free\s+at/); + const title = tooltip.locator('h3').filter({ hasText: 'Synthetic meeting' }); + const host = tooltip.getByText('Synthetic Meeting Host', { exact: true }); + await expect(title).toHaveCount(enabled ? 1 : 0); + await expect(host).toHaveCount(enabled ? 1 : 0); + if (enabled) { + await expect(title).toBeVisible(); + await expect(host).toBeVisible(); + await expect.poll(() => + audit.fixtures['/api/staff/v1/people/meeting-host%40example.invalid'] ?? 0) + .toBeGreaterThan(0); + } else { + await expect(tooltip).not.toContainText('meeting-host@example.invalid'); + } + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + }); +} + diff --git a/apps/workplace/e2e/settings/explore/explore.show-presence-indicators.browser.spec.ts b/apps/workplace/e2e/settings/explore/explore.show-presence-indicators.browser.spec.ts new file mode 100644 index 00000000000..01f83202435 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.show-presence-indicators.browser.spec.ts @@ -0,0 +1,317 @@ +// Coverage: 1 unique Setting. Exact key: app.show_presence_indicators. +// Scenarios: 2. +// 1. true: with the room's live `presence` value true, the Explore map shows +// a `sensor_occupied` presence icon on the room, in the success colour. +// 2. false: with the identical live values, no presence icon is shown. +// Scope: the Explore map for one synthetic room. Nothing is clicked or booked. +// Docs: docs/settings/workplace.md. +// Readiness waits for a busy fill: a free fill is the unset-status fallback +// and shows before any live value arrives. +// +// Source: libs/explore/src/lib/explore-spaces.service.ts subscribes to +// the `bookings`, `status` and `presence` variables of module `Bookings` on +// every room of the level. A `presence` value schedules +// _updateIcons, which returns at once while +// app.show_presence_indicators is off and otherwise publishes one +// ExploreIconComponent (`sensor_occupied`, background var(--success) when +// presence is true) per room in the `spaces-presence` feature group +// (libs/explore/src/lib/explore-icon.component.ts). A `status` value +// schedules the room fill style the same way. The room +// hover card also subscribes to `hide_meeting_details` and +// `hide_meeting_title` (explore-space-info.component.ts). +// +// Safety: settings-harness browserOnlyTest (fresh empty context, synthetic +// fake-bearer sign-in, every request answered in the browser, strict teardown +// audit) and controlSocket (the app's control WebSocket is answered in the +// browser and never reaches a backend). Only bind/unbind of the five +// variables above, on the synthetic room's `Bookings` module index 1, with a +// positive request id, are answered; anything else is refused and fails the +// scenario. On every connection the `status` value is held until the +// `presence` value has been sent, so the room fill appearing proves the +// presence update has already run; absence is never inferred from waiting. +import { expect, type Page, type WebSocketRoute } from '@playwright/test'; +import { + browserOnlyTest, + browserSignIn, + type ControlAudit, + controlAudit, + CONTROL_PATH, + controlSocket, + type DebugWindow, + fakeBearer, + type Settings, + syntheticExplore, + syntheticOrgGets, +} from '../support/settings-harness'; + +const FAKE_BEARER = fakeBearer('presence'); +const USER = { id: 'e2e-presence-user', name: 'Synthetic Presence Staff', + email: 'presence-staff@example.invalid', groups: [], sys_admin: false, support: false }; +const READ_ONLY_SETTINGS: Settings = { + 'app.allow_dark_mode': false, + 'app.desks.can_book_lockers': false, + 'app.desks.has_assets': false, + 'app.bookings.has_assets': false, + 'app.desks.use_assets': false, +}; + +const ORG = syntheticExplore('e2e-presence-'); +const { PREFIX, ORGANISATION, REGION, BUILDING, LEVEL } = ORG; + +/** The one synthetic room on the level. */ +const ROOM = { + id: PREFIX + 'room', + name: 'Presence Room', + display_name: 'Presence Room', + email: 'presence-room@example.invalid', + map_id: PREFIX + 'room-shape', + bookable: true, + capacity: 4, + features: [], + images: [], + zones: [ORGANISATION, REGION, BUILDING, LEVEL], +}; +/** Module and index the room's live variables are bound on. */ +const ROOM_MODULE = 'Bookings'; +const ROOM_MODULE_INDEX = 1; +/** + * The only live variables answered, and their fixed values. Identical in + * both scenarios: presence true, status busy, no bookings, meeting details + * hidden (the card's default). + */ +const LIVE_VALUES: Record = { + bookings: [], + status: 'busy', + presence: true, + hide_meeting_details: true, + hide_meeting_title: true, +}; +/** + * The room fill DEFAULT_COLOURS uses for `busy` (explore-spaces.service.ts). + * Not `free`: handleStatusChange falls back to 'free' for an unset status, so a + * free fill appears before any live value arrives and proves nothing. + */ +const BUSY_FILL = '#e53935'; + +// Controls, not covered settings: everything else on the Explore map is held +// off so only the room's live values vary the map. +const CONTROLS: Settings = { + 'app.has_assets': false, + 'app.global_search': false, + 'app.chat.enabled': false, + 'app.analytics.enabled': false, + 'app.auto_release': { resources: [] }, + 'app.schedule.event_sources': ['api'], + 'app.desks.use_assets': false, + 'app.explore.colors': {}, + 'app.explore.show_legend': false, + 'app.explore.legend': [], + 'app.explore.disable': [], + 'app.explore.disable_features': [], + 'app.explore.disable_actions': [], + 'app.explore.disable_styles': [], + 'app.explore.hide_zones': true, + 'app.explore.hide_devices': true, + 'app.explore.hide_sensors': true, + 'app.explore.cisco_maps.enabled': false, + 'app.maps_people.keys': {}, + 'app.maps_people.use_zones': [], +}; + +/** The audit, plus the live variables sent on each control connection, in send order. */ +type PresenceAudit = ControlAudit & { sends: string[][] }; + +/** + * One controlSocket per WebSocket, so each control connection keeps its own + * send order and its own held `status` value, including after a reconnect. + * Only the synthetic room's `Bookings` variables with a positive request id + * are answered. + */ +function presenceSocket( + socket: WebSocketRoute, + audit: PresenceAudit, + pending: Set>, +) { + const sends: string[] = []; + let held_status: (() => void) | undefined; + if (new URL(socket.url()).pathname === CONTROL_PATH) audit.sends.push(sends); + controlSocket({ + bearer: FAKE_BEARER, + key: (request) => request.name, + values: (request) => + typeof request.id === 'number' && + request.id > 0 && + request.sys === ROOM.id && + request.mod === ROOM_MODULE && + request.index === ROOM_MODULE_INDEX + ? LIVE_VALUES + : undefined, + deliver: (request, value, notify) => { + const send = () => { + notify(value); + sends.push(request.name); + }; + if (request.name === 'status' && !sends.includes('presence')) { + held_status = send; + return; + } + send(); + if (request.name === 'presence' && held_status) { + const release = held_status; + held_status = undefined; + release(); + } + }, + })(socket, audit, pending); +} + +const test = browserOnlyTest({ + audit: () => ({ ...controlAudit(), sends: [] }), + socket: presenceSocket, + fetchAssets: true, +}); + +async function openExplore(page: Page, audit: PresenceAudit, enabled: boolean) { + await browserSignIn(page, ORG, { + bearer: FAKE_BEARER, + user: USER, + authority: 'Synthetic Presence Authority', + settings: { + ...READ_ONLY_SETTINGS, + ...CONTROLS, + 'app.features': ['explore'], + 'app.use_region': false, + 'app.use_geolocation': false, + 'app.allow_dark_mode': false, + 'app.default_building': BUILDING, + 'app.show_presence_indicators': enabled, + }, + }); + // Bulk metadata, including `bindings`, is empty: no area, location or + // locker module is bound, so only the room variables reach the socket. + await syntheticOrgGets(page, ORG, { + user: USER, + zone_metadata: ORG.ZONE_METADATA, + rooms: [ROOM], + label: 'Presence', + user_metadata: { description: '' }, + svg: + '' + + `` + + '', + }); + await page.goto('/#/explore'); + 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(page.locator('explore-map-view')).toHaveCount(1); + await expect.poll(() => audit.fixtures['/auth/authority'] ?? 0).toBeGreaterThan(0); + await expect.poll(() => audit.fixtures['/api/engine/v2/users/current'] ?? 0).toBeGreaterThan(0); +} + +function presenceSetting(page: Page) { + return page.evaluate(() => + (window as DebugWindow).setting?.('app.show_presence_indicators'), + ); +} + +/** + * Read-only probe of the map view's inputs (explore-map-view.component.ts): + * the room's fill and the presence icon features. Fails closed if the signals + * are unavailable. + */ +function mapState(page: Page) { + return page + .locator('explore-map-view') + .evaluate((element, map_id) => { + const view = (window as DebugWindow).ng?.getComponent(element) as + | { + features?: () => { + location?: unknown; + data?: { icon?: { content?: string }; color?: string }; + }[]; + styles?: () => Record; + } + | undefined; + if (!view?.features || !view.styles) { + throw new Error('explore map signals are unavailable'); + } + return { + room_fill: view.styles()['#' + map_id]?.fill ?? '', + presence_icons: view + .features() + .filter( + (feature) => + feature.location === map_id && + feature.data?.icon?.content === 'sensor_occupied', + ) + .map((feature) => feature.data?.color ?? ''), + }; + }, ROOM.map_id); +} + +/** Presence icons rendered on the map (explore-icon.component.ts). */ +const presenceIcons = (page: Page) => + page.locator('explore-map-view explore-icon').filter({ + has: page.locator('icon', { hasText: /^\s*sensor_occupied\s*$/ }), + }); + +// Identical live values in both scenarios; only the setting differs. Kept as +// data so the test body has no branches. +const SCENARIOS = [ + { enabled: true, icons: ['var(--success)'], rendered: 1 }, + { enabled: false, icons: [] as string[], rendered: 0 }, +]; + +for (const scenario of SCENARIOS) { + test(`show_presence_indicators ${scenario.enabled} sets the room presence icon on the Explore map`, async ({ + isolated: { page, audit }, + }) => { + await openExplore(page, audit, scenario.enabled); + await expect + .poll(() => presenceSetting(page), { + message: 'effective app.show_presence_indicators', + }) + .toBe(scenario.enabled); + // The synthetic socket sent presence, then status. + await expect + .poll(() => audit.sends.at(-1)?.slice() ?? [], { + message: 'live values sent by the synthetic socket', + }) + .toEqual( + expect.arrayContaining(['bookings', 'presence', 'status']), + ); + for (const sends of audit.sends.filter((sent) => sent.includes('status'))) { + expect( + sends.indexOf('presence'), + 'presence is sent before status on every connection', + ).toBeGreaterThanOrEqual(0); + expect( + sends.indexOf('presence'), + 'presence is sent before status on every connection', + ).toBeLessThan(sends.indexOf('status')); + } + // The room fill comes from the later status value, so once it is + // shown the presence update has already run. + await expect + .poll(async () => (await mapState(page)).room_fill, { + message: 'room fill from the live status', + }) + .toBe(BUSY_FILL); + expect((await mapState(page)).presence_icons).toStrictEqual( + scenario.icons, + ); + await expect(presenceIcons(page)).toHaveCount(scenario.rendered); + expect(audit.refused, 'no refused request, socket frame or extra page').toEqual([]); + expect(audit.errors, 'no application or HTTP errors').toEqual([]); + expect(audit.monitoring, 'no interception failure').toEqual([]); + expect( + audit.connections + .flatMap((connection) => connection.binds) + .filter((name) => !Object.hasOwn(LIVE_VALUES, name)), + 'only room live variables are bound', + ).toEqual([]); + }); +} diff --git a/apps/workplace/e2e/settings/explore/explore.show-zone-sensor-info.browser.spec.ts b/apps/workplace/e2e/settings/explore/explore.show-zone-sensor-info.browser.spec.ts new file mode 100644 index 00000000000..5e4f2a6aba6 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.show-zone-sensor-info.browser.spec.ts @@ -0,0 +1,58 @@ +/** + * Setting: explore.show_zone_sensor_info + * Exact runtime key: app.explore.show_zone_sensor_info + * Coverage: 1 unique Setting / 2 scenarios + * ExploreZonesService.parseData creates a sensors feature only for a room- + * matched metadata area with coordinates and temperature/humidity. The target + * uses the real synthetic room ID/map ID; the separate control area is not a room. + * Each scenario enables Sensors through the UI with identical readings. + * True shows 23 C / 45% in the actual tooltip; false omits the sensor button. + * Source: libs/explore/src/lib/explore-zones.service.ts and + * libs/explore/src/lib/explore-sensor-info.component.ts. + * No device data is supplied and LocationServices commands remain forbidden. + * + * Safety: settings-harness zoneSensorTest (browser-only). Fresh empty storage, + * synthetic staff and auth, every API/auth GET answered locally, no control + * socket forwarded. Only exact AreaManagement/Bookings bind/unbind tuples are + * accepted; commands, mutations, redirects and unexpected requests fail the + * final audit. Read-only Angular probes establish readiness. + */ +import { expect } from '@playwright/test'; +import { + type DebugWindow, + openZoneSensorExplore, + ZONE_SENSOR_TARGET, + zoneSensorTest as test, +} from '../support/settings-harness'; + +for (const enabled of [true, false]) { + test('explore.show_zone_sensor_info=' + enabled + ' controls the room sensor tooltip', + async ({ zones: { page, audit } }) => { + // Waits for the room, then turns Sensors on through the UI. + await openZoneSensorExplore(page, audit, { 'app.explore.show_zone_sensor_info': enabled }); + // Metadata/live processing, room matching and the enabled overlay + // are proven before a negative assertion about its sensor feature. + await expect.poll(() => page.locator('explore-map-view').evaluate((element, id) => { + const view = (window as DebugWindow).ng?.getComponent(element) as { + features(): { track_id?: string }[]; + } | undefined; + if (!view) throw new Error('Read-only map features unavailable'); + return view.features().some((feature) => feature.track_id === 'sensors:' + id); + }, ZONE_SENSOR_TARGET)).toBe(enabled); + const sensor_button = page.locator('explore-map-view explore-sensor-info button'); + await expect(sensor_button).toHaveCount(enabled ? 1 : 0); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + if (enabled) { + await expect(sensor_button).toBeVisible(); + await sensor_button.click(); + const tooltip = page.locator('.cdk-overlay-container [custom-tooltip] > div'); + await expect(tooltip).toHaveCount(1); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText('23˚C'); + await expect(tooltip).toContainText('45%'); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + await page.keyboard.press('Escape'); + } + }); +} + diff --git a/apps/workplace/e2e/settings/explore/explore.use-imperial-units.browser.spec.ts b/apps/workplace/e2e/settings/explore/explore.use-imperial-units.browser.spec.ts new file mode 100644 index 00000000000..467175eeb50 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.use-imperial-units.browser.spec.ts @@ -0,0 +1,61 @@ +// Setting: use_imperial_units (docs/settings/workplace.md). +// Coverage: 1 unique Setting / 2 scenarios. +// +// Source: libs/explore/src/lib/explore-zones.service.ts parseData sets +// temp_unit = use_imperial_units ? 'F' : 'C' and passes it, with the reading +// unchanged, to the sensor feature; explore-sensor-info.component.ts shows +// '˚'. The setting only changes the unit label; there is no +// conversion, and the backend may send either scale. +// +// Both scenarios use the same synthetic room reading (23, 45%) from the shared +// zone-sensor harness (settings-harness zoneSensorTest; fully synthetic org, +// user, map and AreaManagement binding; no writes; strict teardown), with +// explore.show_zone_sensor_info on: +// 1. false: the sensor tooltip shows 23˚C. +// 2. true: the same reading shows 23˚F (not converted, e.g. not 73.4). +// The tooltip's warning icon (shown above 82 in either unit) is not asserted. +// Runs in the settings-browser project (no backend). +import { expect } from '@playwright/test'; +import { + type DebugWindow, + openZoneSensorExplore, + ZONE_SENSOR_READING, + ZONE_SENSOR_TARGET, + zoneSensorTest as test, +} from '../support/settings-harness'; + +const SCENARIOS = [ + { imperial: false, shown: 'C', other: 'F' }, + { imperial: true, shown: 'F', other: 'C' }, +]; + +for (const scenario of SCENARIOS) { + test('use_imperial_units ' + scenario.imperial + ' labels the zone temperature ˚' + scenario.shown, + async ({ zones: { page, audit } }) => { + await openZoneSensorExplore(page, audit, { + 'app.use_imperial_units': scenario.imperial, + 'app.explore.show_zone_sensor_info': true, + }); + // The room's sensor feature carries the reading and the unit. + await expect.poll(() => page.locator('explore-map-view').evaluate((element, id) => { + const view = (window as DebugWindow).ng?.getComponent(element) as { + features(): { track_id?: string; data?: { temp?: number; temp_unit?: string } }[]; + } | undefined; + if (!view) throw new Error('Read-only map features unavailable'); + const feature = view.features().find((item) => item.track_id === 'sensors:' + id); + return feature ? { temp: feature.data?.temp, unit: feature.data?.temp_unit } : null; + }, ZONE_SENSOR_TARGET)).toEqual({ temp: ZONE_SENSOR_READING.temperature, unit: scenario.shown }); + const button = page.locator('explore-map-view explore-sensor-info button'); + await expect(button).toHaveCount(1); + await expect(button).toBeVisible(); + await button.click(); + const tooltip = page.locator('.cdk-overlay-container [custom-tooltip] > div'); + await expect(tooltip).toHaveCount(1); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText(ZONE_SENSOR_READING.temperature + '˚' + scenario.shown); + await expect(tooltip).not.toContainText('˚' + scenario.other); + await expect(tooltip).toContainText(ZONE_SENSOR_READING.humidity + '%'); + await expect(page.locator('mat-dialog-container')).toHaveCount(0); + await page.keyboard.press('Escape'); + }); +} diff --git a/apps/workplace/e2e/settings/explore/explore.zone-polygons-and-labels.browser.spec.ts b/apps/workplace/e2e/settings/explore/explore.zone-polygons-and-labels.browser.spec.ts new file mode 100644 index 00000000000..af96d5be438 --- /dev/null +++ b/apps/workplace/e2e/settings/explore/explore.zone-polygons-and-labels.browser.spec.ts @@ -0,0 +1,117 @@ +/** + * Settings coverage: 3 unique Settings / 6 scenarios. + * Exact Setting keys: + * explore.use_zone_polygons + * explore.show_zone_labels + * explore.disable_labels + * Two independent contexts per Setting: true/false polygons; true/false zone + * labels; []/['zones'] disabled labels. + * Source contracts: + * - explore-zones.service.ts: init reads map_regions.details.areas; hide_label + * must be false; draw_polygon OR use_zone_polygons selects the polygon path. + * _bindToLevel reads AreaManagement :areas and ; parseData + * excludes room-backed areas and publishes zones labels and zones-styles. + * - explore-desks.service.ts also binds and :desk_bookings. + * - OrganisationService.module resolves bindings.area_management.id/mod. + * - ExploreStateService._disabledGroups merges explore.disable_labels. + * - MapCanvasComponent draws polygons on [map-canvas] canvas; DynamicMapComponent + * renders label.map-label. No application methods or renderer calls are patched. + * - ts-client auth uses MD5(redirect_uri) token/expiry storage keys and + * GET /auth/authority; realtime becomes connected on its first server message. + * + * Safety: settings-harness browserOnlyTest. Fresh empty storage, synthetic + * staff and auth (fake bearer), every API/auth GET answered in the browser, + * no request or socket forwarded to a backend. Only exact AreaManagement + * bind/unbind tuples are accepted; commands, mutations, redirects and + * unexpected requests fail the strict teardown audit. + */ +import { expect, type Page } from '@playwright/test'; +import { + browserOnlyTest, + type ControlAudit, + controlAudit, + controlSocket, + type DebugWindow, + exploreValues, + openSyntheticExplore, + pixel, + type Settings, + syntheticExplore, +} from '../support/settings-harness'; + +const FAKE_BEARER = 'browser-only-zone-settings-not-a-valid-credential'; +const EXPLORE = syntheticExplore('e2e-user-zone-settings-'); +const { TARGET, COLOUR } = EXPLORE; +const LIVE = EXPLORE.LIVE; +const BASE: Settings = { + ...EXPLORE.BASE, + 'app.explore.colors': { 'zone-free': COLOUR }, +}; + +const test = browserOnlyTest({ + audit: controlAudit, + socket: controlSocket({ + bearer: FAKE_BEARER, + values: exploreValues(EXPLORE, LIVE), + key: (r) => r.name, + }), +}); + +async function openExplore(page: Page, audit: ControlAudit, override: Settings) { + return openSyntheticExplore(page, audit, EXPLORE, { + settings: { ...BASE, ...override }, + bearer: FAKE_BEARER, + delivered: Object.keys(LIVE), + }); +} + +function renderedLabels(page: Page) { + return page.locator('explore-map-view label.map-label').filter({ hasText: '23' }); +} +function mapState(page: Page) { + return page.locator('explore-map-view').evaluate((element, id) => { + const view = (window as DebugWindow).ng?.getComponent(element) as { + styles(): Record; + labels(): { content: string }[]; + } | undefined; + if (!view?.styles || !view.labels) throw new Error('Read-only map signals unavailable'); + return { fill: view.styles()['#' + id]?.fill ?? '', + labels: view.labels().map((label) => label.content) }; + }, TARGET); +} + +for (const enabled of [true, false]) { + test('explore.use_zone_polygons=' + enabled + ' renders the area using the selected drawing path', + async ({ isolated: { page, audit } }) => { + await openExplore(page, audit, { 'app.explore.use_zone_polygons': enabled }); + await expect(renderedLabels(page)).toHaveCount(1); + await expect(renderedLabels(page)).toBeVisible(); + // A rendered label at this area's explicit location independently + // proves the same target metadata/live record has been processed. + await expect.poll(() => pixel(page, 0.25, 0.25)).toEqual( + enabled ? [18, 52, 86, 128] : [0, 0, 0, 0]); + await expect.poll(async () => (await mapState(page)).fill).toBe(enabled ? '' : COLOUR); + }); +} +for (const enabled of [true, false]) { + test('explore.show_zone_labels=' + enabled + ' controls the rendered sensor label', + async ({ isolated: { page, audit } }) => { + await openExplore(page, audit, { 'app.explore.show_zone_labels': enabled }); + await expect.poll(() => pixel(page, 0.25, 0.25)).toEqual([18, 52, 86, 128]); + // Target polygon proves this specific live area was parsed before + // asserting absence of its label (control polygon alone is insufficient). + await expect.poll(async () => (await mapState(page)).labels.some((s) => s.includes('23'))).toBe(enabled); + await expect(renderedLabels(page)).toHaveCount(enabled ? 1 : 0); + if (enabled) await expect(renderedLabels(page)).toBeVisible(); + }); +} +for (const disabled of [false, true]) { + test('explore.disable_labels=' + (disabled ? '[zones]' : '[]') + ' filters zone labels while retaining the area', + async ({ isolated: { page, audit } }) => { + await openExplore(page, audit, { 'app.explore.disable_labels': disabled ? ['zones'] : [] }); + await expect.poll(() => pixel(page, 0.25, 0.25)).toEqual([18, 52, 86, 128]); + await expect.poll(async () => (await mapState(page)).labels.some((s) => s.includes('23'))).toBe(!disabled); + await expect(renderedLabels(page)).toHaveCount(disabled ? 0 : 1); + if (!disabled) await expect(renderedLabels(page)).toBeVisible(); + }); +} 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..4d0f4feff90 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.allow-dark-mode.spec.ts @@ -0,0 +1,197 @@ +import type { 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 { jsonBody } from '../../../../../e2e/support/teardown'; +import { READ_ONLY_SETTINGS } from '../../local/wpa-settings-readonly-legacy.fixture'; +import { + type Audit, + CHAT_HINT_FIXTURE, + image, + isolateChatHint, + test, +} from '../support/settings-harness'; + +// Setting: allow_dark_mode (docs/settings/workplace.md). +// Coverage: 1 unique setting, 2 scenarios. +// 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 LIGHT = image('white'); +const DARK_A = image('navy'); + +// Every PlaceOS WebSocket fails the test, the shared systems/control endpoint +// included. +test.use({ auditSockets: 'block-all' }); + +async function openBranding( + page: Page, + overrides: Record, + audit: Audit, +) { + 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 = jsonBody(response); + }); + 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.fixtures[CHAT_HINT_FIXTURE] ?? 0, { + 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(`allow_dark_mode ${enabled} gates a saved dark theme`, async ({ + staffPage, + audit, + }) => { + await staffPage.clock.install(); + const logo = await openBranding( + staffPage, + { 'app.allow_dark_mode': enabled }, + audit, + ); + // SettingsService applies the theme class from setOverrides (after the + // org settings load, which creates the override style) and from a timer + // 1000 ms after the user settings load. + await expect + .poll(() => + staffPage.evaluate(() => + ( + window as Window & { + setting?: (key: string) => unknown; + } + ).setting?.('theme'), + ), + ) + .toBe('dark'); + await expect(staffPage.locator('style#css-var-overrides')).toHaveCount( + 1, + ); + await staffPage.clock.runFor(1_100); + 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.banner.spec.ts b/apps/workplace/e2e/settings/general/general.banner.spec.ts new file mode 100644 index 00000000000..f1601c87ea2 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.banner.spec.ts @@ -0,0 +1,136 @@ +// Setting: banner (banner.id, banner.type, banner.content) (docs/settings/workplace.md). +// Coverage: 5 scenarios shared by the banner keys (warn, error, empty content, +// close remembers the id, a new id shows again). Consumer: GlobalBannerComponent. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { + BASE_SETTINGS, + LANDING_TIMEZONE, + landingSafetyTest, +} from '../support/settings-harness'; + +const test = landingSafetyTest(BASE_SETTINGS, 'e2e-settings-landing'); + +test.describe.configure({ timeout: 120_000 }); + +test.use({ + storageState: undefined, + timezoneId: LANDING_TIMEZONE, + readOnlyTimezone: LANDING_TIMEZONE, + locale: 'en-AU', +}); + +// GlobalBannerComponent: hidden until is_setup() (500 ms after org init), then shown when content is set and +// localStorage PLACE.last_banner differs from banner.id. Close only writes that browser-local key. +type BannerHost = { + is_setup(): boolean; + banner(): { id?: string } | undefined; +}; +const bannerState = (page: Page) => + page.locator('global-banner').evaluate((el) => { + const c = ( + window as unknown as { ng?: { getComponent(e: Element): unknown } } + ).ng?.getComponent(el) as BannerHost | undefined; + return { setup: !!c?.is_setup(), id: c?.banner()?.id ?? null }; + }); +const bannerBox = (page: Page) => + page.locator('global-banner > div:not(.environment-bar)'); + +for (const [type, colour] of [ + ['warn', /\bbg-warning\b/], + ['error', /\bbg-error\b/], +] as const) { + test( + 'banner type ' + type + ' renders the configured content and colour', + async ({ isolated }) => { + const page = await isolated.open({ + 'app.banner': { + id: 'prepared-banner-' + type, + type, + content: 'Prepared banner ' + type, + }, + }); + const box = bannerBox(page); + await expect(box).toHaveCount(1); + await expect(box).toBeVisible(); + await expect(box).toContainText('Prepared banner ' + type); + await expect(box).toHaveClass(colour); + await expect(box.locator('button icon')).toHaveText('close'); + }, + ); +} + +test('banner with empty content stays hidden after setup', async ({ + isolated, +}) => { + const page = await isolated.open({ + 'app.banner': { + id: 'prepared-banner-empty', + type: 'info', + content: '', + }, + }); + // Wait for the source readiness signal so the absence is not vacuous. + await expect + .poll(() => bannerState(page)) + .toEqual({ setup: true, id: 'prepared-banner-empty' }); + await expect(bannerBox(page)).toHaveCount(0); +}); + +test('banner close remembers banner.id in browser storage only', async ({ + isolated, +}) => { + const id = 'prepared-banner-dismiss'; + const page = await isolated.open({ + 'app.banner': { + id, + type: 'info', + content: 'Prepared dismissible banner', + }, + }); + const box = bannerBox(page); + await expect(box).toBeVisible(); + await expect(box).toHaveClass(/\bbg-info\b/); + await box + .locator('button') + .filter({ has: page.locator('icon', { hasText: 'close' }) }) + .click(); + await expect(box).toHaveCount(0); + expect( + await page.evaluate(() => localStorage.getItem('PLACE.last_banner')), + ).toBe(id); + await page.reload(); + // Same setting still applied after reload; the stored id keeps it dismissed. + await expect.poll(() => bannerState(page)).toEqual({ setup: true, id }); + await expect(bannerBox(page)).toHaveCount(0); +}); + +// A dismissal is remembered for one banner.id only, so a different stored id +// (an earlier banner the user closed) does not hide a new banner. The stored +// value is written in the browser before load. +test('banner with a new id shows again after an earlier banner was dismissed', async ({ + isolated, + staffPage, +}) => { + const previous = 'prepared-banner-previous'; + const id = 'prepared-banner-new'; + // isolated.open uses staffPage for the first page of a test. + await staffPage.addInitScript( + (value) => localStorage.setItem('PLACE.last_banner', value), + previous, + ); + const page = await isolated.open({ + 'app.banner': { id, type: 'info', content: 'Prepared new banner' }, + }); + expect(page).toBe(staffPage); + // Wait for setup so visibility is decided by the stored id, not by timing. + await expect.poll(() => bannerState(page)).toEqual({ setup: true, id }); + expect( + await page.evaluate(() => localStorage.getItem('PLACE.last_banner')), + ).toBe(previous); + const box = bannerBox(page); + await expect(box).toHaveCount(1); + await expect(box).toBeVisible(); + await expect(box).toContainText('Prepared new banner'); + await expect(box).toHaveClass(/\bbg-info\b/); +}); diff --git a/apps/workplace/e2e/settings/general/general.basic-user-search.spec.ts b/apps/workplace/e2e/settings/general/general.basic-user-search.spec.ts new file mode 100644 index 00000000000..70f1308f85f --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.basic-user-search.spec.ts @@ -0,0 +1,195 @@ +// Setting: basic_user_search (docs/settings/workplace.md). +// Coverage: 1 unique setting, 2 scenarios (the landing colleague search and the +// global search, both with basic_user_search true). The false path of the +// landing search is covered by landing.colleagues-require-auth.spec.ts and of +// the global search by general.hide-global-search-items.spec.ts. +import type { Page } from '@playwright/test'; +import { expect } from '../../../../../e2e/support/fixtures'; +import { + type Audit, + CONTROLS, + fixtureHits, + getOnly, + prepare, + prepareSettings, + test, +} from '../support/settings-harness'; + +test.use({ readOnlyTimezone: 'Etc/UTC' }); + +const QUERY = 'E2ESettingsColleague'; +// The global search only lists results whose name or email contains the query. +const ENGINE_USER = { + id: 'e2e-settings-user', + name: 'E2E Settings User List', + email: 'e2esettingscolleague-user@example.invalid', +}; +const STAFF_PERSON = { + id: 'e2e-settings-dir', + name: 'E2E Settings Directory', + email: 'e2esettingscolleague-dir@example.invalid', +}; + +/** Answers the engine user list and the staff directory for QUERY, counted. */ +async function userSources(page: Page, audit: Audit) { + await getOnly( + page, + audit, + 'PlaceOS user list', + (url) => + url.pathname === '/api/engine/v2/users' && + url.searchParams.get('q') === QUERY, + () => [ENGINE_USER], + ); + // Provider-backed directory, answered in the browser so it is never contacted. + await getOnly( + page, + audit, + 'staff directory', + (url) => + url.pathname === '/api/staff/v1/people' && + url.searchParams.get('q') === QUERY, + () => [STAFF_PERSON], + ); +} + +// LandingStateService.search_fn selects engine users or staff people; no +// contact is selected or saved. +test('basic_user_search true searches the PlaceOS user list for landing colleagues', async ({ + staffPage, + audit, +}) => { + const fixture = await prepare(staffPage, audit, { + 'app.basic_user_search': true, + 'app.colleagues_require_auth': false, + 'app.hide_colleagues': false, + 'app.show_quick_book': false, + 'app.show_quick_links': false, + }); + await userSources(staffPage, audit); + await staffPage.goto('/#/landing'); + await fixture.ready(); + await expect(staffPage.locator('landing-new')).toHaveCount(1); + await expect(staffPage.locator('landing-new > div')).toBeVisible(); + const colleagues = staffPage.locator('landing-colleagues-new'); + const open = colleagues.getByRole('button', { + name: 'person_add Add Colleagues', + exact: true, + }); + await expect(open).toBeVisible(); + await open.click(); + const modal = staffPage.locator('add-colleagues-modal'); + await expect(modal).toBeVisible(); + const input = modal.locator('input[matInput]'); + await expect(input).toBeEnabled(); + // The modal renders before its first change detection, which runs + // ngOnInit and clears the search. ng-pristine is set in that pass. + await expect(input).toHaveClass(/\bng-pristine\b/); + // ngModel -> setOptions -> 500ms debounce. Fill once so no partial + // query can reach the provider-backed directory between keystrokes. + await input.fill(QUERY); + const results = modal.locator('main button'); + await expect(results.filter({ hasText: ENGINE_USER.name })).toHaveCount(1); + await expect(results.filter({ hasText: ENGINE_USER.name })).toBeVisible(); + await expect(results.filter({ hasText: STAFF_PERSON.name })).toHaveCount(0); + expect(fixtureHits(audit, 'PlaceOS user list')).toBeGreaterThan(0); + expect(fixtureHits(audit, 'staff directory')).toBe(0); + // Selection stays empty; the save action must remain disabled. + await expect(modal.locator('footer button').last()).toBeDisabled(); + // Adding a colleague writes the user's contacts metadata, so only Cancel is clicked. + await modal.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(modal).toHaveCount(0); + await expect(staffPage).toHaveURL(/#\/landing$/); +}); + +// ExploreSearchService.search_fn: queryUsers when basic_user_search is set, +// otherwise searchStaff. The other result sources are answered empty. +test('basic_user_search true searches the PlaceOS user list in the global search', async ({ + staffPage, + audit, +}) => { + const fixture = await prepareSettings( + staffPage, + { + 'app.global_search': true, + 'app.basic_user_search': true, + 'app.hide_global_search_items': [], + }, + { + audit, + controls: CONTROLS, + landingRooms: false, + identity: false, + routes: async () => { + await userSources(staffPage, audit); + await getOnly( + staffPage, + audit, + 'empty space search', + (url) => + url.pathname === '/api/engine/v2/systems' && + url.searchParams.get('q') === QUERY, + () => [], + ); + await getOnly( + staffPage, + (url) => + /^\/api\/engine\/v2\/metadata\/[^/]+\/children$/.test( + url.pathname, + ) && + ['desks', 'map_features'].includes( + url.searchParams.get('name') ?? '', + ), + () => [], + ); + await getOnly( + staffPage, + (url) => + /^\/api\/engine\/v2\/metadata\/[^/]+$/.test( + url.pathname, + ) && + ['points-of-interest', 'emergency_contacts'].includes( + url.searchParams.get('name') ?? '', + ), + (url) => { + const name = url.searchParams.get('name') ?? ''; + return { + [name]: { + id: url.pathname.split('/')[5], + name, + details: {}, + }, + }; + }, + ); + await getOnly( + staffPage, + (url) => + url.pathname === '/api/engine/v2/asset_categories' && + !!url.searchParams.get('zone_id'), + () => [], + ); + }, + }, + ); + await staffPage.goto('/#/landing'); + await fixture.ready(); + await staffPage + .locator('topbar global-search button[name="global-search"]') + .click(); + const input = staffPage.locator('global-search div[search] input'); + await expect(input).toBeVisible(); + await input.fill(QUERY); + const rows = staffPage.locator('global-search a[href*="/explore?"]'); + const engine = staffPage.locator( + 'global-search a[href*="/explore?"][href*="user=' + + ENGINE_USER.email.split('@')[0] + + '"]', + ); + await expect(engine).toHaveCount(1); + await expect(engine).toContainText(ENGINE_USER.name); + await expect(rows.filter({ hasText: STAFF_PERSON.name })).toHaveCount(0); + await expect(rows).toHaveCount(1); + expect(fixtureHits(audit, 'PlaceOS user list')).toBeGreaterThan(0); + expect(fixtureHits(audit, 'staff directory')).toBe(0); +}); diff --git a/apps/workplace/e2e/settings/general/general.css-variables-dark.spec.ts b/apps/workplace/e2e/settings/general/general.css-variables-dark.spec.ts new file mode 100644 index 00000000000..3f5785af6d7 --- /dev/null +++ b/apps/workplace/e2e/settings/general/general.css-variables-dark.spec.ts @@ -0,0 +1,216 @@ +// Coverage: 1 unique setting, 3 scenarios. +// Setting: css_variables_dark (docs/settings/workplace.md). +// Consumer: libs/common/src/lib/settings.service.ts. +// The dark overrides are emitted as a `body.theme-dark` rule in the shared +// `