diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index dc2c5d07451..a89ec6458d7 100644 --- a/E2E_USER_STORIES.md +++ b/E2E_USER_STORIES.md @@ -111,7 +111,7 @@ the PR gate. |----|---|-------|--------| | WP-E2E-01 | P0 | An authenticated user lands on the workplace home; the shell renders and org data resolves (**not** `/misconfigured`). | **done** — `local/boot.spec.ts` | | WP-E2E-02 | P0 | An unauthenticated visit redirects to the authority's login page, real credentials sign in, and the app loads authenticated. | **done** — `local/login.spec.ts` | -| WP-E2E-05 | P0 | A **non-admin** books a desk through the full UI; the backend stores it with the right asset, title and zones. | **done** — `local/desk-booking.spec.ts` | +| WP-E2E-05 | P0 | A **non-admin** books a desk through the full UI; the backend stores it with the right asset, title and zones, and the app shows its success view with the name of the desk. | **done** — `local/desk-booking.spec.ts` | | WP-E2E-06 | P1 | A deleted booking disappears from the listing (teardown really tears down). | **done** — `local/desk-booking.spec.ts` | | WP-E2E-03 | P1 | Building/level selectors are populated from seeded zones, and changing them re-scopes what is bookable. | todo | | WP-E2E-07 | P1 | "Your bookings" lists the user's own booking; cancelling it moves it out of the upcoming list. | todo | @@ -151,19 +151,19 @@ was built in. Extending there is a separate piece of work, not an oversight. | VIS-05 | P1 | One user's visitor invite is **not** visible to another user, and cannot be deleted by them. | **done** — `local/visitor-scoping.spec.ts`. The desk equivalent is WP-E2E-08; a visitor invite leaks a named outside person, not just a seat. | | VIS-06 | P1 | Control for VIS-05: you *can* see your own invite and its guest record, so "nobody sees anything" cannot pass as success. | **done** — `local/visitor-scoping.spec.ts` | | VIS-07 | P2 | With all-day invites allowed, an all-day invite is **stored** as all-day rather than as a long timed booking. | **done** — `local/visitor-settings.spec.ts` | -| VIS-08 | P2 | With a reason required, sending without one is refused by the form and nothing reaches the backend. | **done** — `local/visitor-settings.spec.ts` | +| VIS-08 | P2 | With a reason required, sending without one is refused by the form and nothing reaches the backend. | **blocked** — `local/visitor-settings.spec.ts`, `test.fixme`. The new invite flow does not read `visitors.reason_required`: with the reason cleared, Confirm sends the invite with the title "Visit" and gets `201`. The spec passed on the flow before PR #561. | | VIS-09 | P1 | Booking on behalf of somebody else stores **them** as the host (`user_*`) and **you** as the booker (`booked_by_*`). | **done** — `local/visitor-delegate.spec.ts`. Needs `visitors.can_book_for_anyone` **and** `visitors.can_book_for_others`: one without the other renders the host picker and then silently discards the choice. | | VIS-10 | P1 | The booker's schedule flags a delegated invite as **Associate** (and shows "For {name}"); the host's own listing shows it as theirs, unflagged. | **done** — `local/visitor-delegate.spec.ts`. Visibility is asymmetric — the booker only sees it at all because the schedule sends `include_booked_by=true`. | | VIS-11 | P1 | A visitor is checked **in** and then back **out**, both recorded on the backend; after checking out the control is gone, so one invite cannot be used twice. | **done** — `local/visitor-checkin.spec.ts`. Check-in and check-out are the same button; only the check-out asks for confirmation. | | VIS-12 | P1 | A booking too far ahead offers no check-in control, **and** the API refuses one for the timing reason — so hiding the button is not the only thing stopping it. | **done** — `local/visitor-checkin.spec.ts`. The two gates are different sizes: the app hides the button until **15 minutes** before the start, the backend accepts within **1 hour**. | | VIS-13 | P2 | Re-saving a delegated invite **for a future day** keeps the original host, and keeps the day, rather than resetting either to the person editing. | **done** — `local/visitor-edit.spec.ts` | | VIS-14 | P2 | Removing one visitor from a group invite leaves the other members untouched. | **done** — `local/visitor-edit.spec.ts` (PPT-2634 / PPT-2638) | -| VIS-15 | P2 | The same edit works when the app is in **single-visitor** mode. | **blocked** — `local/visitor-edit.spec.ts`, `test.fixme`. VIS-B8 below. The identical flow in the default mode is green (VIS-13), so this is the mode, not the flow. | +| VIS-15 | P2 | The same edit works when the app is in **single-visitor** mode. | **done** — `local/visitor-edit.spec.ts`. VIS-B8 does not occur in the new invite flow: a single-mode edit sends `PATCH /bookings/:id`, gets `200`, and the reason is stored. | | VIS-16 | P1 | Cancelling an invite **from the app** — the booking menu and its confirmation — really removes it, on the backend and not just on screen. | **done** — `local/visitor-cancel.spec.ts`. Every other spec tears down through the API, so this button was never once pressed. | | 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. | **done** — `local/visitor-details.spec.ts`. Both behind settings that are off by default. | +| 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-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. | @@ -226,23 +226,23 @@ needs, and its row says what the spec has to set up. | ROOM-08 | P2 | A maximum meeting length and bookable hours limit what the form offers. | **done** — `local/room-times.spec.ts`. Asserts on the options *offered*, because an out-of-range choice is absent rather than refused. The keys are `app.events.*`, not `app.bookings.*`: set the bookings keys and the form happily offers an 8-hour meeting. | | ROOM-09 | P2 | An attendee added on the form is stored with the booking. | **done** — `local/room-attendees.spec.ts`. A room booking is the only one of the three surfaces with a real attendee list, so it is the only place the list can go wrong. Stored in `extension_data.attendees`, which also carries the room itself as a resource. | | ROOM-10 | P2 | An attendee removed before sending is not invited, and the rest still are. | **done** — `local/room-attendees.spec.ts` | -| ROOM-11 | P1 | Cancelling a room booking **from the app** — the booking menu and its confirmation — really removes it on the backend. | **blocked** — `local/room-cancel.spec.ts`, `test.fixme`. ROOM-B4 below. Every other room spec tears down through the API, so this button was never once pressed. | +| ROOM-11 | P1 | Cancelling a room booking **from the app** — the booking menu and its confirmation — really removes it on the backend. | **done** — `local/room-cancel.spec.ts`. On the new schedule the cancel sends `DELETE /api/staff/v1/bookings/` and gets `202`. ROOM-B4 does not occur. | | ROOM-12 | P1 | Declining that confirmation leaves the booking alone. | **done** — `local/room-cancel.spec.ts`. A dialog whose decline button also deletes is worse than one that fails to delete. It passing while ROOM-11 does not is what shows ROOM-B4 is the delete itself, not the menu or the dialog. | -| ROOM-13 | P1 | A room booking carries its **zone hierarchy** (org, building, level), as desk and visitor bookings do. | **blocked** — `local/room-booking.spec.ts`, `test.fixme`. ROOM-B2 below. Fold it into ROOM-01 once the app populates zones. | -| ROOM-14 | P1 | The meeting **length** chosen on the form is the length the room is held for. | **blocked** — `local/room-times.spec.ts`, `test.fixme`. ROOM-B3 below. Fold it into ROOM-07 once the app carries the choice through. | +| ROOM-13 | P1 | A room booking carries its **zone hierarchy** (org, building, level), as desk and visitor bookings do. | **done** — `local/room-booking.spec.ts`. The new meeting flow stores the zones. ROOM-B2 does not occur. | +| ROOM-14 | P1 | The meeting **length** chosen on the form is the length the room is held for. | **done** — `local/room-times.spec.ts`. A 90 minute meeting is stored as 90 minutes. ROOM-B3 does not occur. | | ROOM-15 | P1 | Re-opening a booking and choosing a new **start time** stores the new time, keeps the room, and updates the same booking rather than replacing it. | **done** — `local/room-edit.spec.ts`. The first row here to exercise a `PATCH` rather than a `POST`: `saveBooking` branches on the id, so an edit that lost it would create a second booking and hold the room twice. | | ROOM-16 | P1 | Moving a booking to **another room** stores it against that room, and does not move it in time. | **done** — `local/room-edit.spec.ts`. Needs the second seeded room — with one room, "the room changed" and "the field was ignored" are the same observation. | | ROOM-17 | P1 | With `app.events.strict_capacity_check`, a meeting with more people than the room holds is **refused by the form and nothing reaches the backend**. | **done** — `local/room-capacity.spec.ts`. Asserts on the network, not on a message. Carries its own control — one attendee lighter, the same form reaches the confirm screen. Red-checked: without the setting the over-capacity meeting gets straight through. | | ROOM-18 | P2 | **By default** the same meeting is only **warned** about, and still books. | **done** — `local/room-capacity.spec.ts`. The shipped behaviour, so the one real users get. Only the room changes between the control and the assertion, which is what makes it about capacity. | | ROOM-19 | P2 | A room marked as a **favourite** is saved to the user's own settings and the "Favorites Only" filter then narrows the picker to it. | **done** — `local/room-favourites.spec.ts`. The one room feature that outlives the booking flow: it is stored in the user's `settings` metadata, debounced ~2.4s, so the spec polls the backend rather than trusting the star. | | ROOM-20 | P1 | A room booked with the default settings is stored **unapproved** (`tentative`), and still holds the room. | **done** — `local/room-approval.spec.ts`. Also the control for ROOM-21. | -| ROOM-21 | P2 | With `app.bookings.no_approval`, the booking is stored **approved**. | **blocked** — `local/room-approval.spec.ts`, `test.fixme`. ROOM-B1 below, **re-measured 2026-09-16**: the booking POST is still a `500`. | -| ROOM-22 | P1 | **Catering** ordered on the form reaches the backend as its own `catering-order` booking, linked to the meeting. | **blocked** — `local/room-catering.spec.ts`, `test.fixme`. ROOM-B5 below. The menu seeding works and the form offers catering; it is the order that cannot be saved in this mode. | +| ROOM-21 | P2 | With `app.bookings.no_approval`, a standard user can still book: the booking is stored, and it is stored **pending**, not approved. | **done** — `local/room-approval.spec.ts`. The app does not send `approved` for such a user, so the backend keeps the booking pending. ROOM-B1 (the `500`) does not occur, because the booking now has zones. | +| ROOM-22 | P1 | **Catering** ordered on the form reaches the backend as its own `catering-order` booking, linked to the meeting. | **done** — `local/room-catering.spec.ts`. The catering order is stored and names the item. ROOM-B5 does not occur. | | ROOM-23 | P1 | **Checking in** to a room booking. | **unblocked, no spec yet.** The check-in button only renders when a websocket `binding` to a **`Bookings` driver module** on the room's System reports a status (`event-details-modal.component.ts`: `mod="Bookings" bind="status"`, and the button also needs `room_status() !== 'free'`). The stack now runs core, and every seeded room carries `Place::Bookings` polling a demo calendar in the same system (`e2e/support/room/room.seed.ts`); `home-availability.spec.ts` (HOME-10) proves that chain end to end, since the home page lists a room only while its module reports `free`. A spec here has to put an event covering now into the room's `Calendar_1` (`create_event`, through the module exec route) so the module reports a meeting, then the control renders. Contrast VIS-11, where visitor check-in is a plain API call and is covered. | ### Findings from building this coverage -Five, all reproduced and minimised, **none filed**. Each has a `fixme` row above waiting on it. +Five, all reproduced and minimised on the meeting flow before PR #561, **none filed**. **Measured again on 2026-10-13 with the new meeting flow: none of the five occurs**, and the rows that waited on them are done. The table is kept as the record of what the earlier flow did. | ID | Finding | |----|---------| @@ -267,6 +267,140 @@ What is left, in rough order of value: - **Room features / facilities** filtering in the picker, which has the same shape as ROOM-19's favourites filter. +## 1c. Workplace — the views that PR #561 made the default + +PR #561 changed the components that these routes open: + +| Route | Opens now | Opened before | +|---|---|---| +| `/landing` | `landing-new` | `app-landing` | +| `/book/desk` | `desk-flow-new` | `desk-flow-form` and the desk picker modal | +| `/book/meeting` | `meeting-flow-new` | `meeting-flow-form` and the room picker modal | +| `/book/visitor` | `visitor-flow-new` | `invite-visitor-form` | +| `/your-bookings` | Day, week and list views. The day view is the default. | One list of cards | +| `/team-schedule` | `team-schedule` | No route | + +**What that did to the suite, and what was done.** The specs drove the replaced views, so on +2026-10-13 a full local run had **102 failures in 140 tests**. All 102 are accounted for: + +- **The page objects now drive the new views.** The specs kept their stories and their backend + 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. +- **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** + (2026-10-14). Five of the `fixme` are the four below and VIS-B1, which was `fixme` before. + The other two are VIS-28 and VIS-28b, the detector for REG-09. They were active until + 2026-10-14 and one of them had a phantom write in each full run, so the first runs of this + work had 155 passes and 1 failure. The REG-09 row in §3 has the measurement. +- **ROOM-25b failed in CI for a reason of its own.** `room-recurring.spec.ts` picked the day of + its booking in Sydney time and listed the local day of the machine. The CI runner is in UTC, + where the two are different dates from 13:00 to 24:00 UTC, and the test failed in + [CI run 38482234089](https://github.com/PlaceOS/user-interfaces/actions/runs/38482234089). + It now lists the Sydney day of the booking. +- **The `mock` project has 57 passes and no failure**, three times in a row. Six of its tests + were wrong before this work. PR #561 broke one. The other five were wrong before it. + - `landing.spec.ts`, "controlled free status shows a room and opens its booking modal". It + waited for `app-landing`, which has no route now. It is deleted: the room rows and the + booking modal are gone from the page, and HOME-10 (`local/home-availability.spec.ts`) holds + the free status of a room on the new page. + - `discovery.spec.ts`, US-071 and US-072. The default features do not include + `deals-n-offers`, and the page goes back to the home page when its feature is off. The + spec now turns the feature on. + - `error-states.spec.ts`, US-090. The only child of `app-unauthorised` is + `absolute inset-0`, so the host has no box and Playwright reports it as hidden. This is + the cause of NEW-F11. The spec now asserts on the element inside. + - `explore.spec.ts`, US-041, and `schedule.spec.ts`, US-033/036. Each took a count of an + element at once, before the page had rendered it, and then passed with no check. In a run + where the element was there at that moment, the check that followed was wrong and the + test failed. US-041 expected "Clear Pin", which is the control of a `locate` query: the pin + action renders `event-map-pin`. US-033/036 expected the status text to be visible: the day + view shows a status as a dot, and the text is a hidden description for screen readers. + Both now wait for the element and assert what the page does. + +How each area changed: + +| Area | What the specs do differently | +|---|---| +| Your Bookings (`bookings-*`, and every spec that opens a booking from the schedule) | The page opens on the day view, which has no cards. `YourBookingsPage.open` asks for `?view=list`. `showDayOf` goes to the day view, picks the day on its calendar and comes back, because the list view has a range picker and no single-day calendar. The type filters are matched on their icons. | +| Desk (`desk-*`) | One page, no picker modal and no confirm overlay: Confirm sends the booking. Group and "book for other" are buttons of the details. | +| Room (`room-*`) | Three steps on one route (`view=0`, `1`, `2`) and no confirm screen. The room list has ten rooms a page. | +| Visitor (`visitor-*`) | One page. Single or multiple visitors is a toggle of the page, not a setting. | +| Home (`home-*`) | The next-booking card shows ONE booking, the first of today, so each spec clears the bookings its user has today (`clearToday`). For that reason no spec may make a booking of today for the user of a different worker: it would show on that worker's card, and that worker's sweep would delete it. The "other user" of the scoping spec is the admin, and `visitor-delegate` invites three days on. The clear state of the card is also its state before the bookings load, so the HOME-B1 guard holds a second live booking as its control (red-checked on 2026-10-14: it fails when `upcoming_events` keeps cancelled bookings). One test has a booking that has not started, for the "starts in" branch of the card. Add colleague is a modal. Quick book is the "auto-assign desk" action. | +| Parking (`parking-request`, `parking-restrictions`, `parking-favourites`) | The submit control of the request form lost its `confirm` attribute. The favourite setting is now `favourite_parking`. | + +### Stories that only the new views have + +Sixteen tests in six files. Each has `NEW-` in its name. There is no NEW-DESK-01: it was a copy +of WP-E2E-05, and its two checks of the success view are now in that test. + +| ID | P | Story | Status | +|----|---|-------|--------| +| NEW-DESK-02 | P1 | Confirm with no desk selected is refused with an error notice, and no booking request leaves the page. | **done** — `local/desk-flow-new.spec.ts`. Red-checked: with a desk selected the flow goes to the success view. | +| NEW-ROOM-01 | P1 | A **non-admin** books a room through the three steps of the new flow. The backend stores a `room` booking with the correct system, title and zones. | **done** — `local/meeting-flow-new.spec.ts`. Runs with `app.events.use_bookings = true`, as §1b does. | +| NEW-ROOM-02 | P1 | Each step refuses to go forward without its requirement: a title for the room step, a room for the options step. Each refusal shows an error notice. No booking request leaves the page. | **done** — `local/meeting-flow-new.spec.ts`. The error notice is the signal: "the flow is still on this step" is also true before the click. Red-checked on 2026-10-14: with the `has_space()` check removed from `continue()` the test fails. | +| NEW-VIS-01 | P1 | A **non-admin** invites one visitor through the new flow. The backend stores the booking and makes a guest record. | **done** — `local/visitor-flow-new.spec.ts` | +| NEW-VIS-02 | P1 | Confirm with no visitor address is refused, and no booking request leaves the page. | **done** — `local/visitor-flow-new.spec.ts`. Red-checked. | +| NEW-VIS-03 | P2 | A recent visitor from the user settings (`visitor-invitees`) is shown on the quick action panel, and a click fills the visitor fields. | **done** — `local/visitor-flow-new.spec.ts`. Red-checked. | +| NEW-HOME-02 | P1 | Edit on the next-booking card opens the new desk flow with the booking loaded: the title and the selected desk are those of the booking. | **done** — `local/landing-new.spec.ts` | +| NEW-HOME-03 | P1 | Delete on the card, and its confirmation, removes the booking on the backend. | **done** — `local/landing-new.spec.ts`. Red-checked: without the confirmation the booking stays live. | +| NEW-HOME-04 | P2 | A favourite desk from the user settings is listed, and Book opens the new desk flow with that desk selected. | **done** — `local/landing-new.spec.ts` | +| NEW-HOME-05 | P2 | The desk week offers to book a desk on a day with no desk booking. It shows the booking on a day that has one, and a click opens the booking details. | **done** — `local/landing-new.spec.ts`. Red-checked. | +| NEW-YB-01 | P1 | A booking of today shows on the day view, the week view and the list view. The page opens on the day view. | **done** — `local/schedule-views.spec.ts` | +| NEW-YB-02 | P1 | The day view shows only the bookings of its day, and the topbar moves it to the next day and back to today. | **done** — `local/schedule-views.spec.ts`. Has a booking of today as a control: "not shown" is also true of a view that has not loaded. | +| NEW-YB-03 | P1 | A booking cancelled from the day view (details, menu, confirmation) is removed on the backend. | **done** — `local/schedule-views.spec.ts`. Red-checked. A booking that has ended has no actions. | +| NEW-YB-04 | P2 | The Desks filter of the sidebar hides a desk booking and shows it again. | **done** — `local/schedule-views.spec.ts`. Red-checked. | +| NEW-TEAM-01 | P1 | A colleague from the contacts is a row of the team schedule, and their desk booking shows on its day and on no other day. | **done** — `local/team-schedule.spec.ts`. The booking is four weeks on, so that it cannot race the specs of the worker whose user is the colleague. | +| NEW-TEAM-02 | P2 | A colleague marked as a favourite is saved to the user settings (`favourite_team_members`), and the Favorites view lists only the favourites. | **done** — `local/team-schedule.spec.ts`. Red-checked. | + +### Blocked by the new views + +Each of these is `test.fixme` with the measurement in its comment. The first three passed on the +views before PR #561. + +| ID | Story | What blocks it | +|----|-------|----------------| +| VIS-08 | With a reason required, an invite without one is refused. | The new invite flow does not read `visitors.reason_required`. With the reason cleared, Confirm sends the invite with the title "Visit" and gets `201`. | +| 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. | + +**Not covered, and why.** + +- **The map views** of the desk list and the room list. The stack has no map metadata + (WP-E2E-13). +- **"Available now" counts on the home page.** The free count of a level is shared by all + workers, so HOME-10 asserts that a level is listed, not a number. The room rows and the + booking modal of the earlier page are gone from the page. +- **The route guard of the invite flow**, which asks before it drops an edited invite. +- **Narrow screens.** The team schedule list, the mobile filter sheets and the mobile schedule + calendar. Every spec runs at the desktop size. +- **The calendar path** for rooms, as in §1b. + +### Findings from building this coverage + +None is a failure of a test. None is filed. The four in "Blocked by the new views" are not +repeated here. + +| ID | Finding | +|----|---------| +| **NEW-F1** | **The load-bearing controls of the new views have no `name` attribute.** The Confirm buttons of the three flows, "Search available rooms", Continue, the single/group/other buttons, the schedule view links and type filters, and every control of the team schedule. The page objects match them on their icon ligature or on their position. The replaced views had names on these controls. A `name` on each would make the page objects shorter and stop a change of icon from breaking a spec. | +| **NEW-F2** | A room booking from the new meeting flow is stored with the **address of the host as `user_id`**. Desk and visitor bookings are stored with the user id (`user-…`). The value comes from `newBookingFromCalendarEvent` (`event.organiser?.id \|\| event.host`). The booking is still in the listing of the user. It was not measured whether the earlier meeting flow did the same. | +| **NEW-F3** | **A card of the schedule list that is clicked while the list loads never opens its details.** The list drops its cards while it loads, and the card opens its details on a timer that is cleared when the card is destroyed. A change of view starts a load on a 300 ms debounce. In two traces of the visitor work, the answer for page 2 of today (more than 100 rows) came after the answer for the selected day and replaced the list. `openDetails` clicks again when this happens, and puts each such click in the report as a `NEW-F3` annotation on the test. | +| **NEW-F4** | **Group invite edits.** The invite page loads the other members of a group after it opens. A Confirm before that sent a `PATCH` for the opened member only. After one member of a group invite is refused, the visitor list of the page goes from two chips to none, and a live container with no members stays on the backend. | +| **NEW-F5** | **The asset modal of the meeting options can drop a request.** In 2 of 24 runs the modal closed with the group at quantity 0, and `asset-list-field` dropped the request with no message. `addAssetRequest` adds it again, up to three times. The retry starts for any request that does not land, so each drop goes in the report as a `NEW-F5` annotation on the test. A green run with that annotation is not a clean run. | +| **NEW-F6** | The "no results" text of the add-colleagues modal shows the placeholder itself: `No matching users found for "{{ text }}"`. | +| **NEW-F7** | "Auto-assign desk" sends the **desk id as `asset_name`**, where the modal shows the desk name. The schedule then shows the id for that booking. | +| **NEW-F8** | The All Day toggle of the desk filters shows, and turns on, with `app.bookings.allow_all_day` and `app.desks.allow_all_day` both false. It was not measured whether such a booking saves. | +| **NEW-F9** | PR #561 renamed two user settings: `favourite_spaces` is now `favourite_rooms`, and `favourite_parking_spaces` is now `favourite_parking`. The app reads a value under an old key only until the new key has a value (`settings.service.ts`, `LEGACY_USER_SETTING_KEYS`). This is not a fault; the helpers that seed favourites must write the new keys. | +| **NEW-F10** | The API helpers send `asset_name` at the top level of a booking. The backend does not keep it, so every surface shows the **asset id** for a booking made by a helper. This is a limit of the helpers, not of the app. | +| **NEW-F11** | `landing-new` and `desk-flow-success` have no box, so Playwright reports them as hidden while their content shows. The loading screen (`global-loading`) can also cover a form for a moment after it renders. Wait for an element inside, and do not force a click. | +| **NEW-F12** | **Two forms drop a value that is set while they load.** This is REG-10 on forms that DESK-21 does not cover. The edit form of a parking booking is rebuilt while it loads and puts the booked start time back (`parking-edit.spec.ts`). The meeting flow makes a new form 300 ms after the "meeting with" shortcut opens it, and drops a title typed before that (`home-meeting-with.spec.ts`). A user who is quick loses the value the same way. Each spec sets the value again until the form holds it, and puts a second set in the report as a `REG-10` annotation on the test. | +| **NEW-F13** | **The "meeting with" shortcut of the new home page can lose the colleague.** The shortcut navigates, then writes the colleague to the form on a 300 ms timer (`landing-colleagues-new.component.ts`, `bookMeeting`). When the form page loads shortly after the timer, `loadForm` replaces the model before the form is saved, and the attendee list is empty. `home-meeting-with.spec.ts` failed 2 of 3 tries on this in [CI run 38490657250](https://github.com/PlaceOS/user-interfaces/actions/runs/38490657250) and passed as flaky. Measured in mock mode with each script of the page slowed down: the colleague is lost at a delay of 120, 150 and 180 ms, and kept at 60, 90 and 1000 ms. PR #567 fixed the same fault with `newFormWith`, but only in the replaced home page. "Meeting with the selected colleagues" and Book on a favourite room have the same timer. PR #571 fixes the three. "Book desks with the selected colleagues" has the same shape and is not measured. Until #571 is merged, `home-meeting-with` can be flaky, and the cause is the app and not the test. After it, the 300 ms write is gone and the title loop of NEW-F12 has nothing to retry. | + ## 2. Auth & session Grounded in the auth.cr work (PPT-2536), where every production failure was an @@ -298,8 +432,8 @@ task that found it, so the row can be traced. | REG-06 | P2 | Timezone parsing does not error for a building with an unusual timezone. | "Fix error when parsing timezones" | todo | | REG-07 | P2 | Level selection does not persist once the selector is hidden/disabled. | "Fix level selections persisting when selector is disabled/hidden" | todo | | REG-08 | P1 | An authority with a **relative** `login_url` still reaches a usable login page. | Found 2026-07-30, this suite | **blocked** — currently worked around in `seed.ts`; ts-client resolves a relative `login_url` against the authority host **without its port**, so any non-443 deployment dead-ends. Needs a ts-client/init fix before a spec can assert the good behaviour. | -| REG-09 | P1 | Concurrent `POST /bookings` do not 500. | **[PPT-2642](https://acaprojects.atlassian.net/browse/PPT-2642)** | **fixed at source, not yet in the image this suite runs against.** Root cause was in pg-orm: crystal-db clears a connection's transaction flag only *after* the COMMIT it issues, so a COMMIT that fails — the serialization failures a booking burst produces — leaves it set, and the one place connections return to the pool could not see it. The connection went back to the pool with its `BEGIN` still open; reads kept working inside the orphaned transaction while every write returned 500 until restart. Fixed in [pg-orm#19](https://github.com/spider-gazelle/pg-orm/pull/19) (v2.2.4) and picked up by staff-api in #386. Measured with images differing only by that version: control stranded a connection in 3 of 4 bursts and ended unable to write; fixed, 0 of 11 and still writable. **`e2e/stack` pulls `placeos/staff-api:latest`, which is a release image — so this suite keeps running the old behaviour until the next platform release.** **Corrected 2026-09-14: this suite now DOES burst concurrent bookings.** The line that used to sit here — "harmless for now, nothing here bursts concurrent bookings" — stopped being true when the group-invite specs (VIS-03/VIS-04) landed: a group invite posts the container and every member at once, which is exactly the burst. Measured with `e2e/support/repro/vis-b6-burst.ts` (no browser, 4 concurrent POSTs x 12 rounds): 13x 201, 29x 400, 6x 500, clean in the early rounds and degrading after. Before a rollback-and-retry was added to the visitor flows the group specs failed about one run in three; with it, 10 of 10 runs clean and the recovery fired twice. That retry is a **workaround living in `e2e/support/visitor/visitor.flows.ts`**, to be deleted once the staff-api image carries pg-orm v2.2.4 — it is also why the suite being green must not be read as REG-09 being gone from the image. Reproducers: `e2e/support/repro/reg09-concurrent-bookings.ts` and `vis-b6-burst.ts`. | -| REG-10 | P1 | The booking form does not discard user input while it is still initialising. | **[PPT-2643](https://acaprojects.atlassian.net/browse/PPT-2643)** | **partial** — `desk-initialisation.spec.ts` (DESK-21) covers the desk form. It holds the 300 ms settle timer of the desk flow, so its edits to the title and "Require locker" come before the form reset on any hardware. It fails when the replay of the "Require locker" edit is removed. No spec covers the other forms, and the flows below still converge on the form state. History: the form is rebuilt when async init completes and restores defaults (title, All Day, Require locker), silently dropping anything typed before that. A real user can hit this; they would just see their title or options revert. `bookDeskViaUI` converges on the state to work around it, which means **the suite no longer detects it** — hence this row. Investigated 2026-08-05 against #478 (`a0360486`): **the bug is still live**, established by reading the code rather than by running this suite, and fixed in **PR #479**. `newForm`'s protected branch is never taken by the flows — the current user is restored from cache ~50ms after bootstrap, while `NewDeskFlowComponent.ngOnInit` calls `loadForm` then `newForm` only after org init plus a 300ms settle — and `loadForm` had no capture at all. The shipped e2e suite meanwhile is stable at 6 consecutive full runs, 8/8, `--retries=0`, which is precisely the problem: **it passes either way**. Removing the block could not be shown to be safe *or* unsafe from here: the race needs initialisation to be slow relative to typing, and this machine wins it every time. Two failed attempts to prove otherwise, both recorded so nobody repeats them: (1) a synthetic "type during init, assert it survives" spec passed with *and* without the fix, even with the API responses held to widen the window; (2) removing the block appeared to prove the bug survived — it did not. That red was a Playwright **strict-mode violation**, not a reverted value: opening the desk-select modal puts a second "All Day" checkbox in the DOM (`desk-filters`, bound to the same field), so an unscoped locator matched two elements and threw, with both checked. Scoping the locator to `desk-flow-form` then broke it a second way, because `setCheckbox` silently returns when its locator matches nothing, turning a narrower scope into a no-op and a genuinely invalid form. Both experiments were reverted. **The block stays and this row stays blocked even once #479 lands** — not because the app is unfixed, but because this suite cannot tell either way on fast hardware. The guard for REG-10 is the unit specs in `libs/bookings/src/test/booking-form.service.spec.ts`; unblocking this row needs artificial slowness (throttled CPU), not another e2e attempt. | +| REG-09 | P1 | Concurrent `POST /bookings` do not 500. | **[PPT-2642](https://acaprojects.atlassian.net/browse/PPT-2642)** | **not fixed on the image this suite runs: measured again on 2026-10-14.** On staff-api `placeos-2.2609.6` (revision `e7257439`), the version that has the pg-orm change, `POST /bookings` still answers `201` for a row that is never committed. VIS-28 alone, ten times on one worker, had a phantom write in 3 runs. Each full local run of 13 and 14 October had one, and VIS-28 failed 3 of 3 tries in CI run 38386909981. One phantom was checked by hand: booking 2519 answered `201`, and `GET /bookings/2519` as the admin answered `404` afterwards, while the other member of its group was stored. The refusals of the burst are still `400` and `500`. So VIS-28 and VIS-28b (`local/visitor-concurrency.spec.ts`) are `test.fixme` again, and the retries in the helpers stay. PPT-2642 needs to be opened again. **The history, as it was written before this measurement:** fixed at source, not yet in the image this suite runs against. Root cause was in pg-orm: crystal-db clears a connection's transaction flag only *after* the COMMIT it issues, so a COMMIT that fails — the serialization failures a booking burst produces — leaves it set, and the one place connections return to the pool could not see it. The connection went back to the pool with its `BEGIN` still open; reads kept working inside the orphaned transaction while every write returned 500 until restart. Fixed in [pg-orm#19](https://github.com/spider-gazelle/pg-orm/pull/19) (v2.2.4) and picked up by staff-api in #386. Measured with images differing only by that version: control stranded a connection in 3 of 4 bursts and ended unable to write; fixed, 0 of 11 and still writable. **`e2e/stack` pulls `placeos/staff-api:latest`, which is a release image — so this suite keeps running the old behaviour until the next platform release.** **Corrected 2026-09-14: this suite now DOES burst concurrent bookings.** The line that used to sit here — "harmless for now, nothing here bursts concurrent bookings" — stopped being true when the group-invite specs (VIS-03/VIS-04) landed: a group invite posts the container and every member at once, which is exactly the burst. Measured with `e2e/support/repro/vis-b6-burst.ts` (no browser, 4 concurrent POSTs x 12 rounds): 13x 201, 29x 400, 6x 500, clean in the early rounds and degrading after. Before a rollback-and-retry was added to the visitor flows the group specs failed about one run in three; with it, 10 of 10 runs clean and the recovery fired twice. That retry is a **workaround living in `e2e/support/visitor/visitor.flows.ts`**, to be deleted once the staff-api image carries pg-orm v2.2.4 — it is also why the suite being green must not be read as REG-09 being gone from the image. Reproducers: `e2e/support/repro/reg09-concurrent-bookings.ts` and `vis-b6-burst.ts`. | +| REG-10 | P1 | The booking form does not discard user input while it is still initialising. | **[PPT-2643](https://acaprojects.atlassian.net/browse/PPT-2643)** | **partial** — `desk-initialisation.spec.ts` (DESK-21) covers the desk form. The new desk flow resets the form before its first render, so there is no settle timer to hold. A late reset comes only when `newForm` waits for `GET /users/current`, so the spec holds that request, makes its edits to the title and "Require locker", then releases it. It fails when the replay of the "Require locker" edit is removed, and when `_captureUserEdits()` is removed from the deferred branch of `newForm`. No spec covers the other forms, and the flows below still converge on the form state. History: the form is rebuilt when async init completes and restores defaults (title, All Day, Require locker), silently dropping anything typed before that. A real user can hit this; they would just see their title or options revert. `bookDeskViaUI` converges on the state to work around it, which means **the suite no longer detects it** — hence this row. Investigated 2026-08-05 against #478 (`a0360486`): **the bug is still live**, established by reading the code rather than by running this suite, and fixed in **PR #479**. `newForm`'s protected branch is never taken by the flows — the current user is restored from cache ~50ms after bootstrap, while `NewDeskFlowComponent.ngOnInit` calls `loadForm` then `newForm` only after org init plus a 300ms settle — and `loadForm` had no capture at all. The shipped e2e suite meanwhile is stable at 6 consecutive full runs, 8/8, `--retries=0`, which is precisely the problem: **it passes either way**. Removing the block could not be shown to be safe *or* unsafe from here: the race needs initialisation to be slow relative to typing, and this machine wins it every time. Two failed attempts to prove otherwise, both recorded so nobody repeats them: (1) a synthetic "type during init, assert it survives" spec passed with *and* without the fix, even with the API responses held to widen the window; (2) removing the block appeared to prove the bug survived — it did not. That red was a Playwright **strict-mode violation**, not a reverted value: opening the desk-select modal puts a second "All Day" checkbox in the DOM (`desk-filters`, bound to the same field), so an unscoped locator matched two elements and threw, with both checked. Scoping the locator to `desk-flow-form` then broke it a second way, because `setCheckbox` silently returns when its locator matches nothing, turning a narrower scope into a no-op and a genuinely invalid form. Both experiments were reverted. **The block stays and this row stays blocked even once #479 lands** — not because the app is unfixed, but because this suite cannot tell either way on fast hardware. The guard for REG-10 is the unit specs in `libs/bookings/src/test/booking-form.service.spec.ts`; unblocking this row needs artificial slowness (throttled CPU), not another e2e attempt. | ## 4. Platform & configuration @@ -327,9 +461,10 @@ Config gaps caused several production incidents, and they are invisible to UI sp 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. -- **Eight rows are blocked on product fixes, not on test effort** (REG-08, REG-09, - VIS-15, ROOM-11, ROOM-13, ROOM-14, ROOM-21, ROOM-22). All were found by this suite. Leaving them visible here is the point — a blocked row is coverage - information, a deleted row is not. +- **Six rows are blocked on product fixes, not on test effort** (REG-08, REG-09, VIS-08, the + international half of VIS-20, ROOM-28 and NEW-DESK-03). The last four 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 what would unblock it. ROOM-23 was the other until the stack gained core and a `Bookings` driver on every seeded room; it is now only unwritten. @@ -342,7 +477,10 @@ Config gaps caused several production incidents, and they are invisible to UI sp The desk specs also fail more often as the suite grows, because more specs means more parallel load. **Nothing should go near CI until this is settled**, and settling it is a backend change or a purge step — not a spec change, and the user's decision either way. -- **REG-09 is fixed, and worth reading about.** One burst of concurrent booking POSTs used to +- **REG-09 is NOT fixed on the image that the suite runs.** This note said "fixed" until + 2026-10-14. The REG-09 row in §3 has the measurement: phantom `201` writes still occur on + staff-api `placeos-2.2609.6`. The rest of this note is the history of the first fix, and it + is still worth reading. One burst of concurrent booking POSTs used to poison staff-api's connection pool, so booking creation returned 500 for everyone until the service restarted — while reads kept working, because they ran inside the orphaned transaction. Filed as PPT-2642, root-caused to pg-orm and fixed in v2.2.4. It reaches this suite when the @@ -357,7 +495,7 @@ Config gaps caused several production incidents, and they are invisible to UI sp form bug). - **REG-10 is invisible to the booking flows of this suite by design.** `bookDeskViaUI` converges on the form state, so no flow will catch it regressing. Only `desk-initialisation.spec.ts` - (DESK-21) detects it, and only for the desk form: it holds the settle timer of the desk flow. PPT-2643's fix landed in #478; whether it is + (DESK-21) detects it, and only for the desk form: it holds the request that the late form reset waits for. PPT-2643's fix landed in #478; whether it is *complete* is genuinely unresolved, and the honest summary is that this suite cannot answer it on hardware this fast. Deleting the converging block passes serially and fails about one full parallel run in three, on the symptom rather than on the mechanism. diff --git a/apps/concierge/e2e/local/concierge-catering-management.spec.ts b/apps/concierge/e2e/local/concierge-catering-management.spec.ts index 550af05857c..4d8931eead8 100644 --- a/apps/concierge/e2e/local/concierge-catering-management.spec.ts +++ b/apps/concierge/e2e/local/concierge-catering-management.spec.ts @@ -8,7 +8,7 @@ import { runName, } from '../../../../e2e/support/concierge/management.api'; import { APP_URL, BACKEND_URL, roleFor } from '../../../../e2e/support/env'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; import { ROOM_BASE_SETTINGS, useSettings, @@ -82,7 +82,7 @@ test('CON-CAT-03: an item created in concierge is stored and offered on the work const page = await context.newPage(); await useSettings(page, ROOM_BASE_SETTINGS); await page.goto(`${APP_URL}/#/book/meeting/form`); - const form = new MeetingForm(page); + const form = new MeetingFlow(page); const systems = await adminApi.get(`${ENGINE}/systems`, { params: { limit: '500' }, }); @@ -92,7 +92,12 @@ test('CON-CAT-03: an item created in concierge is stored and offered on the work (item: { name: string }) => item.name === 'E2E Room 0', ); expect(room, 'the framework must seed E2E Room 0').toBeTruthy(); - await form.chooseRoom(room.name); + // Catering is on the last step of the meeting flow. The first + // step needs a title and the second needs a room. + await form.title.fill(name); + await form.toRoomStep(); + await form.selectRoom(room.name); + await form.toOptionsStep(); await form.addCateringItem(name); await expect(form.cateringOrders).toContainText(name); } finally { diff --git a/apps/workplace/e2e/discovery.spec.ts b/apps/workplace/e2e/discovery.spec.ts index a65cd341d98..5b7e420d131 100644 --- a/apps/workplace/e2e/discovery.spec.ts +++ b/apps/workplace/e2e/discovery.spec.ts @@ -71,6 +71,19 @@ test.describe('Group Events', () => { }); test.describe('Deals & Offers', () => { + // The default features do not include `deals-n-offers`, and the page goes + // back to the home page when its feature is off (`deals.component.ts`, + // `ngOnInit`). The override map is the debug hook that the app reads from + // localStorage (`settings.service.ts`, `loadDebugOverrides`). + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => + window.localStorage.setItem( + 'PLACEOS.setting_overrides', + JSON.stringify({ 'app.features': ['deals-n-offers'] }), + ), + ); + }); + test('US-071: renders the deals & offers page', async ({ page }) => { await page.goto('/#/deals-n-offers?mock=true'); await page diff --git a/apps/workplace/e2e/error-states.spec.ts b/apps/workplace/e2e/error-states.spec.ts index b1767bba764..a1456d6928e 100644 --- a/apps/workplace/e2e/error-states.spec.ts +++ b/apps/workplace/e2e/error-states.spec.ts @@ -10,7 +10,9 @@ const LOAD_TIMEOUT = 30000; test.describe('Error & access states', () => { test('US-090: renders the unauthorised page', async ({ page }) => { await page.goto('/#/unauthorised?mock=true'); - await expect(page.locator('app-unauthorised')).toBeVisible({ + // Not the host element: its only child is `absolute inset-0`, so the + // host has no box and Playwright reports it as hidden. + await expect(page.locator('app-unauthorised [unauthorised]')).toBeVisible({ timeout: LOAD_TIMEOUT, }); }); diff --git a/apps/workplace/e2e/explore.spec.ts b/apps/workplace/e2e/explore.spec.ts index f7c23abf922..53759341b50 100644 --- a/apps/workplace/e2e/explore.spec.ts +++ b/apps/workplace/e2e/explore.spec.ts @@ -42,15 +42,27 @@ test.describe('Explore / Map', () => { timeout: LOAD_TIMEOUT, }); + await expect(page.locator('explore-map-view')).toBeVisible({ + timeout: LOAD_TIMEOUT, + }); + + // The pill shows only while the mock user has a booking of today that + // is not done. The mock bookings are from 7am to 6pm, so an evening + // run has no pill. The pill renders a moment after the map, so wait + // for it: a count taken at once is always 0. const booking_pill = page.locator('[name="upcoming-booking-map-pill"]'); - if ((await booking_pill.count()) > 0) { - await booking_pill.locator('button').click(); - await expect(page.getByRole('button', { name: 'Clear Pin' })).toBeVisible( - { timeout: LOAD_TIMEOUT }, - ); - } else { - await expect(page.locator('explore-map-view')).toBeVisible(); - } + const has_booking = await booking_pill + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!has_booking) return; + + await booking_pill.locator('button').click(); + // The action puts the pin of the booking on the map. Not "Clear Pin": + // that is the control of a `locate` query (US-043), not of this pin. + await expect(page.locator('event-map-pin')).toBeAttached({ + timeout: LOAD_TIMEOUT, + }); }); test('US-043/044: map can open with a located resource query', async ({ diff --git a/apps/workplace/e2e/landing.spec.ts b/apps/workplace/e2e/landing.spec.ts index 2e0292a9c04..9a7240ac3c7 100644 --- a/apps/workplace/e2e/landing.spec.ts +++ b/apps/workplace/e2e/landing.spec.ts @@ -100,24 +100,4 @@ test.describe('Home / Landing', () => { timeout: LOAD_TIMEOUT, }); }); - - test('controlled free status shows a room and opens its booking modal', async ({ - page, - }) => { - await page.goto('/#/?mock=true'); - await expect(page.locator('app-landing')).toBeVisible({ timeout: 30_000 }); - - const room = page.locator('button[name="landing-book-room"]').first(); - await expect(room, 'the mock Bookings module should publish free status').toBeVisible({ - timeout: 30_000, - }); - const room_name = ((await room.innerText()).split('\n')[0] ?? '').trim(); - await room.click(); - - const modal = page.locator('.cdk-overlay-container mat-dialog-container'); - await expect(modal, 'booking the available room should open its modal').toBeVisible({ - timeout: 20_000, - }); - await expect(modal).toContainText(room_name); - }); }); diff --git a/apps/workplace/e2e/local/booking-scoping.spec.ts b/apps/workplace/e2e/local/booking-scoping.spec.ts index 6d3256138ea..d53ff45446a 100644 --- a/apps/workplace/e2e/local/booking-scoping.spec.ts +++ b/apps/workplace/e2e/local/booking-scoping.spec.ts @@ -22,23 +22,11 @@ import { listBookings, releaseAsset, uniqueTitle, - zonesWithTag, } from '../../../../e2e/support/api'; -import type { APIRequestContext } from '@playwright/test'; +import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api'; import { deskFor } from '../../../../e2e/support/env'; -/** - * The org/building/level zone ids a booking carries, matching what the UI sends. - * Always queried by tag: `GET /zones` with no `tags` parameter comes back empty. - */ -async function bookingZones(api: APIRequestContext): Promise { - const groups = await Promise.all( - ['org', 'building', 'level'].map((tag) => zonesWithTag(api, tag)), - ); - return groups.flat().map((z) => z.id); -} - const DAY = 86_400; // Window comfortably wider than any booking these specs create. Keep it that // way: a booking placed exactly on the boundary is not returned by the listing, @@ -60,26 +48,17 @@ test.describe('booking visibility between users', () => { // Create as *this* worker's user, through the API rather than the UI — // the subject here is authorisation, not the booking form. - const me = await (await staffApi.get('/api/engine/v2/users/current')).json(); - const zones = await bookingZones(staffApi); const start = Math.floor(Date.now() / 1000) + DAY; - const created = await staffApi.post(`${STAFF_API}/bookings`, { - data: { - booking_type: 'desk', - asset_id: desk.id, - booking_start: start, - booking_end: start + 3600, - timezone: 'Etc/UTC', - user_email: me.email, - user_id: me.id, - user_name: me.name, - title, - zones, - }, + // The helper creates the booking again when the backend refuses it + // with a concurrent-write signature (REG-09), which a parallel run + // can produce on any create. + const booking = await createDeskBookingViaApi(staffApi, { + desk, + title, + start, + end: start + 3600, }); - expect(created.ok(), `creating the booking should succeed: ${created.status()}`).toBeTruthy(); - const booking = await created.json(); // A second, genuinely different user. const other_role = roleFor('staff', theirs); @@ -99,7 +78,9 @@ test.describe('booking visibility between users', () => { // Sanity: the two identities really are different, or everything below // would pass for the wrong reason. const them = await (await other.get('/api/engine/v2/users/current')).json(); - expect(them.email, 'the second user must be a different person').not.toBe(me.email); + expect(them.email, 'the second user must be a different person').not.toBe( + booking.user_email, + ); // The privacy boundary. const their_view = await listBookings(other, 'desk', from(), to()); @@ -136,29 +117,17 @@ test.describe('booking visibility between users', () => { const title = uniqueTitle('E2E Scoping Control'); await releaseAsset(staffApi, 'desk', desk.id, from(), to()); - const me = await (await staffApi.get('/api/engine/v2/users/current')).json(); - const zones = await bookingZones(staffApi); const start = Math.floor(Date.now() / 1000) + DAY; - const created = await staffApi.post(`${STAFF_API}/bookings`, { - data: { - booking_type: 'desk', - asset_id: desk.id, - booking_start: start, - booking_end: start + 3600, - timezone: 'Etc/UTC', - user_email: me.email, - user_id: me.id, - user_name: me.name, - title, - zones, - }, + // The helper creates the booking again when the backend refuses it + // with a concurrent-write signature (REG-09), which a parallel run + // can produce on any create. + const booking = await createDeskBookingViaApi(staffApi, { + desk, + title, + start, + end: start + 3600, }); - expect( - created.ok(), - `creating the booking should succeed: ${created.status()} ${await created.text()}`, - ).toBeTruthy(); - const booking = await created.json(); try { const mine = await listBookings(staffApi, 'desk', from(), to()); diff --git a/apps/workplace/e2e/local/bookings-edit.spec.ts b/apps/workplace/e2e/local/bookings-edit.spec.ts index c7b29333018..7aa2cba8b8b 100644 --- a/apps/workplace/e2e/local/bookings-edit.spec.ts +++ b/apps/workplace/e2e/local/bookings-edit.spec.ts @@ -76,7 +76,7 @@ test.describe('your bookings — editing', () => { booking_id = booking.id; const schedule = new SchedulePage(staffPage); - schedule.expectEditForm('desk-flow-form'); + schedule.expectEditForm('desk-flow-new'); await schedule.open(); await schedule.showBooking(booking_id, slot.date_ms, ['desk'], async () => { const now = await readBooking(staffApi, booking_id!); @@ -135,7 +135,7 @@ test.describe('your bookings — editing', () => { booking_id = booking.id; const schedule = new SchedulePage(staffPage); - schedule.expectEditForm('invite-visitor-form'); + schedule.expectEditForm('visitor-flow-new'); await schedule.open(); await schedule.showBooking(booking_id, slot.date_ms, ['visitor']); await schedule.startEdit(booking_id); @@ -147,10 +147,12 @@ test.describe('your bookings — editing', () => { // The visitor is the invite's identity, so the form arriving without // them is the same failure as an empty desk form. + // In the visitor flow the address is the value of an input, so + // it is not in the text of the form. await expect( - staffPage.locator('invite-visitor-form'), + staffPage.locator('visitor-flow-new input[name$=".asset_id"]'), 'the visitor form should carry the invited visitor', - ).toContainText(visitor.email.split('@')[0], { timeout: 30_000 }); + ).toHaveValue(visitor.email, { timeout: 30_000 }); } finally { if (booking_id != null) await deleteBooking(staffApi, booking_id); await deleteGuestByEmail(staffApi, visitor.email); diff --git a/apps/workplace/e2e/local/desk-booking.spec.ts b/apps/workplace/e2e/local/desk-booking.spec.ts index 3ec388763c0..0f1ce6333e7 100644 --- a/apps/workplace/e2e/local/desk-booking.spec.ts +++ b/apps/workplace/e2e/local/desk-booking.spec.ts @@ -17,6 +17,7 @@ import { test, expect } from '../../../../e2e/support/fixtures'; import { deskFor } from '../../../../e2e/support/env'; import { bookDeskViaUI } from '../../../../e2e/support/flows'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; import { deleteBooking, getBooking, @@ -52,7 +53,10 @@ test.describe('desk booking', () => { // The UI reached its success state — proof the app believed it too, // not merely that a POST happened. + const flow = new DeskFlow(staffPage); await expect(staffPage).toHaveURL(/#\/book\/desk\/success/); + await expect(flow.successContinue).toBeVisible(); + await expect(flow.success, 'the success view names the desk').toContainText(desk.name); // Read it back independently of the response we just parsed. const stored = await getBooking(staffApi, booking_id); diff --git a/apps/workplace/e2e/local/desk-delegate.spec.ts b/apps/workplace/e2e/local/desk-delegate.spec.ts index 719574b8316..128f6e10e34 100644 --- a/apps/workplace/e2e/local/desk-delegate.spec.ts +++ b/apps/workplace/e2e/local/desk-delegate.spec.ts @@ -4,9 +4,9 @@ * The visitor equivalent (VIS-09) found a real defect: the host picker rendered * from one setting while the choice was only KEPT when a second one was also * set, so a booking silently came back owned by whoever filled the form. The - * desk form has the same shape — a host field rendered from - * `app.bookings.can_book_for_others` — and nothing has ever checked which user - * the booking ends up against. + * desk flow has the same shape — a "Book for other" booking type, with a host + * field, that `can_book_for_others` turns on — and nothing has ever checked + * which user the booking ends up against. * * It matters beyond bookkeeping: a desk booked "for" a colleague but stored * against the booker does not appear on the colleague's schedule, so they have @@ -18,9 +18,10 @@ * * ## One setting, not two * - * Unlike visitors, the desk form reads `can_book_for_others` only - * (`desk-form-details.component.ts`). `can_book_for_anyone` is a visitor-form - * key; setting it here would look thorough and do nothing. + * Unlike visitors, the desk flow reads `can_book_for_others` only + * (`desk-flow-details.component.ts`, from `app.desks` or `app.bookings`). + * `can_book_for_anyone` is a visitor-form key; setting it here would look + * thorough and do nothing. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { @@ -38,7 +39,7 @@ import { slotOn, } from '../../../../e2e/support/desk/desk.env'; import { BOOK_FOR_OTHERS, useSettings } from '../../../../e2e/support/desk/desk.settings'; -import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; test.describe('booking a desk for a colleague', () => { test('the chosen colleague is stored as the user, and you as the booker', async ({ @@ -61,32 +62,34 @@ test.describe('booking a desk for a colleague', () => { try { const me = await currentUser(staffApi); - const form = new DeskForm(staffPage); + const form = new DeskFlow(staffPage); await form.open(); - // The host field only exists with the setting on. Saying so here - // turns a missing-selector timeout into a sentence about - // configuration. + // The "Book for other" type only exists with the setting on, and + // the host field only shows for that type. Saying so here turns a + // missing-selector timeout into a sentence about configuration. await expect( - form.userField, - 'the host field is missing — is `app.bookings.can_book_for_others` set?', + form.bookForOtherButton, + 'the "Book for other" type is missing — is ' + + '`app.bookings.can_book_for_others` set?', ).toBeVisible({ timeout: 30_000 }); + await form.bookForOther(); await form.pickDate(slot.date_ms); - - await expect(async () => { - await form.setChecked(form.requireLocker, false); - await form.setChecked(form.allDay, false); - await form.title.fill(title); - await form.chooseUser(colleague); - if ((await form.chosenDesks.count()) === 0) { - await form.chooseDesk(desk.name); - } - await expect(form.title).toHaveValue(title, { timeout: 1_000 }); - }).toPass({ timeout: 60_000 }); - + await form.setChecked(form.requireLocker, false); + await form.setAllDay(false); await form.setStartTime(hhmm(slot.start)); - await staffPage.waitForTimeout(3_000); + await form.title.fill(title); + await form.chooseUser(colleague); + await form.selectDesk(desk.name); + + // Read the form back before the click. Confirm saves at once. + await expect(form.title).toHaveValue(title); + await expect( + form.userField.locator('a-user-avatar'), + 'the host field should still hold the colleague', + ).toBeVisible(); + await expect(form.chosenDeskNames).toHaveText([desk.name]); const [response] = await Promise.all([ staffPage.waitForResponse( diff --git a/apps/workplace/e2e/local/desk-edit.spec.ts b/apps/workplace/e2e/local/desk-edit.spec.ts index 8e9028f395c..0f42bbb6339 100644 --- a/apps/workplace/e2e/local/desk-edit.spec.ts +++ b/apps/workplace/e2e/local/desk-edit.spec.ts @@ -11,7 +11,7 @@ * * ## Where the edit starts, and why that is not this page * - * A desk booking cannot be opened for editing from the desk form directly: the + * A desk booking cannot be opened for editing from the desk flow directly: the * schedule loads the form with the booking (`editBooking`) and then routes to * `/book/desk`. So these tests begin on the schedule, through * `DeskSchedulePage` — which inherits it rather than copying it. @@ -43,7 +43,7 @@ import { hourFor, slotOn, } from '../../../../e2e/support/desk/desk.env'; -import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; import { DeskSchedulePage } from '../../../../e2e/support/desk/desk-schedule.page'; import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api'; @@ -80,17 +80,16 @@ test.describe('editing a desk booking', () => { await schedule.showDayOf(from_slot.date_ms); await schedule.startEdit(booking_id); - const form = new DeskForm(staffPage); + const form = new DeskFlow(staffPage); await expect( form.chosenDesks, - 'the edit form should open with the booked desk already on it', + 'the edit form should open with the booked desk already selected', ).toHaveCount(1, { timeout: 30_000 }); + await expect(form.title, 'and with the title of the booking').toHaveValue(title); - // Only the time changes. The fields reach the model asynchronously - // and the confirm dialog snapshots the model when it opens, so set - // and settle before confirming. + // Only the time changes. Confirm saves at once, with the values + // the form holds at the click. await form.setStartTime(hhmm(to_slot.start)); - await staffPage.waitForTimeout(3_000); await form.confirmAndSend(); await expect(async () => { @@ -149,25 +148,19 @@ test.describe('editing a desk booking', () => { await schedule.showDayOf(slot.date_ms); await schedule.startEdit(booking_id); - const form = new DeskForm(staffPage); + const form = new DeskFlow(staffPage); await expect( form.chosenDesks, - 'the edit form should open with the booked desk already on it', + 'the edit form should open with the booked desk already selected', ).toHaveCount(1, { timeout: 30_000 }); - - // The form holds one desk, so the old one comes off before the new - // one goes on. Converged on the NAME rather than the count: the - // count is 1 before and 1 after, so it cannot see this swap, and the - // choice reaches the model asynchronously while the confirm dialog - // snapshots the model when it opens. - await expect(async () => { - if (!(await form.chosenDeskNames()).includes(other.name)) { - await form.removeDesk(); - await form.chooseDesk(other.name); - } - expect(await form.chosenDeskNames()).toEqual([other.name]); - }).toPass({ timeout: 45_000 }); - await staffPage.waitForTimeout(3_000); + await expect(form.chosenDeskNames).toHaveText([desk.name]); + + // The flow holds one desk, so a click on another desk replaces the + // selection: there is nothing to take off first. Asserted on the + // NAME rather than the count: the count is 1 before and 1 after, + // so it cannot see this swap. + await form.selectDesk(other.name); + await expect(form.chosenDeskNames).toHaveText([other.name]); await form.confirmAndSend(); await expect(async () => { diff --git a/apps/workplace/e2e/local/desk-favourites.spec.ts b/apps/workplace/e2e/local/desk-favourites.spec.ts index 1d4241bc083..4753589e156 100644 --- a/apps/workplace/e2e/local/desk-favourites.spec.ts +++ b/apps/workplace/e2e/local/desk-favourites.spec.ts @@ -1,5 +1,5 @@ /** - * DESK-16 — saving a favourite desk and filtering the picker to favourites. + * DESK-16 — saving a favourite desk and filtering the desk list to favourites. * * Favourites are a USER setting, written to the signed-in user's `settings` * metadata as `favourite_desks` and read back on every later visit. The room @@ -21,13 +21,12 @@ import { test, expect } from '../../../../e2e/support/fixtures'; import { deskFor } from '../../../../e2e/support/env'; import { altDesk, slotOn } from '../../../../e2e/support/desk/desk.env'; -import { zonesWithTag } from '../../../../e2e/support/api'; import { readUserSettings, setFavouriteDesks, } from '../../../../e2e/support/desk/desk.api'; import { useSettings } from '../../../../e2e/support/desk/desk.settings'; -import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; test.describe('favourite desks', () => { test('a desk marked as a favourite is saved and drives favourites-only filtering', async ({ @@ -45,73 +44,52 @@ test.describe('favourite desks', () => { await setFavouriteDesks(staffApi, []); try { await useSettings(staffPage, {}); - const form = new DeskForm(staffPage); - const levels = await zonesWithTag(staffApi, 'level'); - const level = levels.find((candidate) => - candidate.parent_id && !candidate.tags.includes('parking'), - ); - expect(level, 'a seeded desk level must exist').toBeTruthy(); - const level_name = String(level!.display_name || level!.name); - const openPicker = async () => { + const form = new DeskFlow(staffPage); + const openList = async () => { await form.open(); await form.pickDate(slot.date_ms); await form.setChecked(form.requireLocker, false); - await form.setChecked(form.allDay, false); + await form.setAllDay(false); await form.setStartTime('08:00'); await form.setDuration(60); - await form.addDeskButton.click(); }; - // The local stack can contain duplicate desk metadata on parking - // levels. Match both resources on the same non-parking level. - const favourite = staffPage.locator('button[name="select-desk"]').filter({ - has: staffPage.getByText(desk.name, { exact: true }), - }).filter({ - has: staffPage.getByText(level_name, { exact: true }), - }); - const other = staffPage.locator('button[name="select-desk"]').filter({ - has: staffPage.getByText(control.name, { exact: true }), - }).filter({ - has: staffPage.getByText(level_name, { exact: true }), - }); - const filter = staffPage.locator( - 'desk-filters section[favs] settings-toggle button', - ); - const active_filter = staffPage.locator( - 'button[name="remove-desk-favs-filter"]', - ); + // The list loads again after each change to a filter, and it has + // pages, so both checks read the names on all pages and converge. const expectBoth = async () => { - await expect(active_filter, 'favourites-only filtering must be off').toBeHidden(); - await expect(favourite, 'the worker desk must be offered').toBeVisible(); - await expect(other, 'the non-favourite control must be offered').toBeVisible(); + await expect( + form.favouritesOnly, + 'favourites-only filtering must be off', + ).not.toBeChecked(); + await expect(async () => { + const names = await form.deskNames(); + expect(names, 'the worker desk must be offered').toContain(desk.name); + expect(names, 'the non-favourite control must be offered').toContain( + control.name, + ); + }).toPass({ timeout: 30_000 }); }; const expectFiltered = async () => { - await expect(active_filter, 'favourites-only filtering must be on').toBeVisible(); - await expect(other, 'filtering must exclude the non-favourite').toHaveCount(0); - // Check the retained row last so an empty/loading list cannot pass. - await expect(favourite, 'filtering must retain the favourite').toBeVisible(); + await expect( + form.favouritesOnly, + 'favourites-only filtering must be on', + ).toBeChecked(); + await expect(async () => { + const names = await form.deskNames(); + expect(names, 'filtering must exclude the non-favourite').not.toContain( + control.name, + ); + // Check the retained row too, so an empty list cannot pass. + expect(names, 'filtering must retain the favourite').toContain(desk.name); + }).toPass({ timeout: 30_000 }); }; - await openPicker(); + await openList(); await expectBoth(); - const row = staffPage - .locator('li[desk]') - .filter({ has: favourite }); - const star = row.locator('button[name="toggle-desk-favourite"]').first(); - const found = await star - .waitFor({ state: 'visible', timeout: 20_000 }) - .then(() => true) - .catch(() => false); - if (!found) { - const offered = await staffPage - .locator('button[name="select-desk"]') - .allInnerTexts(); - throw new Error( - `no favourite control on the row for ${desk.name}. The picker offers ` + - `${offered.length} desk(s): ` + - `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}.`, - ); - } - await star.click(); + // The local stack can list the same desk id on two levels. A + // favourite is saved by id, so the first row with the name will + // do, and the saved id is asserted below. + const row = await form.findDesk(desk.name); + await form.favouriteToggle(row).click(); await expect(async () => { const settings = await readUserSettings(staffApi); @@ -121,17 +99,17 @@ test.describe('favourite desks', () => { ).toContain(desk.id); }).toPass({ timeout: 30_000 }); - await filter.click(); + await form.setChecked(form.favouritesOnly, true); await expectFiltered(); - await filter.click(); + await form.setChecked(form.favouritesOnly, false); await expectBoth(); // And it is remembered: a fresh page load reads the saved setting // back, which is the whole point of a favourite. await staffPage.reload(); - await openPicker(); + await openList(); await expectBoth(); - await filter.click(); + await form.setChecked(form.favouritesOnly, true); await expectFiltered(); } finally { // Not optional: this is a saved user setting, not a booking. diff --git a/apps/workplace/e2e/local/desk-flow-new.spec.ts b/apps/workplace/e2e/local/desk-flow-new.spec.ts new file mode 100644 index 00000000000..fcd3fd12fc1 --- /dev/null +++ b/apps/workplace/e2e/local/desk-flow-new.spec.ts @@ -0,0 +1,85 @@ +/** + * NEW-DESK-02, NEW-DESK-03 — the `desk-flow-new` booking flow, as a NON-ADMIN, + * against a real local backend. + * + * `desk-booking.spec.ts` (WP-E2E-05) holds the story of a booking through this + * flow, with its success view. These specs drive the page object directly: + * NEW-DESK-02 checks that Confirm sends nothing when no desk is selected. + * NEW-DESK-03 is a `fixme` for an app fault, see its comment. + * + * There is no NEW-DESK-01. It was a copy of WP-E2E-05 and is merged into it. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deskFor } from '../../../../e2e/support/env'; +import { uniqueTitle } from '../../../../e2e/support/api'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; +import { + createSecondLevel, + removeSecondLevel, +} from '../../../../e2e/support/desk/desk.zones'; + +const isBookingPost = (url: string, method: string) => + url.includes('/api/staff/v1/bookings') && method === 'POST'; + +test.describe('desk booking — the new flow', () => { + test('NEW-DESK-02: Confirm with no desk selected is refused and nothing is sent', async ({ + staffPage, + }) => { + const flow = new DeskFlow(staffPage); + const posts: string[] = []; + staffPage.on('request', (request) => { + if (isBookingPost(request.url(), request.method())) posts.push(request.url()); + }); + + await flow.open(); + await expect(flow.desks.first(), 'the list should offer desks').toBeVisible({ + timeout: 20_000, + }); + await flow.title.fill(uniqueTitle('E2E New Desk None')); + await flow.confirm.click(); + + // The refusal is an error notice. Assert that it shows, then that the + // flow is still on the form: a notice alone does not prove no save. + await expect(staffPage.locator('mat-snack-bar-container.error')).toBeVisible(); + await expect(staffPage).toHaveURL(/#\/book\/desk\/form/); + await expect(flow.successContinue).toHaveCount(0); + expect(posts, 'no booking request may leave the page without a desk').toEqual([]); + }); + + // FIXME (app fault, measured 2026-10-13): one desk whose metadata has no + // `features` list stops `desk-flow-select` from rendering a selection. + // The `features` computed of `desk-flow-select.component.ts` calls + // `.trim()` on the `features` of every desk of the building with no guard, + // and throws for such a desk. The console shows `TypeError: Cannot read + // properties of undefined (reading 'trim')` on each render, and the + // bindings after it in the template do not update. Measured: after a + // click on a desk the form holds it (`model().asset_id` is the desk id), + // and no row shows as selected. This test fails at `selectDesk` with + // `desk "E2E Desk 0" should show as selected`. + // + // The flow that this one replaced read the list through + // `BookingFormService.features`, which has the guard. + // + // Do not run this beside the other specs before the app is fixed: the + // desk it adds breaks the selection in the pages of all workers. + test.fixme('NEW-DESK-03: a desk with no features list does not break the desk selection', async ({ + staffPage, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const extra = await createSecondLevel(testInfo.parallelIndex, { features: false }); + try { + const flow = new DeskFlow(staffPage); + await flow.open(); + // The desk with no list is offered, so the page has it loaded. + await expect + .poll(() => flow.deskNames(), { timeout: 30_000 }) + .toContain(extra.desk.name); + + // A desk that has the list must still show as selected. + await flow.selectDesk(desk.name); + await expect(flow.chosenDeskNames).toHaveText([desk.name]); + } finally { + await removeSecondLevel(extra.zone_id); + } + }); +}); diff --git a/apps/workplace/e2e/local/desk-group.spec.ts b/apps/workplace/e2e/local/desk-group.spec.ts index f9f43e93b85..4a4f4d5ca01 100644 --- a/apps/workplace/e2e/local/desk-group.spec.ts +++ b/apps/workplace/e2e/local/desk-group.spec.ts @@ -53,18 +53,10 @@ test.describe('group desk bookings', () => { staffApi, }) => { await group.ui.prepare(group.seed, group.title, [], group.slot); - if (await group.ui.form.confirmViewButton.isEnabled()) { - await group.ui.submit(); - await expect( - group.ui.page.locator('mat-snack-bar-container'), - ).toBeVisible(); - await expect(group.ui.form.confirmButton).toBeVisible(); - await group.ui.page - .locator('button[name="close-desk-confirm"]') - .click(); - } else { - await expect(group.ui.form.confirmViewButton).toBeDisabled(); - } + // Confirm is always enabled. With no colleague the flow refuses the + // booking with a notice and sends nothing. + await group.ui.submit(); + await group.ui.expectRefused(); expect(await group.created()).toHaveLength(0); await group.ui.form.addGroupMember(group.members[1]); await group.ui.form.addGroupMember(group.members[1]); @@ -103,10 +95,7 @@ test.describe('group desk bookings', () => { group.slot, ); await group.ui.submit(); - await expect( - group.ui.page.locator('mat-snack-bar-container:not([mat-exit])'), - ).toBeVisible(); - await expect(group.ui.form.confirmButton).toBeVisible(); + await group.ui.expectRefused(); expect( await group.created(), 'Insufficient mapped desks must not create a partial group', @@ -119,8 +108,8 @@ test.describe('group desk bookings', () => { } }); - // Known product defect: the UI reports the read-only Response.status error - // instead of displaying the member refusal reason. + // The success view lists each member. A member whose write was refused + // shows with the refusal reason. test('DESK-GROUP-04: reports partial success when a member write is refused', async ({ groupScenario: group, staffApi, diff --git a/apps/workplace/e2e/local/desk-initialisation.spec.ts b/apps/workplace/e2e/local/desk-initialisation.spec.ts index 98dced442fe..bb46984d942 100644 --- a/apps/workplace/e2e/local/desk-initialisation.spec.ts +++ b/apps/workplace/e2e/local/desk-initialisation.spec.ts @@ -2,101 +2,122 @@ * DESK-21 — values entered before desk-form initialisation completes survive * the form reset of the initialisation. */ -import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; import { expect, test } from '../../../../e2e/support/fixtures'; -/** State of the held settle timer of the desk flow. */ -interface DeskInitHold { - /** True after the desk flow started its settle timer. */ - held: boolean; - /** Ends the settle timer. The form reset follows. */ - release: () => void; -} - -type HoldWindow = Window & { desk_init_hold: DeskInitHold }; +/** The time a route guard gives the backend before it uses the offline fallback. */ +const OFFLINE_FALLBACK_MS = 20_000; +/** Upper limit for the guards on the route. Two are there today. */ +const MAX_GUARDS = 4; test.describe('desk form initialisation', () => { test('preserves values entered before the form reset', async ({ staffPage, }, testInfo) => { - // The desk flow shows its form immediately. It resets the form after - // the organisation data loads and a settle timer of 300 ms ends. On - // fast hardware the reset comes before the first edit, and the test - // then passes with or without the fault. Hold the settle timer, so - // the edits below always come before the reset. - await staffPage.addInitScript(() => { - const native = window.setTimeout; - const hold: DeskInitHold = { held: false, release: () => null }; - (window as unknown as HoldWindow).desk_init_hold = hold; - window.setTimeout = (( - fn: (...args: unknown[]) => void, - ms?: number, - ...args: unknown[] - ) => { - const is_settle_timer = - ms === 300 && - !hold.held && - /NewDeskFlowComponent\b/.test(new Error().stack || ''); - if (!is_settle_timer) return native(fn, ms, ...args); - hold.held = true; - hold.release = () => fn(...args); - return 0; - }) as typeof window.setTimeout; + // The desk flow resets its form when it starts. The reset waits for + // the signed-in user (`BookingFormService.newForm`), and the route + // guards wait for the groups of that user too, so the reset usually + // comes before the first render. The test then passes with or without + // the fault. + // + // A guard that gets no answer in 20 s lets the route through on the + // cached credentials. The form then shows with no user, and the reset + // comes when `GET /users/current` answers. Hold that request, so the + // edits below always come before the reset. + let release_user!: () => void; + const user_released = new Promise((resolve) => { + release_user = resolve; + }); + let user_requests = 0; + await staffPage.route('**/api/engine/v2/users/current', async (route) => { + user_requests++; + await user_released; + await route.continue().catch(() => undefined); }); + // Fake timers, so that the test does not wait for the guards in real + // time. Time continues to flow at its usual rate between the jumps. + await staffPage.clock.install(); - const form = new DeskForm(staffPage); - await form.open(); - // If the timer is not found, the reset is not held and the test - // cannot find the fault. Fail here and do not pass without a check. - await expect - .poll( - () => - staffPage.evaluate( - () => - (window as unknown as HoldWindow).desk_init_hold - .held, - ), - { - message: 'the desk flow did not start its settle timer', + try { + await staffPage.goto('/#/book/desk/form'); + await expect + .poll(() => user_requests, { + message: 'the app did not request the signed-in user', timeout: 30_000, - }, - ) - .toBe(true); + }) + .toBeGreaterThan(0); - const title = `DESK-21 distinctive ${Date.now()}`; - await form.title.fill(title); - // The box starts clear, so a tick is the change that the form reset - // can lose. `setChecked` does nothing when the box is not in the - // page, so make sure that the tick is there. - await form.setChecked(form.requireLocker, true); - await expect(form.requireLocker).toBeChecked(); - await testInfo.attach('desk-form-before-reset', { - body: await staffPage.screenshot(), - contentType: 'image/png', - }); + // Let the app finish its start in real time before the first + // jump. The start has its own time limits, and a jump while it + // runs makes the app load again. The start does not wait for the + // user, so the loading screen goes away while the request is held. + await expect( + staffPage.locator('global-loading [loader]'), + 'the app did not finish its start while the user request was held', + ).toBeHidden({ timeout: 60_000 }); + + // More than one guard is on the route, and each waits in turn, so + // jump one fallback time for each guard until the form shows. The + // limit keeps the jumped time below the timers of the app that + // load the page again. If the form does not show while the request + // is held, the reset is not held and the test cannot find the + // fault. Fail here and do not pass without a check. + const form = new DeskFlow(staffPage); + let shown = false; + for (let jump = 0; jump < MAX_GUARDS && !shown; jump++) { + await staffPage.clock.fastForward(OFFLINE_FALLBACK_MS); + shown = await form.title + .waitFor({ state: 'visible', timeout: 3_000 }) + .then(() => true) + .catch(() => false); + } + await expect( + form.title, + 'the desk flow did not show while the user request was held', + ).toBeVisible({ timeout: 20_000 }); - // The reset runs in the microtask after the timer callback. Two - // animation frames let Angular render the result. - await staffPage.evaluate(async () => { - (window as unknown as HoldWindow).desk_init_hold.release(); - await new Promise((resolve) => - requestAnimationFrame(() => - requestAnimationFrame(() => resolve()), - ), + const title = `DESK-21 distinctive ${Date.now()}`; + await form.title.fill(title); + // The box starts clear, so a tick is the change that the form reset + // can lose. `setChecked` fails when the box is not in the page. + await form.setChecked(form.requireLocker, true); + await testInfo.attach('desk-form-before-reset', { + body: await staffPage.screenshot(), + contentType: 'image/png', + }); + + // The reset runs in the microtask after the user is applied. The + // app requests the groups of the user in the same task, so the + // answer to that request comes after the reset. Two animation + // frames then let Angular render the result. + const groups_loaded = staffPage.waitForResponse((response) => + response.url().includes('/api/engine/v2/groups/current'), + ); + release_user(); + await groups_loaded; + await staffPage.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()), + ), + ), ); - }); - await expect( - form.title, - 'the title was lost after the desk form reset', - ).toHaveValue(title); - await expect( - form.requireLocker, - 'the Require locker value was lost after the desk form reset', - ).toBeChecked(); - await testInfo.attach('desk-form-after-reset', { - body: await staffPage.screenshot(), - contentType: 'image/png', - }); + await expect( + form.title, + 'the title was lost after the desk form reset', + ).toHaveValue(title); + await expect( + form.requireLocker, + 'the Require locker value was lost after the desk form reset', + ).toBeChecked(); + await testInfo.attach('desk-form-after-reset', { + body: await staffPage.screenshot(), + contentType: 'image/png', + }); + } finally { + release_user(); + } }); }); diff --git a/apps/workplace/e2e/local/desk-scoping.spec.ts b/apps/workplace/e2e/local/desk-scoping.spec.ts index 6d831c4303c..2afedfa737d 100644 --- a/apps/workplace/e2e/local/desk-scoping.spec.ts +++ b/apps/workplace/e2e/local/desk-scoping.spec.ts @@ -18,7 +18,7 @@ * * ## What is being guarded * - * A picker that ignored the selector would offer every desk in the building + * A desk list that ignored the selector would offer every desk in the building * whatever the user chose — so somebody would book a desk on a floor they never * meant, and nothing about the screen would look wrong. Hence both halves: the * extra level's desk appears when it is selected, and the ordinary level's desks @@ -30,72 +30,44 @@ import { createSecondLevel, removeSecondLevel, } from '../../../../e2e/support/desk/desk.zones'; -import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; test.describe('desk level scoping', () => { - test('choosing a level changes which desks the picker offers', async ({ + test('choosing a level changes which desks the list offers', async ({ staffPage, }, testInfo) => { const ordinary_desk = deskFor(testInfo.parallelIndex); const extra = await createSecondLevel(testInfo.parallelIndex); try { - const form = new DeskForm(staffPage); + const form = new DeskFlow(staffPage); await form.open(); await form.setChecked(form.requireLocker, false); - await form.addDeskButton.click(); - const offered = async () => { - const texts = await staffPage - .locator('button[name="select-desk"]') - .allInnerTexts(); - return texts.map((t) => t.split('\n')[0].trim()); - }; - await expect( - staffPage.locator('button[name="select-desk"]').first(), - 'the desk picker did not open', - ).toBeVisible({ timeout: 20_000 }); - - const before = await offered(); - expect( - before, - `precondition: the ordinary level's desk is offered to begin with. ` + - `Offered: ${JSON.stringify(before)}`, - ).toContain(ordinary_desk.name); + // Converging: the list shows its empty placeholder for a moment + // before the first desks load. + await expect(async () => { + const before = await form.deskNames(); + expect( + before, + `precondition: the ordinary level's desk is offered to begin with. ` + + `Offered: ${JSON.stringify(before)}`, + ).toContain(ordinary_desk.name); + }).toPass({ timeout: 30_000 }); - // Switch the level. The selector is a `mat-select` named `location` - // in the picker's filters; its options are the building's levels. - const selector = staffPage.locator('mat-select[name="location"]').first(); - const present = await selector - .waitFor({ state: 'visible', timeout: 10_000 }) - .then(() => true) - .catch(() => false); - if (!present) { - throw new Error( - `no level selector in the desk picker. It is only rendered when the ` + - `building has levels to choose between — this spec creates a ` + - `second one, so check createSecondLevel ran (zone ` + - `${extra.zone_id}).`, - ); - } - await selector.click(); - // `.first()`: a run that died before teardown can leave a second - // level with this name, and either carries the desk. - const option = staffPage - .locator('.cdk-overlay-container') - .getByRole('option', { name: extra.name, exact: true }) - .first(); - await expect( - option, - 'the extra level is not among the options the selector offers', - ).toBeVisible({ timeout: 10_000 }); - await option.click(); + // Switch the level. The selector is the `mat-select` named + // `location-multi` in the filters of the desk list; its options are + // the levels of the building that have desks. This spec creates a + // second one, so if the option is missing check createSecondLevel + // ran (zone `extra.zone_id`). + await form.chooseLevel(extra.name); // The desk on the extra level appears, and the ordinary level's does // not. The second half is what makes this about scoping rather than - // about the list simply growing. + // about the list simply growing. Converging, because the list + // loads again after the filter changes. await expect(async () => { - const after = await offered(); + const after = await form.deskNames(); expect( after, `the extra level's desk should be offered once it is selected. ` + @@ -103,7 +75,7 @@ test.describe('desk level scoping', () => { ).toContain(extra.desk.name); expect( after, - `and the other level's desks must NOT be — otherwise the picker is ` + + `and the other level's desks must NOT be — otherwise the list is ` + `ignoring the selector, and somebody can book a desk on a floor ` + `they never chose`, ).not.toContain(ordinary_desk.name); diff --git a/apps/workplace/e2e/local/desk-settings.spec.ts b/apps/workplace/e2e/local/desk-settings.spec.ts index e32f14e4bc8..25df619bfd3 100644 --- a/apps/workplace/e2e/local/desk-settings.spec.ts +++ b/apps/workplace/e2e/local/desk-settings.spec.ts @@ -13,10 +13,12 @@ * * ## The setting * - * The checkbox only renders when all-day bookings are allowed - * (`desk-form-details.component.ts`: `allow_all_day`, which also requires - * `allow_time_changes`). It is `app.bookings.allow_all_day` — the BOOKING - * family, not the event family the meeting form reads. + * All-day bookings are allowed with `app.bookings.allow_all_day` — the BOOKING + * family, not the event family the meeting form reads. The setting gates the + * All Day checkbox of `desk-flow-details` (`allow_all_day`, which also requires + * `allow_time_changes`), and that checkbox shows on small screens only. The + * All Day toggle in the filters of `desk-flow-select`, which this spec uses, + * shows with or without the setting. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { @@ -32,7 +34,7 @@ import { slotOn, } from '../../../../e2e/support/desk/desk.env'; import { ALLOW_ALL_DAY, useSettings } from '../../../../e2e/support/desk/desk.settings'; -import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; const HOUR = 3600; @@ -51,25 +53,20 @@ test.describe('desk booking settings', () => { await useSettings(staffPage, ALLOW_ALL_DAY); try { - const form = new DeskForm(staffPage); + const form = new DeskFlow(staffPage); await form.open(); await form.pickDate(slot.date_ms); - await expect(async () => { - await form.setChecked(form.requireLocker, false); - await form.setChecked(form.allDay, true); - await form.title.fill(title); - if ((await form.chosenDesks.count()) === 0) { - await form.chooseDesk(desk.name); - } - await expect(form.title).toHaveValue(title, { timeout: 1_000 }); - await expect( - form.allDay, - 'the All Day checkbox should be on — is `app.bookings.allow_all_day` set?', - ).toBeChecked({ timeout: 1_000 }); - }).toPass({ timeout: 45_000 }); + await form.setChecked(form.requireLocker, false); + await form.setAllDay(true); + await form.title.fill(title); + await form.selectDesk(desk.name); + + // Read the form back before the click. Confirm saves at once. + await expect(form.title).toHaveValue(title); + await expect(form.allDayInput, 'the All Day toggle should be on').toBeChecked(); + await expect(form.chosenDeskNames).toHaveText([desk.name]); - await staffPage.waitForTimeout(2_000); const [response] = await Promise.all([ staffPage.waitForResponse( (r) => diff --git a/apps/workplace/e2e/local/desk-times.spec.ts b/apps/workplace/e2e/local/desk-times.spec.ts index 8b94f8b831a..cca7323cbaa 100644 --- a/apps/workplace/e2e/local/desk-times.spec.ts +++ b/apps/workplace/e2e/local/desk-times.spec.ts @@ -36,7 +36,7 @@ import { slotOn, } from '../../../../e2e/support/desk/desk.env'; import { LIMITED_HOURS, useSettings } from '../../../../e2e/support/desk/desk.settings'; -import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; const MINUTES = 60; @@ -54,35 +54,25 @@ test.describe('desk booking times', () => { await releaseAsset(staffApi, 'desk', desk.id, from, to); try { - const form = new DeskForm(staffPage); + const form = new DeskFlow(staffPage); await form.open(); - // The date goes first and ONCE. Changing the day resets the - // duration asynchronously, so a date click repeated inside the - // converging block below lands after the length is picked and - // quietly reverts it — the same trap the meeting form has. + // The date goes first: a new day loads the list of free desks + // again, and the desk is chosen for the window that gets booked. await form.pickDate(slot.date_ms); - - // Converge on the rest: the form is rebuilt when async init - // finishes and restores its defaults (REG-10). - await expect(async () => { - await form.setChecked(form.requireLocker, false); - await form.setChecked(form.allDay, false); - await form.title.fill(title); - if ((await form.chosenDesks.count()) === 0) { - await form.chooseDesk(desk.name); - } - await expect(form.title).toHaveValue(title, { timeout: 1_000 }); - await expect(form.allDay).not.toBeChecked({ timeout: 1_000 }); - }).toPass({ timeout: 45_000 }); - - // Times LAST, after the rebuild can no longer undo them, and with a - // settle before confirming: the fields reach the model - // asynchronously and the confirm dialog snapshots the model when it - // opens. - await form.setDuration(60); + await form.setChecked(form.requireLocker, false); + await form.setAllDay(false); + await form.title.fill(title); + // Not 60: that is the default length (`desks.default_duration`), + // and a length field that did nothing would also store it. + await form.setDuration(90); await form.setStartTime(hhmm(slot.start)); - await staffPage.waitForTimeout(3_000); + await form.selectDesk(desk.name); + + // Read the form back before the click. Confirm saves at once. + await expect(form.title).toHaveValue(title); + await expect(form.allDayInput).not.toBeChecked(); + await expect(form.chosenDeskNames).toHaveText([desk.name]); const [response] = await Promise.all([ staffPage.waitForResponse( @@ -111,7 +101,7 @@ test.describe('desk booking times', () => { expect( (stored.booking_end - stored.booking_start) / MINUTES, 'and last as long as was picked', - ).toBe(60); + ).toBe(90); } finally { if (booking_id != null) await deleteBooking(staffApi, booking_id); await releaseAsset(staffApi, 'desk', desk.id, from, to); @@ -126,13 +116,10 @@ test.describe('desk booking times', () => { await useSettings(staffPage, LIMITED_HOURS); - const form = new DeskForm(staffPage); + const form = new DeskFlow(staffPage); await form.open(); - await expect(async () => { - await form.setChecked(form.requireLocker, false); - await form.setChecked(form.allDay, false); - await expect(form.allDay).not.toBeChecked({ timeout: 1_000 }); - }).toPass({ timeout: 45_000 }); + // The time and length fields show only while All Day is off. + await form.setAllDay(false); // Lengths: nothing beyond the cap may be on offer. Without the setting // the form offers up to eight hours, which is what makes this diff --git a/apps/workplace/e2e/local/home-availability.spec.ts b/apps/workplace/e2e/local/home-availability.spec.ts index 95013f93e64..05de0660e81 100644 --- a/apps/workplace/e2e/local/home-availability.spec.ts +++ b/apps/workplace/e2e/local/home-availability.spec.ts @@ -1,78 +1,62 @@ /** - * HOME-10 — the availability panel, and booking a room from it. + * HOME-10 — the "available now" panel lists free rooms by level. * - * The panel lists the building's bookable rooms with their live status, and its - * button is a second route into booking a room — one that skips the meeting form - * entirely: `explore-spaces.service.ts::bookSpace` loads the room into the event - * form and opens a booking modal. + * ## What changed with the new home page * - * What is asserted is that the panel offers the seeded rooms and that the button - * opens that modal carrying the room that was clicked. Deliberately NOT - * asserted: completing the booking. The modal's confirm goes through the same - * path as the meeting form, which is covered properly by the room specs, and in - * the default mode it reaches the calendar and 500s on this stack. + * The page before this one listed each free room with a button that opened a + * booking modal. `landing-available-now` lists LEVELS, each with the number of + * its free resources, and a level links to the map. There is no room row and + * no booking modal, so that half of the story is gone from the page. * - * ## Where the rooms on the panel come from + * ## Where the count of the Rooms tab comes from * - * The panel does not list every bookable room. It lists the ones a LIVE STATUS - * BINDING reports as free (`landing-state.service.ts`): + * It is not the number of bookable rooms. It is the number of rooms that a + * LIVE STATUS BINDING reports as free (`landing-state.service.ts`): * - * free_space_list = space_list().filter((_, i) => space_statuses()[i] === 'free') + * free_space_list = filtered spaces whose status is 'free' * * and a status comes from a websocket binding to the `Bookings` module on each * room's System. The stack runs core, and every room `room.seed.ts` creates * gets a `Place::Bookings` module polling a demo calendar in the same system * (`ensureRoomModules`), so a seeded room reports `free` as long as nothing is - * put in that calendar. A room on the panel is therefore the whole chain + * put in that calendar. A level on the Rooms tab is therefore the whole chain * working: driver process, redis, the rest-api websocket, the app. Room * check-in (ROOM-23) binds the same module. + * + * The count is shared by all workers, so the test asserts that the tab lists + * a level, not an exact number. */ import { test, expect } from '../../../../e2e/support/fixtures'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; import { roomForWorker } from '../../../../e2e/support/room/room.seed'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; test.describe('home page — room availability', () => { - test('the panel lists a seeded room, and its button opens the booking modal', async ({ + test('the Rooms tab lists a level that has a free seeded room', async ({ staffPage, }, testInfo) => { - // The rooms the room specs seed are the only ones this org has. - const room = await roomForWorker(testInfo.parallelIndex); + // Seeds the rooms and their `Bookings` modules if they are not there. + await roomForWorker(testInfo.parallelIndex); - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); + await home.availableTab('rooms').click(); - const button = home.bookRoom(room.name); - const listed = await button - .waitFor({ state: 'visible', timeout: 30_000 }) - .then(() => true) - .catch(() => false); - if (!listed) { - // Say what IS offered: "not listed" cannot tell apart a room that - // was never seeded, one on the wrong zone, and a panel that is - // hidden for this deployment. - const offered = await home.offeredRooms(); - const panel = await home.availability.count(); - throw new Error( - `${room.name} is not on the availability panel. The panel ` + - `${panel ? 'is present and offers' : 'is not on the page at all;'} ` + - `${offered.length} room(s): ${JSON.stringify(offered)}. Rooms come ` + - `from the building's systems, so check room.seed.ts ran and the ` + - `room sits on the building zone.`, - ); - } - - await button.click(); - - // The modal is the assertion: it carries the room that was clicked, so a - // panel that opened the wrong room's booking would fail here. - const modal = staffPage.locator('.cdk-overlay-container mat-dialog-container'); + // The panel lists a level only when it has a free room, so a row is + // the proof that a room reported `free`. + // Each row has the icon of its tab, which keeps a row of the Desks + // tab from passing while the panel changes tab. + const level = home.availableLevels + .filter({ has: staffPage.locator('icon', { hasText: 'meeting_room' }) }) + .first(); await expect( - modal, - 'clicking Book should open the room booking modal', - ).toBeVisible({ timeout: 20_000 }); + level, + 'the Rooms tab lists no level. A level is listed when a room on it reports ' + + '`free` through its `Bookings` module, so check the drivers loaded ' + + '(the seed logs a warning when they did not)', + ).toBeVisible({ timeout: 30_000 }); await expect( - modal, - `the modal should be for ${room.name} — the room that was clicked`, - ).toContainText(room.name, { timeout: 20_000 }); + level.locator('[count]'), + 'the level should show how many of its rooms are free', + ).toContainText(/[1-9]/); }); }); diff --git a/apps/workplace/e2e/local/home-colleagues.spec.ts b/apps/workplace/e2e/local/home-colleagues.spec.ts index 3fd22f63a8e..ab8b3e4f7fa 100644 --- a/apps/workplace/e2e/local/home-colleagues.spec.ts +++ b/apps/workplace/e2e/local/home-colleagues.spec.ts @@ -1,5 +1,5 @@ /** - * HOME-11 — the colleagues list in the home page sidebar. + * HOME-11 — the colleagues list on the home page. * * A colleague list is stored per user, in its own `contacts` metadata document * (`landing-state.service.ts`: `updateMetadata(user.id, { name: 'contacts' })`). @@ -19,8 +19,12 @@ * list is the default path and this needs no settings at all. Contrast DESK-14, * where the host picker goes to the calendar directory and 500s here. * - * Cleared either side through a read-modify-write: the settings blob is shared - * with the visitor specs' invitee list and every area's favourites. + * ## Add is a modal, remove is a row menu + * + * "Add Colleagues" opens `add-colleagues-modal`: search, select, add. Each row + * of the list has a menu, and its `person_remove` action removes the row. + * + * The `contacts` document is this worker's own, so it is written whole. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { WORKERS, staffEmail } from '../../../../e2e/support/env'; @@ -29,10 +33,10 @@ import { readContacts, setContacts, } from '../../../../e2e/support/home/home.api'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; test.describe('home page — colleagues', () => { - test('a colleague added from the sidebar is saved against the user', async ({ + test('a colleague added from the home page is saved against the user', async ({ staffPage, staffApi, }, testInfo) => { @@ -41,16 +45,18 @@ test.describe('home page — colleagues', () => { test.skip(theirs === mine, 'needs at least two workers to have somebody to add'); const colleague = staffEmail(theirs); - // Start from none: a colleague left by an earlier run would let the - // assertion pass without this test having done anything. await setContacts(staffApi, []); try { - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); - await home.showTab('people'); await home.addColleague(colleague); + await expect( + home.colleague(colleague), + 'the colleague should be a row of the list after the add', + ).toBeVisible(); + // Saved, not merely rendered: the list updates optimistically. await expect(async () => { const saved = JSON.stringify(await readContacts(staffApi)); expect( @@ -73,42 +79,22 @@ test.describe('home page — colleagues', () => { test.skip(theirs === mine, 'needs at least two workers to have somebody to remove'); const colleague = staffEmail(theirs); - // Seeded through the API rather than through the UI: this test is about - // REMOVING, and adding through the form is the other test's subject. await setContacts(staffApi, [{ email: colleague, name: colleague.split('@')[0] }]); try { - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); - await home.showTab('people'); - - // The row shows the person's NAME, not their address, so the lookup - // normalises both sides — see `colleagueIndex`. await expect( - home.colleagueRows, - 'the seeded colleague should be listed in the sidebar', - ).toHaveCount(1, { timeout: 20_000 }); - const index = await home.colleagueIndex(colleague); - if (index < 0) { - const shown = await home.colleagues.innerText().catch(() => ''); - throw new Error( - `${colleague} was saved but cannot be found in the sidebar. The ` + - `panel shows: "${shown.replace(/\s+/g, ' ').trim().slice(0, 200)}". ` + - `Rows are labelled with the display name, so check the user ` + - `exists in the directory.`, - ); - } + home.colleague(colleague), + 'the seeded colleague should be listed on the home page', + ).toBeVisible({ timeout: 20_000 }); - // The remove control is behind the row's overflow menu. - await home.colleagueRows.nth(index).click({ timeout: 10_000 }); - const remove = staffPage - .locator('.cdk-overlay-container button[name="remove-colleague"]') - .first(); - await expect(remove, 'the colleague menu has no remove action').toBeVisible({ - timeout: 10_000, - }); + const remove = await home.colleagueAction(colleague, 'person_remove'); await remove.click(); + // The remove asks for confirmation first. + await home.acceptConfirm(); + await expect(home.colleague(colleague)).toHaveCount(0); await expect(async () => { const saved = JSON.stringify(await readContacts(staffApi)); expect( diff --git a/apps/workplace/e2e/local/home-errors.spec.ts b/apps/workplace/e2e/local/home-errors.spec.ts index 400c90b2a39..55079c3afcc 100644 --- a/apps/workplace/e2e/local/home-errors.spec.ts +++ b/apps/workplace/e2e/local/home-errors.spec.ts @@ -20,7 +20,7 @@ * production exactly as it does here. */ import { expect, test } from '../../../../e2e/support/fixtures'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; test.describe('home page — loading cleanly', () => { test('the home page loads with no uncaught exception', async ({ @@ -34,7 +34,7 @@ test.describe('home page — loading cleanly', () => { server_errors.push(`${r.status()} ${new URL(r.url()).pathname}`); }); - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); // Long enough for the panels to finish their own requests: an exception // raised by a late response is still an exception. diff --git a/apps/workplace/e2e/local/home-favourites.spec.ts b/apps/workplace/e2e/local/home-favourites.spec.ts index bd3f7530f58..1e7c98958e1 100644 --- a/apps/workplace/e2e/local/home-favourites.spec.ts +++ b/apps/workplace/e2e/local/home-favourites.spec.ts @@ -1,5 +1,5 @@ /** - * HOME-13 / HOME-14 — the Favourites tab in the home page sidebar. + * HOME-13 / HOME-14 — the Favourites section of the home page. * * Favourites are set in the pickers (covered for rooms, desks and parking in * their own specs) and READ here. That split is the point: the home page is @@ -13,8 +13,8 @@ * * ## The key * - * `landing-favourites.component.ts` reads `FAV_DESK_KEY` from `@placeos/bookings` - * — `favourite_desks`, inside the user's `settings` blob. The same key the desk + * `landing-favourites-new.component.ts` reads `SETTING_KEYS.FAVORITE_DESKS` — + * `favourite_desks`, inside the user's `settings` blob. The same key the desk * picker writes, which is why DESK-16 and this test agree. * * Cleared either side through a read-modify-write: that blob is shared with the @@ -22,40 +22,36 @@ */ import { test, expect } from '../../../../e2e/support/fixtures'; import { deskFor } from '../../../../e2e/support/env'; +import { uniqueTitle } from '../../../../e2e/support/api'; import { FAVOURITE_DESKS_KEY, readUserSettings, setUserSetting, } from '../../../../e2e/support/home/home.api'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; import { roomForWorker } from '../../../../e2e/support/room/room.seed'; import { + FAVOURITE_ROOMS_KEY, readUserSettings as readRoomUserSettings, setFavouriteSpaces, } from '../../../../e2e/support/room/room.api'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; test.describe('home page — favourites', () => { - test('a favourite desk is listed on the Favourites tab', async ({ + test('a favourite desk is listed in the Favourites section', async ({ staffPage, staffApi, }, testInfo) => { const desk = deskFor(testInfo.parallelIndex); - await setUserSetting(staffApi, FAVOURITE_DESKS_KEY, [desk.id]); try { - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); - await home.showTab('fav'); - - await expect( - home.favourites, - 'the Favourites panel should be on the page', - ).toBeVisible({ timeout: 30_000 }); await expect( home.favourites, - `${desk.name} was saved as a favourite desk, so the panel should list ` + + `${desk.name} was saved as a favourite desk, so the section should list ` + `it — a favourite that never shows up here benefits nobody`, ).toContainText(desk.name, { timeout: 30_000 }); } finally { @@ -68,34 +64,20 @@ test.describe('home page — favourites', () => { staffApi, }, testInfo) => { const desk = deskFor(testInfo.parallelIndex); - await setUserSetting(staffApi, FAVOURITE_DESKS_KEY, [desk.id]); try { - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); - await home.showTab('fav'); await expect( - home.favourites, + home.favourite(desk.name), 'precondition: the favourite is listed before it is removed', - ).toContainText(desk.name, { timeout: 30_000 }); + ).toBeVisible({ timeout: 30_000 }); - // The remove control is behind the row's overflow menu. - await home.favourites - .locator('button[name="favourite-more"]') - .first() - .click({ timeout: 10_000 }); - const remove = staffPage - .locator('.cdk-overlay-container button[name="landing-remove-favourite"]') - .first(); - await expect( - remove, - 'the favourite menu has no remove action', - ).toBeVisible({ timeout: 10_000 }); - await remove.click(); + await home.removeFavourite(desk.name).click({ timeout: 10_000 }); - // The backend is the assertion: the row leaving the panel proves - // nothing, and the write is debounced. + await expect(home.favourite(desk.name)).toHaveCount(0); + // The save to the user settings is delayed, so poll the backend. await expect(async () => { const settings = await readUserSettings(staffApi); expect( @@ -114,60 +96,48 @@ test.describe('home page — favourites', () => { }, testInfo) => { const room = await roomForWorker(testInfo.parallelIndex); const previous_settings = await readRoomUserSettings(staffApi); - const previous_favourites = Array.isArray(previous_settings.favourite_spaces) - ? previous_settings.favourite_spaces - : []; + const saved: unknown = previous_settings[FAVOURITE_ROOMS_KEY]; + const previous_favourites: string[] = Array.isArray(saved) ? saved : []; + // The room list reads free rooms from the bookings in this mode. The + // default mode asks the calendar, which this stack does not have. + await useSettings(staffPage, ROOM_BASE_SETTINGS); try { await setFavouriteSpaces(staffApi, [ ...new Set([...previous_favourites, room.id]), ]); - await expect(async () => { const settings = await readRoomUserSettings(staffApi); expect( - settings.favourite_spaces ?? [], - `${room.id} should be saved in favourite_spaces before Home loads`, + settings[FAVOURITE_ROOMS_KEY] ?? [], + `${room.id} should be saved in ${FAVOURITE_ROOMS_KEY} before Home loads`, ).toContain(room.id); }).toPass({ timeout: 15_000 }); - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); + const flow = new MeetingFlow(staffPage); await home.open(); - await home.showTab('fav'); - await expect( - home.favourites, - 'the Favourites panel should be on the page', - ).toBeVisible({ timeout: 30_000 }); - const room_row = home.favourites.locator('[item]').filter({ - hasText: room.name, - }); - await expect( - room_row, + home.favourite(room.name), `${room.name} was saved as a favourite room, so Home should list it`, - ).toHaveCount(1, { timeout: 30_000 }); - - await room_row.locator('button[name="book-favourite"]').click(); - + ).toBeVisible({ timeout: 30_000 }); + await home.bookFavourite(room.name).click(); await expect( staffPage, 'booking a favourite room should open the room meeting flow', - ).toHaveURL(/#\/book\/(meeting|spaces)(\/form)?/, { timeout: 30_000 }); - const form = new MeetingForm(staffPage); - await expect( - form.root, - 'the room meeting form should render after selecting a favourite', - ).toBeVisible({ timeout: 30_000 }); - await expect( - form.chosenSpaces, - 'the meeting form should already contain the selected favourite room', - ).toHaveCount(1, { timeout: 30_000 }); - await expect(async () => { - expect( - await form.chosenRoomNames(), - 'the form should contain the room that was clicked on Home', - ).toContain(room.name); - }).toPass({ timeout: 30_000 }); + ).toHaveURL(/#\/book\/meeting(\/form)?/, { timeout: 30_000 }); + + // The room is put on the form 300 ms after the route change. The + // last step names the room that the form holds, and the room step + // lets nobody through without a room. A title is the requirement + // of the first step. + await flow.title.fill(uniqueTitle('E2E Home Favourite Room')); + await flow.toRoomStep(); + await flow.toOptionsStep(); + expect( + await flow.chosenRoomNames(), + 'the meeting flow should hold the favourite room that was clicked on Home', + ).toEqual([room.name]); } finally { await setFavouriteSpaces(staffApi, previous_favourites); } diff --git a/apps/workplace/e2e/local/home-meeting-with.spec.ts b/apps/workplace/e2e/local/home-meeting-with.spec.ts index c409364fff3..6a03d24fca8 100644 --- a/apps/workplace/e2e/local/home-meeting-with.spec.ts +++ b/apps/workplace/e2e/local/home-meeting-with.spec.ts @@ -7,15 +7,20 @@ * about — and the form looks perfectly normal either way. * * What is asserted is the handoff: the right person arrives on the form as an - * attendee. Deliberately NOT asserted: sending the meeting. What happens to + * attendee. The attendees are on the LAST step of the meeting flow, so the + * test goes through the first two steps to see them. Deliberately NOT asserted: sending the meeting. What happens to * attendees once a meeting is booked is ROOM-09's job, and the host handling on * that form is a known defect (ROOM-B8), so carrying this through to a booking * would fail for a reason that has nothing to do with this shortcut. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { WORKERS, staffEmail } from '../../../../e2e/support/env'; -import { readContacts, setContacts } from '../../../../e2e/support/home/home.api'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { uniqueTitle } from '../../../../e2e/support/api'; +import { setContacts } from '../../../../e2e/support/home/home.api'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; test.describe('home page — meeting with a colleague', () => { test('the shortcut opens the meeting form with that colleague invited', async ({ @@ -26,49 +31,53 @@ test.describe('home page — meeting with a colleague', () => { const theirs = (mine + 1) % WORKERS; test.skip(theirs === mine, 'needs at least two workers to have a colleague'); const colleague = staffEmail(theirs); + const room = await roomForWorker(mine); + const title = uniqueTitle('E2E Home Meeting With'); - // Seeded through the API: adding a colleague through the UI is - // HOME-11's subject, and this test starts from having one. - await setContacts(staffApi, [ - { email: colleague, name: colleague.split('@')[0] }, - ]); + await setContacts(staffApi, [{ email: colleague, name: colleague.split('@')[0] }]); + // The room list reads free rooms from the bookings in this mode. The + // default mode asks the calendar, which this stack does not have. + await useSettings(staffPage, ROOM_BASE_SETTINGS); try { - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); + const flow = new MeetingFlow(staffPage); await home.open(); - await home.showTab('people'); await expect( - home.colleagueRows, + home.colleague(colleague), 'the seeded colleague should be listed before the shortcut is used', - ).toHaveCount(1, { timeout: 20_000 }); + ).toBeVisible({ timeout: 20_000 }); - const index = await home.colleagueIndex(colleague); - expect(index, `${colleague} is not findable in the sidebar`).toBeGreaterThanOrEqual(0); - await home.colleagueRows.nth(index).click({ timeout: 10_000 }); - - const action = staffPage - .locator('.cdk-overlay-container button[name="meeting-with-colleague"]') - .first(); - await expect( - action, - 'the colleague menu has no "meeting with" action', - ).toBeVisible({ timeout: 10_000 }); + const action = await home.colleagueAction(colleague, 'event'); await action.click(); - await expect( staffPage, 'the shortcut should open the meeting form', - ).toHaveURL(/#\/book\/(meeting|spaces)/, { timeout: 30_000 }); + ).toHaveURL(/#\/book\/meeting\/form/, { timeout: 30_000 }); - const form = staffPage.locator('meeting-flow-form'); - await expect(form, 'the meeting form never rendered').toBeVisible({ - timeout: 30_000, - }); + // The shortcut puts the colleague on the form 300 ms after the + // route change, and that write makes a new form. A title typed + // before it is lost, so the first step is tried until it holds. + // A user who types in that window loses the title the same way: + // this is REG-10 on the meeting flow, and a second try goes in + // the report. + let tries = 0; + await expect(async () => { + tries += 1; + await flow.title.fill(title); + await flow.searchRooms.click(); + await expect(staffPage).toHaveURL(/view=1/, { timeout: 2_000 }); + }).toPass({ timeout: 30_000 }); + if (tries > 1) { + testInfo.annotations.push({ + type: 'REG-10', + description: `the meeting flow needed ${tries} tries to keep the title and reach the room step`, + }); + } + await flow.selectRoom(room.name); + await flow.continue.click(); - // The chip shows the person's NAME or the part before the @, never - // the whole address — the same labelling the attendee list uses - // everywhere else in this app. - const chips = staffPage.locator('a-user-list-field mat-chip-row[user]'); + const chips = flow.options.locator('a-user-list-field mat-chip-row[user]'); await expect( chips, `the meeting form should open with ${colleague} already invited — an ` + diff --git a/apps/workplace/e2e/local/home-quick-book.spec.ts b/apps/workplace/e2e/local/home-quick-book.spec.ts index 4fed53976b7..54f28c69914 100644 --- a/apps/workplace/e2e/local/home-quick-book.spec.ts +++ b/apps/workplace/e2e/local/home-quick-book.spec.ts @@ -1,112 +1,103 @@ /** - * HOME-09 — the quick-book tile with confirmation. + * HOME-09 — the "auto-assign desk" quick action. * * ## It does not open the form * - * `landing-quick-book.component.ts::book()` picks the first available resource, - * then `confirmPost()` opens the shared confirmation modal. Accepting creates - * the booking and navigates to `/book//success`. + * The action opens `auto-assigned-desk-modal`, which finds a free desk for + * today and shows it. The confirm control of the modal creates the booking + * and the page goes to `/book/desk/success`. On the page before this one the + * same story was a quick-book tile and a confirmation dialog. * * So this asserts on what reached the BACKEND, not on the success screen. * * ## Why this test cleans up immediately, and its one caveat * - * The tile books "the first available desk for this building", which is whatever - * the app finds — possibly a desk another worker owns. It cannot be pointed at a - * particular desk, so the booking is deleted as soon as it has been asserted. - * Worth knowing if this ever flakes next to another desk spec: a brief 409 in a + * The action books whatever free desk the app finds — possibly a desk another + * worker owns — for the whole of today. It cannot be pointed at a particular + * desk, so the booking is deleted as soon as it has been asserted. Worth + * knowing if this ever flakes next to another desk spec: a brief 409 in a * neighbouring test is this test holding a desk for a second or two. * + * The same race runs the other way. Another worker can take the offered desk + * between the offer and the confirm, and the backend then answers 409. That + * refusal is correct, so the test asks for another desk, up to three times. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { STAFF_API, deleteBooking } from '../../../../e2e/support/api'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; -import { - SHOW_QUICK_BOOK, - useSettings, -} from '../../../../e2e/support/home/home.settings'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; +import { SHOW_QUICK_BOOK, useSettings } from '../../../../e2e/support/home/home.settings'; const DAY = 86_400; +/** How many desks the test asks for before it gives up. See the header. */ +const MAX_ATTEMPTS = 3; + test.describe('home page — quick book', () => { - test('the desk tile books a desk after confirmation, and the backend stores it', async ({ + test('auto-assign books a desk after confirmation, and the backend stores it', async ({ staffPage, staffApi, }) => { - // The panel has no default: without this setting the tiles are not - // rendered at all, and the failure reads as a missing selector. await useSettings(staffPage, SHOW_QUICK_BOOK); - - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); - - const tile = home.quickBookTile('desk'); - const present = await tile - .waitFor({ state: 'visible', timeout: 20_000 }) - .then(() => true) - .catch(() => false); - test.skip( - !present, - 'no desk quick-book tile even with `app.show_quick_book` on — the panel is ' + - 'also gated on the `desks` feature being enabled for the org', - ); + await expect( + home.autoAssignDesk, + 'the quick action is shown when the `desks` feature is enabled for the org', + ).toBeVisible({ timeout: 20_000 }); let booking_id: number | undefined; try { - await tile.click(); - const confirmation = staffPage.locator('confirm-modal'); - await expect(confirmation).toBeVisible(); - const accept = confirmation.locator('button[name="accept"]'); - await expect(accept).toBeVisible(); + let created: { id: number; booking_type: string; asset_id: string; booking_start: number } | undefined; + let last = ''; + for (let attempt = 1; attempt <= MAX_ATTEMPTS && !created; attempt++) { + await home.autoAssignDesk.click(); + // The confirm control is disabled while the modal looks for a desk. + await expect( + home.autoAssignConfirm, + 'the modal found no desk to offer. It offers a FREE desk, so every ' + + 'desk being held — by another spec, or by a leftover all-day ' + + 'booking — leaves it nothing to book', + ).toBeEnabled({ timeout: 30_000 }); - // The booking POST is the assertion. The success screen only tells - // us the app thinks it worked. - const sent = staffPage - .waitForResponse( - (r) => - r.url().includes(`${STAFF_API}/bookings`) && - r.request().method() === 'POST', - { timeout: 30_000 }, - ) - .catch(() => null); - await accept.click(); - const response = await sent; - if (!response) { - // The flow refuses locally when it can find no free resource, - // and says so in a toast: "No desk available for the current - // building". Reading it turns a bare timeout into the reason. - const message = await staffPage - .locator('.cdk-overlay-container') - .innerText() - .catch(() => ''); + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes(`${STAFF_API}/bookings`) && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + home.autoAssignConfirm.click(), + ]); + const body = await response.text(); + if (response.status() === 409) { + last = body.slice(0, 300); + console.warn(` ! the offered desk was taken (attempt ${attempt}/${MAX_ATTEMPTS})`); + if (await home.autoAssignModal.isVisible()) await home.autoAssignCancel.click(); + await expect(home.autoAssignModal).toHaveCount(0); + continue; + } + expect( + response.status(), + `the auto-assign booking failed: ${body.slice(0, 300)}`, + ).toBeLessThan(300); + created = JSON.parse(body); + } + if (!created) { throw new Error( - `the confirmation was accepted and no booking was sent. On screen: ` + - `"${message.replace(/\s+/g, ' ').trim().slice(0, 300)}". The tile ` + - `books the first AVAILABLE desk, so every desk being held — by ` + - `another spec, or by a leftover all-day booking — leaves it ` + - `nothing to book.`, + `every desk that auto-assign offered was taken before the confirm, ` + + `${MAX_ATTEMPTS} times. Last refusal: ${last}`, ); } - const body = await response.text(); - expect( - response.status(), - `quick-book booking failed: ${body.slice(0, 300)}`, - ).toBeLessThan(300); - - const created = JSON.parse(body); booking_id = created.id; + expect( created.booking_type, - 'the desk tile must book a DESK, whatever resource it picked', + 'the action must book a DESK, whatever resource it picked', ).toBe('desk'); - expect( - created.asset_id, - 'and it must name the desk it booked', - ).toBeTruthy(); + expect(created.asset_id, 'and it must name the desk it booked').toBeTruthy(); expect( Number(created.booking_start), - 'the booking should be for now, not for some default in the past — a ' + - 'booking that has already started cannot be checked into', + 'the booking should be for today, not for some default in the past', ).toBeGreaterThan(Math.floor(Date.now() / 1000) - DAY); await expect( @@ -114,8 +105,6 @@ test.describe('home page — quick book', () => { 'and the page should land on the success screen', ).toHaveURL(/#\/book\/desk\/success/, { timeout: 30_000 }); } finally { - // Immediately, whatever happened: this booking may be holding a desk - // another worker's spec is about to use. if (booking_id != null) await deleteBooking(staffApi, booking_id); } }); diff --git a/apps/workplace/e2e/local/home-scoping.spec.ts b/apps/workplace/e2e/local/home-scoping.spec.ts index 4fd652e1a7f..e59b0728371 100644 --- a/apps/workplace/e2e/local/home-scoping.spec.ts +++ b/apps/workplace/e2e/local/home-scoping.spec.ts @@ -6,111 +6,99 @@ * the third surface, and the one a leak would be most visible on: the home page * is what appears the moment anyone signs in. * - * The panel is built from the schedule's booking list filtered to today - * (`landing-state.service.ts`), so a scope failure anywhere in that chain shows - * up here. + * ## The other user is the ADMIN, not another worker * - * The control is not optional: a panel that showed nothing at all would pass the - * first test on its own. + * The next-booking card shows the first booking a user has today. A booking + * made today for the non-admin of another worker would show on THAT worker's + * card while its own home specs run. The admin has no spec that reads the + * card, so the admin is the "somebody else" here. * - * Both tests book THIS worker's scoping desk (`scopingDeskFor`), as either - * user; no other spec may book it. + * ## The control is in the same test + * + * The card shows one booking, the earliest of today. The admin's booking + * starts BEFORE this user's own booking. A leak would put the admin's booking + * on the card. The correct card shows this user's own booking, which also + * proves that the card loaded. + * + * The admin's booking is on THIS worker's scoping desk (`scopingDeskFor`); no + * other spec may book it. */ import { test, expect } from '../../../../e2e/support/fixtures'; -import { request } from '@playwright/test'; -import { deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { apiFor, deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { deskFor, scopingDeskFor } from '../../../../e2e/support/env'; import { - APP_URL, - BACKEND_URL, - WORKERS, - roleFor, - scopingDeskFor, -} from '../../../../e2e/support/env'; -import { mintToken } from '../../../../e2e/support/auth'; -import { createBookingViaApi } from '../../../../e2e/support/home/home.api'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; + clearToday, + createBookingViaApi, + inProgressSlot, + stillToday, + todayBounds, +} from '../../../../e2e/support/home/home.api'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; const MINUTE = 60; -async function apiAsOtherUser(workerIndex: number) { - const role = roleFor('staff', workerIndex); - const mint = await mintToken(BACKEND_URL, APP_URL, role.email, role.password); - return request.newContext({ - baseURL: BACKEND_URL, - ignoreHTTPSErrors: true, - extraHTTPHeaders: { Authorization: `Bearer ${mint.accessToken}` }, - }); -} - -/** Still today, locally? The panel shows today and nothing else. */ -function stillToday(unix_seconds: number): boolean { - return new Date(unix_seconds * 1000).toDateString() === new Date().toDateString(); -} - test.describe('home page — visibility between users', () => { - test("another user's booking today is not on your panel", async ({ + test("another user's booking today is not on your card", async ({ staffPage, staffApi, }, testInfo) => { - const mine = testInfo.parallelIndex; - const theirs = (mine + 1) % WORKERS; - test.skip(theirs === mine, 'needs at least two workers to have two distinct users'); + const scoping_desk = scopingDeskFor(testInfo.parallelIndex); + const own_desk = deskFor(testInfo.parallelIndex); + const theirs_title = uniqueTitle('E2E Home Theirs'); + const mine_title = uniqueTitle('E2E Home Mine First'); + const { from, to } = todayBounds(); + // Theirs starts first, so a card that leaked would show theirs. + const theirs = inProgressSlot(); + const mine = { start: theirs.start + MINUTE, end: theirs.end }; - const desk = scopingDeskFor(mine); - const start = Math.floor(Date.now() / 1000) + (60 + 20 * mine) * MINUTE; - test.skip(!stillToday(start), 'this window has crossed midnight'); - const other = await apiAsOtherUser(theirs); + const admin = await apiFor('admin', testInfo.parallelIndex); let their_booking: number | undefined; + let my_booking: number | undefined; - // Their booking on this worker's scoping desk, swept by THEM: `GET - // /bookings` is caller-scoped, so this worker cannot see or clear it. - await releaseAsset(other, 'desk', desk.id, start - 60, start + 3600); + // Their booking is swept by THEM: `GET /bookings` is caller-scoped, so + // this worker cannot see or clear it. + await releaseAsset(admin, 'desk', scoping_desk.id, from, to); + await clearToday(staffApi); try { - const booking = await createBookingViaApi(other, { - type: 'desk', - asset_id: desk.id, - asset_name: desk.name, - title: uniqueTitle('E2E Home Theirs'), - start, - end: start + 15 * MINUTE, - }); - their_booking = booking.id; + their_booking = ( + await createBookingViaApi(admin, { + type: 'desk', + asset_id: scoping_desk.id, + asset_name: scoping_desk.name, + title: theirs_title, + ...theirs, + }) + ).id; + my_booking = ( + await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: own_desk.id, + asset_name: own_desk.name, + title: mine_title, + ...mine, + }) + ).id; - const home = new LandingPage(staffPage); + const home = new LandingNewPage(staffPage); await home.open(); await expect( - home.upcomingCard(their_booking), - `booking ${their_booking} belongs to another user and must not appear ` + - `on this user's home page`, - ).toBeHidden({ timeout: 20_000 }); - - const showing = await home.upcomingIds(); - expect( - showing, - `nothing of theirs should be listed. The panel is showing ` + - `[${showing.join(', ')}]`, - ).not.toContain(their_booking); + home.upcomingTitle, + `the card must show this user's own booking. An earlier booking of ` + + `another user (${their_booking}) must not take its place`, + ).toHaveText(mine_title, { timeout: 30_000 }); + await expect(home.upcoming).not.toContainText(theirs_title); } finally { - if (their_booking != null) await deleteBooking(other, their_booking); - await releaseAsset(other, 'desk', desk.id, start - 60, start + 3600); - await other.dispose(); + if (their_booking != null) await deleteBooking(admin, their_booking); + if (my_booking != null) await deleteBooking(staffApi, my_booking); + await admin.dispose(); } }); /** - * The control, and why it does not use the panel. - * - * A control is essential here: without one, "nobody sees anything" passes - * the test above. The obvious control — your own booking IS on the panel — - * cannot be relied on this stack, because the panel holds five cards and - * cancelled bookings take slots (HOME-B1). Measured: the panel was showing - * five cancelled bookings and could not show a live one. - * - * So the control proves the same thing on a surface that is not capped: - * the booking is yours, it exists, and YOUR pages show it. Your Bookings - * lists everything for a day, which is exactly the property needed. + * A second control, on a surface that is not limited to one booking: the + * booking is yours, it exists, and Your Bookings lists it. */ test('control: your own booking today is visible to you', async ({ staffPage, diff --git a/apps/workplace/e2e/local/home-upcoming.spec.ts b/apps/workplace/e2e/local/home-upcoming.spec.ts index 75d8b0bdb74..1628962b432 100644 --- a/apps/workplace/e2e/local/home-upcoming.spec.ts +++ b/apps/workplace/e2e/local/home-upcoming.spec.ts @@ -1,140 +1,128 @@ /** - * HOME-04 / HOME-05 / HOME-08 — the Upcoming panel on the home page. + * HOME-04 / HOME-05 / HOME-08 — the next-booking card on the home page. * - * This panel is the first thing a user sees after signing in, and the only place - * they check "have I got a desk today?". Nothing has tested it: `boot.spec.ts` - * proves the shell renders and org data resolves, which says nothing about - * whether the panel shows the right bookings — or any. + * This card is the first thing a user sees after signing in, and the only + * place they check "have I got a desk today?". * - * ## How the panel actually works, which is not what its name suggests + * ## How the card works * - * "Upcoming" means **today**, and nothing else. `landing-state.service.ts` - * builds it from the SCHEDULE's own booking list: + * The card shows ONE booking: the first booking of TODAY that is not done, + * cancelled or declined (`landing-state.service.ts`, `upcoming_events`, and + * `landing-upcoming-booking.component.ts`, `nextEvent`). Three consequences + * shaped these tests: * - * filtered_bookings().filter(i => i.state !== 'done' && isSameDay(i.date, now)) + * - a booking tomorrow is not on it; + * - a booking that has finished is not on it; + * - a second booking today is not on it until the first one ends. So each + * test starts from an empty day (`clearToday`). * - * then the template shows the first FIVE (`events | slice: 0 : 5`). Three - * consequences, all of which shaped these tests: + * The page before this one listed five bookings and kept cancelled ones, which + * was HOME-B1. This card filters deleted bookings out, and the last test + * holds that. * - * - a booking tomorrow is not on it. A first draft of this file booked sixteen - * days out and spent three failures discovering that; - * - a booking that has finished is not on it either; - * - it inherits everything the schedule's query does — including - * `include_deleted: true`. See HOME-B1 on the third test. + * ## The clear state is not proof of an empty day + * + * The clear state is what the card shows BEFORE its bookings load. A test + * that expects "no booking" after a page load must hold a control: a second + * booking that the card can only show when it has loaded. */ -import { - deleteBooking, - releaseAsset, - uniqueTitle, -} from '../../../../e2e/support/api'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; import { SchedulePage } from '../../../../e2e/support/bookings/schedule.page'; -import { WORKERS, deskFor } from '../../../../e2e/support/env'; +import { deskFor, scopingDeskFor } from '../../../../e2e/support/env'; import { expect, test } from '../../../../e2e/support/fixtures'; -import { createBookingViaApi } from '../../../../e2e/support/home/home.api'; -import { LandingPage } from '../../../../e2e/support/home/landing.page'; +import { + clearToday, + createBookingViaApi, + inProgressSlot, + stillToday, +} from '../../../../e2e/support/home/home.api'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; const MINUTE = 60; -/** - * A short window later TODAY, on the desk no worker owns. - * - * The panel only shows today, which puts these specs on the same day as the - * dev's desk specs (all day, own desk), `bookings-checkin` (an hour from now, - * own desk) and `desk-status` (minutes from now, spare desk). So: the SPARE - * desk, and a start well clear of `desk-status`, staggered twenty minutes per - * worker. - */ -function laterToday(workerIndex: number) { - const start = - Math.floor(Date.now() / 1000) + (90 + 20 * workerIndex) * MINUTE; - return { start, end: start + 15 * MINUTE }; -} +test.describe('home page — the next-booking card', () => { + test('the card changes from the clear state to the booking of today', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Home Upcoming'); + const home = new LandingNewPage(staffPage); + let booking_id: number | undefined; + + await clearToday(staffApi); + + try { + await home.open(); + await expect( + home.clearStateDeskLink, + 'with no booking today the card offers to make one', + ).toBeVisible(); + await expect(home.upcomingEdit).toHaveCount(0); -/** Is that window still today, locally? Late-evening runs cannot use it. */ -function stillToday(unix_seconds: number): boolean { - return ( - new Date(unix_seconds * 1000).toDateString() === - new Date().toDateString() - ); -} + const booking = await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title, + ...inProgressSlot(), + }); + booking_id = booking.id; + + await staffPage.reload(); + await expect( + home.upcomingTitle, + 'the card should show the booking the user has today', + ).toHaveText(title, { timeout: 30_000 }); + await expect(home.clearStateDeskLink).toHaveCount(0); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); -test.describe('home page — the Upcoming panel', () => { /** - * HOME-B1, first facet — a live booking can be crowded off the panel. - * - * This passes in isolation and FAILS under a full suite run, and the reason - * is the finding rather than the test. The panel holds five cards and - * cancelled bookings take slots, so after a busy run today's five slots are - * full of junk: measured at `[1162, 1530, 1642, 1575, 1576]`, every one - * cancelled, with a live booking for the same user on the same day absent. - * - * So this is `fixme` alongside the cancelled-booking test below. Between - * them they state both halves of HOME-B1: cancelled bookings never leave, - * and their presence can hide a real booking. Neither needs changing when - * the panel starts excluding deleted bookings — they will simply go green. + * The card has its own branch for a booking that has not started: it + * shows the time to the start. Every other home spec uses a booking that + * is in progress, so this is the one test that renders that branch. */ - test('a booking later today is listed on the panel', async ({ + test('a booking that starts later today is on the card', async ({ staffPage, staffApi, }, testInfo) => { - const desk = deskFor(WORKERS); - const slot = laterToday(testInfo.parallelIndex); + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Home Later'); + const start = Math.floor(Date.now() / 1000) + 30 * MINUTE; test.skip( - !stillToday(slot.start), - 'this window has crossed midnight, and the panel only shows today', + !stillToday(start), + 'this window has crossed midnight, and the card only shows today', ); + const home = new LandingNewPage(staffPage); let booking_id: number | undefined; - // Only this worker's own window: the spare desk is shared, so a wider - // sweep would delete another worker's booking mid-test. - await releaseAsset( - staffApi, - 'desk', - desk.id, - slot.start - 60, - slot.end + 60, - ); + await clearToday(staffApi); try { const booking = await createBookingViaApi(staffApi, { type: 'desk', asset_id: desk.id, asset_name: desk.name, - title: uniqueTitle('E2E Home Upcoming'), - start: slot.start, - end: slot.end, + title, + start, + end: start + 15 * MINUTE, }); booking_id = booking.id; - const home = new LandingPage(staffPage); await home.open(); - - const found = await home - .upcomingCard(booking_id) - .waitFor({ state: 'visible', timeout: 30_000 }) - .then(() => true) - .catch(() => false); - if (!found) { - // Say what the panel IS showing, with the reason it might be - // full: it holds five, and cancelled bookings count (HOME-B1). - const showing = await home.upcomingIds(); - throw new Error( - `booking ${booking_id} is not on the Upcoming panel. It is showing ` + - `${showing.length}: [${showing.join(', ')}]. The panel holds five ` + - `and CANCELLED bookings take slots too, so a user with five ` + - `cancelled bookings today cannot see a real one — that is HOME-B1, ` + - `covered by the third test in this file.`, - ); - } + await expect( + home.upcomingTitle, + 'the card should show a booking of today that has not started', + ).toHaveText(title, { timeout: 30_000 }); + await expect( + home.upcomingStartsIn, + 'a booking that has not started shows the time to its start', + ).toBeVisible(); } finally { if (booking_id != null) await deleteBooking(staffApi, booking_id); - await releaseAsset( - staffApi, - 'desk', - desk.id, - slot.start - 60, - slot.end + 60, - ); } }); @@ -142,52 +130,36 @@ test.describe('home page — the Upcoming panel', () => { staffPage, staffApi, }, testInfo) => { - const desk = deskFor(WORKERS); - // Well clear of the first test's window, still today. - const start = - Math.floor(Date.now() / 1000) + - (200 + 20 * testInfo.parallelIndex) * MINUTE; - const slot = { start, end: start + 15 * MINUTE }; - test.skip( - !stillToday(slot.start), - 'this window has crossed midnight, and the panel only shows today', - ); + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Home ViewAll'); + const slot = inProgressSlot(); + const home = new LandingNewPage(staffPage); let booking_id: number | undefined; - await releaseAsset( - staffApi, - 'desk', - desk.id, - slot.start - 60, - slot.end + 60, - ); + await clearToday(staffApi); try { const booking = await createBookingViaApi(staffApi, { type: 'desk', asset_id: desk.id, asset_name: desk.name, - title: uniqueTitle('E2E Home ViewAll'), - start: slot.start, - end: slot.end, + title, + ...slot, }); booking_id = booking.id; - const home = new LandingPage(staffPage); await home.open(); - await expect( - home.viewAll, - 'the Upcoming panel should offer a View all link', - ).toBeVisible({ timeout: 30_000 }); - await home.viewAll.click(); - + await expect(home.upcomingTitle).toHaveText(title, { timeout: 30_000 }); + await home.viewAllBookings.click(); await expect( staffPage, 'View all should land on the Your Bookings page', ).toHaveURL(/#\/your-bookings/, { timeout: 30_000 }); // The handoff is only useful if the booking is findable there. + // `open` is what puts the page on its list view. const schedule = new SchedulePage(staffPage); + await schedule.open(); await schedule.showBooking(booking_id, slot.start * 1000, ['desk']); await expect( schedule.card(booking_id), @@ -195,95 +167,74 @@ test.describe('home page — the Upcoming panel', () => { ).toBeVisible({ timeout: 30_000 }); } finally { if (booking_id != null) await deleteBooking(staffApi, booking_id); - await releaseAsset( - staffApi, - 'desk', - desk.id, - slot.start - 60, - slot.end + 60, - ); } }); /** - * HOME-B1 — the home page keeps showing bookings you cancelled. - * - * The panel is built from the schedule's booking list, and the schedule asks - * for bookings with `include_deleted: true` (`schedule-state.service.ts`), - * hiding only what the user cancelled in the CURRENT session. So a cancelled - * booking stays on the home page for ever. - * - * Measured on this stack: the panel was showing **five bookings, every one - * of them cancelled** — `deleted: true` on all five, confirmed by id — while - * a live booking for the same user on the same day was absent. Because the - * panel holds exactly five, the junk had crowded the real one out entirely. + * HOME-B1 — the page before this one kept showing bookings that the user + * had cancelled, and they could crowd a live booking out. The card must + * drop a cancelled booking. * - * Two separate harms, and the second is the worse one: + * The control is a second live booking that starts a minute after the + * cancelled one. The card shows the earliest booking of today, so it shows + * the second only when it has loaded AND has dropped the cancelled first. + * The clear state cannot prove this: see the file header. * - * 1. the page tells a user they have a desk today when they cancelled it; - * 2. it can HIDE a real booking, because cancelled ones take the slots. - * - * This is the same root cause as the 100-row listing problem in the project - * notes, surfacing on the first screen users see. `fixme` until the panel - * either excludes deleted bookings or the schedule stops asking for them. + * Red-checked on 2026-10-14. `upcoming_events` drops a cancelled booking + * twice: on `deleted`, and on the status `cancelled` that a deleted + * booking gets (`resolveStatus`). The test fails only with BOTH filters + * removed. With `!i.deleted` alone removed it passes, correctly. */ - test('a cancelled booking leaves the panel', async ({ - staffPage, - staffApi, - }, testInfo) => { - const desk = deskFor(WORKERS); - const start = - Math.floor(Date.now() / 1000) + - (300 + 20 * testInfo.parallelIndex) * MINUTE; - const slot = { start, end: start + 15 * MINUTE }; - test.skip(!stillToday(slot.start), 'this window has crossed midnight'); - let booking_id: number | undefined; - - await releaseAsset( - staffApi, - 'desk', - desk.id, - slot.start - 60, - slot.end + 60, - ); + test('a cancelled booking leaves the card', async ({ staffPage, staffApi }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const next_desk = scopingDeskFor(testInfo.parallelIndex); + const cancelled_title = uniqueTitle('E2E Home Cancelled'); + const next_title = uniqueTitle('E2E Home Next'); + const first = inProgressSlot(); + const second = { start: first.start + MINUTE, end: first.end }; + const home = new LandingNewPage(staffPage); + let cancelled_id: number | undefined; + let next_id: number | undefined; + + await clearToday(staffApi); try { - const booking = await createBookingViaApi(staffApi, { - type: 'desk', - asset_id: desk.id, - asset_name: desk.name, - title: uniqueTitle('E2E Home Cancelled'), - start: slot.start, - end: slot.end, - }); - booking_id = booking.id; + cancelled_id = ( + await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: desk.id, + asset_name: desk.name, + title: cancelled_title, + ...first, + }) + ).id; + next_id = ( + await createBookingViaApi(staffApi, { + type: 'desk', + asset_id: next_desk.id, + asset_name: next_desk.name, + title: next_title, + ...second, + }) + ).id; - const home = new LandingPage(staffPage); await home.open(); await expect( - home.upcomingCard(booking_id), - 'precondition: the booking is on the panel before it is cancelled', - ).toBeVisible({ timeout: 30_000 }); + home.upcomingTitle, + 'precondition: the earlier booking is on the card before it is cancelled', + ).toHaveText(cancelled_title, { timeout: 30_000 }); - await deleteBooking(staffApi, booking_id); + await deleteBooking(staffApi, cancelled_id); await staffPage.reload(); - await home.open(); - await expect( - home.upcomingCard(booking_id), + home.upcomingTitle, 'a cancelled booking must NOT stay on the home page — it tells the user ' + - 'they have a desk they no longer have, and it takes one of the five ' + - 'slots the panel has', - ).toBeHidden({ timeout: 30_000 }); + 'they have a desk they no longer have. The card must show the next ' + + 'live booking', + ).toHaveText(next_title, { timeout: 30_000 }); } finally { - if (booking_id != null) await deleteBooking(staffApi, booking_id); - await releaseAsset( - staffApi, - 'desk', - desk.id, - slot.start - 60, - slot.end + 60, - ); + if (cancelled_id != null) await deleteBooking(staffApi, cancelled_id); + if (next_id != null) await deleteBooking(staffApi, next_id); } }); }); diff --git a/apps/workplace/e2e/local/landing-new.spec.ts b/apps/workplace/e2e/local/landing-new.spec.ts new file mode 100644 index 00000000000..1d18611c170 --- /dev/null +++ b/apps/workplace/e2e/local/landing-new.spec.ts @@ -0,0 +1,163 @@ +/** + * NEW-HOME-02 … NEW-HOME-05 — the parts of the `landing-new` home page that + * the page before it did not have: edit and delete on the next-booking card, + * Book on a favourite desk, and the desk week. + * + * The stories that both pages have are in the `home-*.spec.ts` files. + * + * ## Isolation + * + * The next-booking card shows the FIRST booking the user has today, whatever + * its type. So each test clears every booking this worker's user has today + * before it starts (`clearToday`). + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deskFor } from '../../../../e2e/support/env'; +import { deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; +import { isLive } from '../../../../e2e/support/bookings/bookings.api'; +import { createDeskBookingViaApi, setFavouriteDesks } from '../../../../e2e/support/desk/desk.api'; +import { DeskFlow } from '../../../../e2e/support/desk/desk-flow.page'; +import { + clearToday, + inProgressSlot, + readUserSettings, + todayBounds, +} from '../../../../e2e/support/home/home.api'; +import { LandingNewPage } from '../../../../e2e/support/home/landing-new.page'; + +test.describe('home page — the new landing', () => { + test('NEW-HOME-02: Edit on the card opens the desk flow with the booking loaded', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Home Edit'); + const landing = new LandingNewPage(staffPage); + const flow = new DeskFlow(staffPage); + + await clearToday(staffApi); + const created = await createDeskBookingViaApi(staffApi, { + desk, + title, + ...inProgressSlot(), + }); + + try { + await landing.open(); + await expect(landing.upcomingTitle).toHaveText(title, { timeout: 30_000 }); + await landing.upcomingEdit.click(); + + await expect(staffPage).toHaveURL(/#\/book\/desk\/form/); + await expect(flow.title, 'the form holds the booking, not a new one').toHaveValue(title); + await expect( + flow.desk(desk.name).locator('icon', { hasText: 'task_alt' }), + 'the desk of the booking shows as selected', + ).toBeVisible({ timeout: 20_000 }); + } finally { + await deleteBooking(staffApi, created.id); + } + }); + + test('NEW-HOME-03: Delete on the card removes the booking on the backend', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Home Delete'); + const landing = new LandingNewPage(staffPage); + + await clearToday(staffApi); + const created = await createDeskBookingViaApi(staffApi, { + desk, + title, + ...inProgressSlot(), + }); + const { from, to } = todayBounds(); + const live = () => isLive(staffApi, 'desk', created.id, from, to); + + try { + await landing.open(); + await expect(landing.upcomingTitle).toHaveText(title, { timeout: 30_000 }); + expect(await live(), 'precondition: the booking is live').toBe(true); + + await landing.upcomingDelete.click(); + await landing.acceptConfirm(); + + await expect + .poll(live, { + message: 'the booking is deleted on the backend, not only on the card', + timeout: 20_000, + }) + .toBe(false); + await expect(landing.clearStateDeskLink).toBeVisible({ timeout: 20_000 }); + } finally { + if (await live()) await deleteBooking(staffApi, created.id); + } + }); + + test('NEW-HOME-04: a favourite desk is listed, and Book opens the desk flow with it selected', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const landing = new LandingNewPage(staffPage); + const flow = new DeskFlow(staffPage); + const before: unknown = (await readUserSettings(staffApi)).favourite_desks; + + await setFavouriteDesks(staffApi, [desk.id]); + + try { + await landing.open(); + await expect(landing.favourites).toContainText(desk.name, { timeout: 30_000 }); + await landing.bookFavourite(desk.name).click(); + + await expect(staffPage).toHaveURL(/#\/book\/desk\/form\?.*asset_id=/); + await expect( + flow.desk(desk.name).locator('icon', { hasText: 'task_alt' }), + 'the favourite desk shows as selected in the flow', + ).toBeVisible({ timeout: 20_000 }); + } finally { + await setFavouriteDesks(staffApi, Array.isArray(before) ? before : []); + } + }); + + test('NEW-HOME-05: the desk week shows the desk booking of today and opens its details', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Home Week'); + const landing = new LandingNewPage(staffPage); + const day = new Date().getDate(); + + await clearToday(staffApi); + + await landing.open(); + await expect( + landing.deskWeekBook(day), + 'with no desk booking, today offers to make one', + ).toBeVisible({ timeout: 30_000 }); + await expect(landing.deskWeekBooking(day)).toHaveCount(0); + + const created = await createDeskBookingViaApi(staffApi, { + desk, + title, + ...inProgressSlot(), + }); + + try { + await staffPage.reload(); + await expect(landing.deskWeekBooking(day)).toBeVisible({ timeout: 30_000 }); + await expect(landing.deskWeekBook(day)).toHaveCount(0); + + await landing.deskWeekBooking(day).click(); + await expect( + staffPage.locator('booking-details-modal'), + 'the details are those of the booking', + ).toContainText(title); + } finally { + await deleteBooking(staffApi, created.id); + } + }); + +}); diff --git a/apps/workplace/e2e/local/meeting-flow-new.spec.ts b/apps/workplace/e2e/local/meeting-flow-new.spec.ts new file mode 100644 index 00000000000..c695743fc01 --- /dev/null +++ b/apps/workplace/e2e/local/meeting-flow-new.spec.ts @@ -0,0 +1,159 @@ +/** + * NEW-ROOM-01, NEW-ROOM-02 — the `meeting-flow-new` booking flow, as a + * NON-ADMIN, against a real local backend. + * + * `room-booking.spec.ts` books through the same flow with `bookRoomViaUI`. + * These specs drive the page object directly: NEW-ROOM-01 also checks the + * summary of the last step and the success view, and NEW-ROOM-02 checks that + * each step refuses to go forward without its requirement. + * + * As with every room spec, this runs with `app.events.use_bookings = true`: + * the room is saved as a PlaceOS booking of type `room`. A green run says + * nothing about the calendar path. See `room.settings.ts`. + * + * NEW-ROOM-01 books the whole of `FOURTH_DAY`, a day that no other room spec + * uses. See `room.env.ts`. + */ +import { + deleteBooking, + getBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { expect, test } from '../../../../e2e/support/fixtures'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; +import { releaseRoom, type RoomBooking } from '../../../../e2e/support/room/room.api'; +import { FOURTH_DAY, slotFor } from '../../../../e2e/support/room/room.env'; +import { setDate } from '../../../../e2e/support/room/room.flows'; +import { roomForWorker } from '../../../../e2e/support/room/room.seed'; +import { + ALLOW_ALL_DAY, + ROOM_BASE_SETTINGS, + useSettings, +} from '../../../../e2e/support/room/room.settings'; + +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 10 * DAY; + +const isBookingPost = (url: string, method: string) => + url.includes('/api/staff/v1/bookings') && method === 'POST'; + +test.describe('room booking — the new flow', () => { + test('NEW-ROOM-01: a non-admin books a room through the three steps and the backend stores it', async ({ + staffPage, + staffApi, + }, testInfo) => { + const room = await roomForWorker(testInfo.parallelIndex); + const title = uniqueTitle('E2E New Room'); + const day = slotFor(9, FOURTH_DAY); + const flow = new MeetingFlow(staffPage); + let booking_id: number | undefined; + + const swept = await releaseRoom(staffApi, room.id, window_from(), window_to()); + if (swept) console.log(` swept ${swept} stale booking(s) off ${room.name}`); + // The All Day checkbox needs the setting. The spec sets it, so that it + // does not depend on the default of the app. + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...ALLOW_ALL_DAY }); + + try { + await flow.open(); + await flow.title.fill(title); + await setDate(flow, staffPage, day.date_ms); + // All Day replaces the default slot, which is the next boundary of + // the clock and makes the booking depend on how fast the run is. + await flow.setAllDay(true); + await expect(flow.title, 'the title holds after the date change').toHaveValue(title); + + await flow.searchRooms.click(); + await expect(staffPage).toHaveURL(/view=1/); + await flow.selectRoom(room.name); + + await flow.continue.click(); + await expect(staffPage).toHaveURL(/view=2/); + await expect( + flow.options, + 'the last step shows the meeting and the room before it is sent', + ).toContainText(title); + await expect(flow.options).toContainText(room.name); + + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => isBookingPost(r.url(), r.request().method()), + { timeout: 30_000 }, + ), + flow.confirm.click(), + ]); + const status = response.status(); + const body = await response.text().catch(() => ''); + expect(response.ok(), `POST /bookings should succeed, got ${status}: ${body}`).toBeTruthy(); + const created_id: number = JSON.parse(body).id; + booking_id = created_id; + + // The app reached its own success state, not merely a POST. + await expect(staffPage).toHaveURL(/#\/book\/meeting\/success/); + await expect(flow.successContinue).toBeVisible(); + + // Read it back independently of the response parsed above. + const stored = (await getBooking(staffApi, created_id)) as RoomBooking; + expect(stored.booking_type, 'stored as a room booking').toBe('room'); + expect(stored.asset_id, 'against the room this worker owns').toBe(room.id); + expect(stored.title, 'the meeting name we typed reached the backend').toBe(title); + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'on the day that was picked', + ).toBe(new Date(day.date_ms).toDateString()); + expect(stored.deleted, 'not soft-deleted').toBeFalsy(); + expect(stored.rejected, 'not rejected').toBeFalsy(); + expect( + stored.zones.length, + 'carries its zone hierarchy (building/level)', + ).toBeGreaterThan(0); + + const listed = await listBookings(staffApi, 'room', window_from(), window_to()); + expect( + listed.map((b) => b.id), + 'the new booking appears in the room listing', + ).toContain(created_id); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + } + }); + + test('NEW-ROOM-02: each step refuses to go forward without its requirement, and nothing is sent', async ({ + staffPage, + }) => { + const flow = new MeetingFlow(staffPage); + // The error notice is the signal of a refusal. "The flow is still on + // this step" is also true before the click, so it proves nothing alone. + const refusal = staffPage.locator('mat-snack-bar-container.error'); + const posts: string[] = []; + staffPage.on('request', (request) => { + if (isBookingPost(request.url(), request.method())) posts.push(request.url()); + }); + await useSettings(staffPage, ROOM_BASE_SETTINGS); + + // Step 0 -> 1 needs a title. + await flow.open(); + await expect(flow.title).toHaveValue(''); + await flow.searchRooms.click(); + await expect(refusal, 'no title: the flow shows an error notice').toBeVisible(); + await expect(flow.details, 'no title: the flow stays on the details').toBeVisible(); + await expect(flow.roomSelect).toHaveCount(0); + + // Step 1 -> 2 needs a room. The notice of the first refusal must go + // before the click, or it passes for the notice of this one. + await expect(refusal, 'the first notice did not go').toHaveCount(0); + await flow.title.fill(uniqueTitle('E2E New Room None')); + await flow.searchRooms.click(); + await expect(flow.rooms.first(), 'the room list should offer rooms').toBeVisible({ + timeout: 20_000, + }); + await flow.continue.click(); + await expect(refusal, 'no room: the flow shows an error notice').toBeVisible(); + await expect(flow.roomSelect, 'no room: the flow stays on the room list').toBeVisible(); + await expect(flow.options).toHaveCount(0); + + expect(posts, 'no booking request may leave the page on a refused step').toEqual([]); + }); +}); diff --git a/apps/workplace/e2e/local/parking-edit.spec.ts b/apps/workplace/e2e/local/parking-edit.spec.ts index e3fb79df243..7a2648232bc 100644 --- a/apps/workplace/e2e/local/parking-edit.spec.ts +++ b/apps/workplace/e2e/local/parking-edit.spec.ts @@ -77,8 +77,31 @@ test.describe('editing a parking booking', () => { 'the edit form should open with the booked space already on it', ).toHaveCount(1, { timeout: 30_000 }); - await form.setStartTime(hhmm(to_slot.start)); - await staffPage.waitForTimeout(3_000); + // The form can be rebuilt while it loads, and the rebuild puts the + // booked time back. Under load that came after the choice, and the + // edit was saved with the old time. So set the time again until + // the form holds the WANTED time for three seconds. A user who + // picks a time in that window loses it the same way: this is + // REG-10 on the parking form, and a second set goes in the report. + const wanted = hhmm(to_slot.start); + let sets = 0; + await expect(async () => { + if ((await form.startTime()) !== wanted) { + await form.setStartTime(wanted); + sets += 1; + } + await staffPage.waitForTimeout(3_000); + expect( + await form.startTime(), + 'the form put the start time back after it was chosen', + ).toBe(wanted); + }).toPass({ timeout: 30_000 }); + if (sets > 1) { + testInfo.annotations.push({ + type: 'REG-10', + description: `the parking form lost the chosen start time ${sets - 1} time(s)`, + }); + } await form.confirmAndSend(); await expect(async () => { diff --git a/apps/workplace/e2e/local/parking-favourites.spec.ts b/apps/workplace/e2e/local/parking-favourites.spec.ts index cb2fa3aa657..49b92795fa3 100644 --- a/apps/workplace/e2e/local/parking-favourites.spec.ts +++ b/apps/workplace/e2e/local/parking-favourites.spec.ts @@ -2,7 +2,7 @@ * PARK-12 — saving a favourite parking space and filtering the picker to favourites. * * Favourites are a USER setting, written to the signed-in user's `settings` - * metadata as `favourite_parking_spaces` and read back on every later visit. The room + * metadata as `favourite_parking` and read back on every later visit. The room * and desk equivalents are both green; parking has the same feature, and the * same failure mode: a star that lights up and is forgotten on reload looks * perfect in a screenshot and is useless to the person circling the car park @@ -101,8 +101,7 @@ test.describe('favourite parking spaces', () => { expect( settings[FAVOURITE_PARKING_KEY] ?? [], `${space.name} (${space.id}) should be saved as a favourite space ` + - `under "${FAVOURITE_PARKING_KEY}". Note the codebase also has a ` + - `dead \`favourite_parking\` constant that nothing reads`, + `under "${FAVOURITE_PARKING_KEY}"`, ).toContain(space.id); }).toPass({ timeout: 30_000 }); diff --git a/apps/workplace/e2e/local/parking-request.spec.ts b/apps/workplace/e2e/local/parking-request.spec.ts index 5551e76cd18..c36d691b0c3 100644 --- a/apps/workplace/e2e/local/parking-request.spec.ts +++ b/apps/workplace/e2e/local/parking-request.spec.ts @@ -63,7 +63,11 @@ test.describe('parking requests', () => { { timeout: 30_000 }, ) .catch(() => null); - await form.locator('button[confirm]').first().click({ timeout: 10_000 }); + // The submit control has no `name`. Its `send` icon is the handle. + await form + .locator('button[btn]') + .filter({ has: staffPage.locator('icon', { hasText: 'send' }) }) + .click({ timeout: 10_000 }); const response = await sent; if (!response) { const message = await staffPage diff --git a/apps/workplace/e2e/local/parking-restrictions.spec.ts b/apps/workplace/e2e/local/parking-restrictions.spec.ts index eb738ca421b..fcc6eba9f1b 100644 --- a/apps/workplace/e2e/local/parking-restrictions.spec.ts +++ b/apps/workplace/e2e/local/parking-restrictions.spec.ts @@ -32,12 +32,16 @@ test('a parking request requires and stores the selected restriction', async ({ await staffPage.goto('/#/book/parking-request/form'); const form = staffPage.locator('parking-request-form'); await expect(form).toBeVisible(); + // The submit control has no `name`. Its `send` icon is the handle. + const submit = form + .locator('button[btn]') + .filter({ has: staffPage.locator('icon', { hasText: 'send' }) }); const restrictions = form.locator( 'mat-radio-group[aria-labelledby="parking-space-restrictions-label"]', ); await expect(restrictions).toBeVisible(); await expect(restrictions).toHaveAttribute('aria-required', 'true'); - await form.locator('button[confirm]').click(); + await submit.click(); await expect(restrictions).toHaveAttribute('aria-invalid', 'true'); expect(posts).toHaveLength(0); await restrictions @@ -53,7 +57,7 @@ test('a parking request requires and stores the selected restriction', async ({ r.request().method() === 'POST' && new URL(r.url()).pathname === `${STAFF_API}/bookings`, ), - form.locator('button[confirm]').click(), + submit.click(), ]); const body = await response.json(); booking_id = body.id; diff --git a/apps/workplace/e2e/local/room-allday.spec.ts b/apps/workplace/e2e/local/room-allday.spec.ts index 11a7fd6d7d7..aedbd147869 100644 --- a/apps/workplace/e2e/local/room-allday.spec.ts +++ b/apps/workplace/e2e/local/room-allday.spec.ts @@ -10,26 +10,25 @@ * * ## The setting is `events.allow_all_day` * - * Not `allow_multiday`, which only widens the range of dates the room picker - * offers. The control lives in `meeting-form-details.component.ts`, a different - * component from the flow form — which is why a first attempt found no checkbox - * and nearly concluded that all-day meetings do not exist in this app. + * Not `allow_multiday`, which replaces the length with an end date and an end + * time. The checkbox is on the details step + * (`meeting-flow-details.component.ts`), and without the setting it is not + * rendered at all. * - * ## `fixme` — ROOM-B7: the all-day flag is ignored + * ## ROOM-B7: the all-day flag was ignored * - * Measured: with `app.events.allow_all_day` on, the All Day checkbox ticked, and - * **the checkbox proven still ticked at the moment the meeting is confirmed**, - * the stored booking is **one hour long**. The room is free for the rest of the - * day while the person who booked it believes they have it all day. + * Measured on the flow that this one replaced: with the All Day checkbox + * ticked, and proven still ticked when the meeting was confirmed, the stored + * booking was **one hour long**. The room was free for the rest of the day + * while the person who booked it believed they had it all day. It was the same + * family as ROOM-B3 (the chosen length was not the length booked). * - * The "still ticked" check matters: it rules out the form rebuilding itself and - * reverting the control (REG-10's family), which would look identical from the - * outside. `bookRoomViaUI` asserts it before sending for exactly this reason. + * Measured on the new flow, 13 Oct 2026: the booking is stored with + * `all_day: true` and holds the room for the day. This test is the guard. * - * Same family as ROOM-B3 (the chosen length is not the length booked): - * `postForm` computes an all-day range, and the booking built from it by - * `newBookingFromCalendarEvent` takes `duration` from the event instead. Fixing - * ROOM-B3 properly would most likely fix this too. + * `bookRoomViaUI` checks that the checkbox is still ticked when it leaves the + * details step. That rules out the control reverting before anything is sent, + * which would look identical from the outside. */ import { deleteBooking, diff --git a/apps/workplace/e2e/local/room-assets.spec.ts b/apps/workplace/e2e/local/room-assets.spec.ts index d118ce76d5f..f15217ee57c 100644 --- a/apps/workplace/e2e/local/room-assets.spec.ts +++ b/apps/workplace/e2e/local/room-assets.spec.ts @@ -1,11 +1,21 @@ /** * ROOM-24 — equipment requested on a meeting. * - * ## The headline: it cannot be done on a PlaceOS-native room booking + * ## State on the new meeting flow * - * Measured 2026-09-17, and it is a NEW finding — **ROOM-B9**. I expected this to - * work from reading the code and it does not, which is worth saying plainly - * because the code genuinely looks like it should. + * Measured 13 Oct 2026 through `meeting-flow-new`: the request is stored as + * its own `asset-request` booking, against a unit of the catalogue, and is + * linked to the meeting. Both tests are active. + * + * The rest of this note is the finding that the first test was written for, + * as it was measured on the flow that this one replaced. It is kept because it + * says what the assertions guard against. + * + * ## ROOM-B9: it could not be done on a PlaceOS-native room booking + * + * Measured 2026-09-17. I expected this to work from reading the code and it + * did not, which is worth saying plainly because the code genuinely looked + * like it should. * * `validateAssetRequestsForResource` (`libs/assets/src/lib/assets.fn.ts`) has a * branch for exactly this case: @@ -58,17 +68,17 @@ * * ## What still passes, and it is worth having * - * ROOM-24b below is active and green: equipment ALLOCATION works even though - * requesting it through a meeting does not. With two of three units held by - * existing `asset-request` bookings, the picker offers exactly one — so + * ROOM-24b below guards equipment ALLOCATION, which worked even while + * requesting it through a meeting did not. With two of three units held by + * existing `asset-request` bookings, the asset modal offers exactly one — so * `queryGroupAvailability` really does read existing requests, and two meetings * cannot be handed the same projector. That is the half of ROOM-24 that can be * guarded today. * * ## Two preconditions, neither of which fails loudly * - * - `app.events.has_assets` (ROOM_ASSETS_MODE) or the form has no equipment - * section at all. + * - `app.events.has_assets` (ROOM_ASSETS_MODE) or the last step of the flow + * has no equipment section at all. * - requestable assets seeded (`asset.seed.ts`) or the modal opens empty. * * The group offered is the asset TYPE, not an individual unit: the app allocates @@ -93,7 +103,7 @@ import { ROOM_BASE_SETTINGS, useSettings, } from '../../../../e2e/support/room/room.settings'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; import { bookRoomViaUI, setDate } from '../../../../e2e/support/room/room.flows'; const DAY = 86_400; @@ -138,11 +148,11 @@ test.describe('room booking equipment requests', () => { await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...ROOM_ASSETS_MODE }); try { - // Through the shared flow rather than the form directly. It owns the - // load-bearing order — the DATE first and exactly once, then title - // and room through the REG-10 retry, then the times last — and - // getting that wrong is what made the first attempt at this spec - // fail on a start time the form had never been asked to offer. + // Through the shared flow rather than the page object directly. It + // owns the order of the three steps — the DATE before the times, + // the room before the equipment — and getting that wrong is what + // made the first attempt at this spec fail on a start time the form + // had never been asked to offer. const meeting = await bookRoomViaUI(staffPage, staffApi, room, title, { date: slot.date_ms, startTime: `${String(ROOM_SLOTS_3.assets.hour).padStart(2, '0')}:00`, @@ -262,49 +272,42 @@ test.describe('room booking equipment requests', () => { ...ROOM_BASE_SETTINGS, ...ROOM_ASSETS_MODE, }); - // No booking is made here, so this drives the form directly rather + // No booking is made here, so this drives the flow directly rather // than going through `bookRoomViaUI` — but the DATE still has to be - // set before the times, or the form offers today's hours and the - // slot's hour is simply absent. - const form = new MeetingForm(staffPage); + // set before the times, or the flow offers today's hours and the + // slot's hour is simply absent. The equipment section is on the last + // step, so the room comes first. + const form = new MeetingFlow(staffPage); await form.open(); + await form.title.fill(uniqueTitle('E2E Assets Offered')); await setDate(form, staffPage, slot.date_ms); - await staffPage.waitForTimeout(2_000); - await form.chooseRoom(room.name); await form.setStartTime( `${String(ROOM_SLOTS_3.assets.second).padStart(2, '0')}:00`, ); - await staffPage.waitForTimeout(3_000); - - await staffPage - .locator('asset-list-field') - .getByRole('button', { name: /Request Assets/i }) - .first() - .click(); - const modal = staffPage.locator('asset-select-modal'); - await expect(modal, 'the asset modal did not open').toBeVisible({ - timeout: 20_000, - }); + await form.toRoomStep(); + await form.selectRoom(room.name); + await form.toOptionsStep(); + await form.openAssetModal(); // The count the modal shows is the assertion. `queryGroupAvailability` // removes any unit covered by a live `asset-request`, so with all but // one held it must offer exactly one. - const listing = modal.locator('asset-list'); + const group = form.assetGroup(kit.type_name); await expect( - listing.getByText(kit.type_name, { exact: false }).first(), + group, 'the equipment group should still be listed while one unit is free', ).toBeVisible({ timeout: 20_000 }); - await expect - .poll(async () => (await listing.innerText()).replace(/\s+/g, ' '), { - message: - `with ${held.length} of ${kit.items.length} units held, the ` + - `modal must offer exactly ${kit.items.length - held.length}. ` + - `If it still offers all of them, availability is not reading ` + - `existing asset-request bookings and two meetings can be given ` + - `the same projector`, - timeout: 25_000, - }) - .toContain(`${kit.items.length - held.length} available`); + // The row shows the number of free units in a translated sentence. + // Only the number is matched. + const free = kit.items.length - held.length; + await expect( + group.locator('p'), + `with ${held.length} of ${kit.items.length} units held, the ` + + `modal must offer exactly ${free}. ` + + `If it still offers all of them, availability is not reading ` + + `existing asset-request bookings and two meetings can be given ` + + `the same projector`, + ).toHaveText(new RegExp(`(^|\\D)${free}(\\D|$)`), { timeout: 25_000 }); } finally { for (const id of held) await deleteBooking(staffApi, id); for (const stale of await ourAssetRequests(staffApi, unit_ids)) { diff --git a/apps/workplace/e2e/local/room-attendees.spec.ts b/apps/workplace/e2e/local/room-attendees.spec.ts index 2de5ad1f7b6..d1c5da3900e 100644 --- a/apps/workplace/e2e/local/room-attendees.spec.ts +++ b/apps/workplace/e2e/local/room-attendees.spec.ts @@ -107,9 +107,8 @@ test.describe('room booking attendees', () => { try { const me = await currentUser(staffApi); // Added and then taken off again — the everyday version of this is - // picking the wrong colleague out of a lookup. The correction - // happens inside the send flow, so a retry cannot quietly re-add - // them. + // picking the wrong colleague out of a lookup. Both the add and the + // correction happen on the last step, before Confirm. const created = await bookRoomViaUI(staffPage, staffApi, room, title, { date: slot.date_ms, attendees: [dropped], diff --git a/apps/workplace/e2e/local/room-booking.spec.ts b/apps/workplace/e2e/local/room-booking.spec.ts index df86e02ccc7..a170685aa30 100644 --- a/apps/workplace/e2e/local/room-booking.spec.ts +++ b/apps/workplace/e2e/local/room-booking.spec.ts @@ -22,9 +22,9 @@ * - A room is EXCLUSIVE for its window, like a desk. Measured before this spec * was written: the same slot as another user is 409, a partial overlap is * 409, a clear slot is 201. `room-clash.spec.ts` owns that. - * - The flow has THREE screens — form, confirm, success — and nothing is sent - * until the second. A spec that clicks Confirm once and looks for a booking - * will find none and blame the backend. + * - The flow has THREE steps — details, room, options — and nothing is sent + * until Confirm on the last one. There is no separate confirm screen: + * Confirm sends the request at once. See `meeting-flow.page.ts`. */ import { deleteBooking, @@ -61,7 +61,7 @@ test.describe('room booking', () => { // Sweep first, not just after. A run that died between booking and // cleanup leaves the room held, and every later run then fails with - // "no room called ... in the picker" — which looks nothing like the cause. + // "no room called ... in the room list" — which looks nothing like the cause. const swept = await releaseRoom( staffApi, room.id, @@ -110,7 +110,7 @@ test.describe('room booking', () => { ).toBe(title); expect(stored.deleted, 'not soft-deleted').toBeFalsy(); expect(stored.rejected, 'not rejected').toBeFalsy(); - // Zones are deliberately NOT asserted here — see the `fixme` below. + // Zones have a test of their own below. expect( new Date(stored.booking_start * 1000).toDateString(), 'and falls on the day chosen in the form, not the default day', @@ -168,21 +168,18 @@ test.describe('room booking', () => { }); /** - * ROOM-13, blocked by ROOM-B2 — a room booked through the app is stored - * with NO zones. + * ROOM-13 — a room booked through the app carries its zone hierarchy. * - * The meeting form sends `zones: []`. Desk and visitor bookings both carry - * the org/building/level hierarchy, and anything that scopes bookings by - * zone — a building filter, a report, a per-site view — cannot see a booking - * that has none. + * Desk and visitor bookings both carry the org/building/level hierarchy, + * and anything that scopes bookings by zone — a building filter, a report, + * a per-site view — cannot see a booking that has none. * - * It is also half of ROOM-B1: with no zones, the backend's approval - * permission check has nothing to check against, and instead of refusing - * with 403 it builds a query with an empty list and dies with - * `syntax error at or near ")" (PQ::PQError)`. + * The flow that this one replaced sent `zones: []` (ROOM-B2), and this test + * was blocked on it. Measured on the new flow, 13 Oct 2026: the booking is + * stored with its zones, so the test is active. * - * A third test in a two-test file, on purpose, and `fixme` so it costs - * nothing per run. Fold it into the first test once the app populates zones. + * It was also half of ROOM-B1: with no zones, the backend's approval + * permission check had nothing to check against. */ test('a room booking carries its zone hierarchy', async ({ staffPage, diff --git a/apps/workplace/e2e/local/room-cancel.spec.ts b/apps/workplace/e2e/local/room-cancel.spec.ts index d44e80a629a..de7b5e9b23a 100644 --- a/apps/workplace/e2e/local/room-cancel.spec.ts +++ b/apps/workplace/e2e/local/room-cancel.spec.ts @@ -44,8 +44,10 @@ async function isLive(api: APIRequestContext, id: number): Promise { } test.describe('cancelling a room booking from the app', () => { - // ROOM-11 / ROOM-B4: native booking cancellation; assert persisted state. - // Keep active so a fix can be verified rather than hidden by test.fixme. + // ROOM-11. With ROOM-B4 the schedule sent `DELETE /events/`, which + // answered 500, and the booking stayed live. Measured 13 Oct 2026: the + // schedule sends `DELETE /api/staff/v1/bookings/`, which answers 202, + // and the booking is gone. The responses are attached as evidence. test('cancelling from the booking menu removes it for real', async ({ staffPage, staffApi, diff --git a/apps/workplace/e2e/local/room-capacity.spec.ts b/apps/workplace/e2e/local/room-capacity.spec.ts index 05221519bb7..dc1bc6452b2 100644 --- a/apps/workplace/e2e/local/room-capacity.spec.ts +++ b/apps/workplace/e2e/local/room-capacity.spec.ts @@ -11,64 +11,67 @@ * `app.events.strict_capacity_check` is not a stronger version of the default — * it is a different mechanism: * - * unset ...... a warning is drawn next to the room list and the booking goes - * through anyway. This is the shipped default, so it is the - * behaviour real users get. - * true ....... `viewConfirm` (meeting-flow-form.component.ts) returns early - * with a toast. The confirm screen never opens and nothing is - * sent. + * unset ...... a warning is drawn under the attendee list of the last step + * and the booking goes through anyway. This is the shipped + * default, so it is the behaviour real users get. + * true ....... Confirm (`confirmBooking`, meeting-flow-options.component.ts) + * returns early with a toast. Nothing is sent. * - * Test 1 takes the strict path, because "the form refused and the backend never + * Test 1 takes the strict path, because "the flow refused and the backend never * heard about it" is the assertion with teeth. Test 2 takes the default path, * because a warning nobody renders is the failure users would actually hit. * * ## Both tests carry their own control * - * A form that refuses everything, or renders a warning always, would pass a + * A flow that refuses everything, or renders a warning always, would pass a * one-sided version of either test. So test 1 removes the attendee and shows - * the same form then reaches the confirm screen, and test 2 starts from a room - * that IS big enough and shows the warning appear only when the room changes. - * Nothing but the capacity differs across either pair. + * the same flow then books, and test 2 starts from a room that IS big enough + * and shows the warning appear only when the room changes. Nothing but the + * capacity differs across either pair. + * + * ## The control of test 1 books for real + * + * The flow has no confirm screen to stop at: Confirm sends the request at + * once. So the control of test 1 is a booking that goes through, and the test + * deletes it afterwards. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { deleteBooking, listBookings, uniqueTitle } from '../../../../e2e/support/api'; import { WORKERS, staffEmail } from '../../../../e2e/support/env'; import { type RoomBooking, releaseRoom } from '../../../../e2e/support/room/room.api'; +import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/room.env'; import { roomForWorker } from '../../../../e2e/support/room/room.seed'; import { ROOM_BASE_SETTINGS, STRICT_CAPACITY, useSettings, } from '../../../../e2e/support/room/room.settings'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; +import { setDate } from '../../../../e2e/support/room/room.flows'; const DAY = 86_400; const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; -/** - * The capacity warning, matched on its CLASSES. - * - * The one selector in the room specs that is neither a `name` attribute nor - * test data. The element has no attribute to hold on to and its text is - * translated (`CALENDAR_EVENT.CAPACITY_WARNING`), so the styling is all there - * is. Scoped to the form so a toast or another warning elsewhere on the page - * cannot satisfy it. - */ -const CAPACITY_WARNING = 'meeting-flow-form div.bg-warning.text-warning-content'; +/** "HH:00" for an hour of the slot table. */ +const onTheHour = (hour: number) => `${String(hour).padStart(2, '0')}:00`; test.describe('room capacity', () => { test('a meeting bigger than the room is refused before anything is sent', async ({ staffPage, + staffApi, }, testInfo) => { const mine = testInfo.parallelIndex; const theirs = (mine + 1) % WORKERS; test.skip(theirs === mine, 'needs at least two workers for a second address'); const small = await roomForWorker(mine, 'small'); + const slot = slotFor(ROOM_SLOTS_2.capacity.strict, SECOND_DAY); const title = uniqueTitle('E2E Room Capacity'); const guest = staffEmail(theirs); + let booking_id: number | undefined; + await releaseRoom(staffApi, small.id, window_from(), window_to()); await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...STRICT_CAPACITY }); // Watch every booking write, not just the successful ones. The point of @@ -78,48 +81,68 @@ test.describe('room capacity', () => { staffPage.on('request', (r) => { if (!r.url().includes('/api/staff/v1/bookings')) return; if (r.method() === 'GET') return; - writes.push(`${r.method()} ${r.url()}`); + writes.push(`${r.method()} ${new URL(r.url()).pathname}`); }); - const form = new MeetingForm(staffPage); - await form.open(); - // Without this the small room is not even offered — the picker starts at - // "min. 4 people". See `setRoomSize`. - await form.setRoomSize(1); - await expect(async () => { + try { + const form = new MeetingFlow(staffPage); + await form.open(); await form.title.fill(title); - if ((await form.chosenSpaces.count()) === 0) await form.chooseRoom(small.name); - await expect(form.title).toHaveValue(title, { timeout: 1_000 }); - expect(await form.chosenSpaces.count()).toBe(1); - }).toPass({ timeout: 45_000 }); - await form.addAttendee(guest); - await staffPage.waitForTimeout(2_000); - - // Press Confirm on the FORM. In strict mode this should not even get as - // far as the confirm screen. - await form.confirmViewButton.click(); - await expect( - form.confirmButton, - 'a meeting with more people than the room holds must not reach the ' + - 'confirm screen when `app.events.strict_capacity_check` is set', - ).toBeHidden({ timeout: 10_000 }); - expect( - writes, - 'nothing at all should have been written to the backend', - ).toEqual([]); - - // CONTROL: the same form, one person lighter, gets through. Without - // this, a form that was simply broken would pass the assertions above. - await form.removeAttendee(guest); - await staffPage.waitForTimeout(2_000); - await form.confirmViewButton.click(); - await expect( - form.confirmButton, - 'with the meeting back inside the room capacity the confirm screen must ' + - 'open — otherwise the refusal above was not about capacity at all', - ).toBeVisible({ timeout: 20_000 }); - // Deliberately NOT confirmed: the control is that the screen opens, and - // sending it would leave a booking to clean up for no extra coverage. + await setDate(form, staffPage, slot.date_ms); + await form.setStartTime(onTheHour(ROOM_SLOTS_2.capacity.strict)); + await form.toRoomStep(); + await form.selectRoom(small.name); + await form.toOptionsStep(); + await form.addAttendee(guest); + + // Press Confirm. In strict mode the flow refuses with a toast and + // stays on the last step. + await form.confirm.click(); + await expect( + staffPage.locator('mat-snack-bar-container'), + 'a meeting with more people than the room holds must be refused ' + + 'with a message when `app.events.strict_capacity_check` is set', + ).toBeVisible({ timeout: 10_000 }); + await expect( + form.confirm, + 'the flow must stay on the last step, ready for a correction', + ).toBeVisible(); + await expect(form.successContinue).toHaveCount(0); + expect( + writes, + 'nothing at all should have been written to the backend', + ).toEqual([]); + + // CONTROL: the same flow, one person lighter, gets through. Without + // this, a flow that was simply broken would pass the assertions above. + await form.removeAttendee(guest); + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes('/api/staff/v1/bookings') && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirm.click(), + ]); + const body = await response.text().catch(() => ''); + expect( + response.ok(), + 'with the meeting back inside the room capacity the booking must go ' + + 'through — otherwise the refusal above was not about capacity at ' + + `all. Got HTTP ${response.status()}: ${body}`, + ).toBeTruthy(); + booking_id = JSON.parse(body).id; + await expect(form.successContinue).toBeVisible({ timeout: 30_000 }); + expect( + writes, + 'the only write of the whole test is the booking of the control, so ' + + 'the refused attempt sent nothing late either', + ).toEqual(['POST /api/staff/v1/bookings']); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await releaseRoom(staffApi, small.id, window_from(), window_to()); + } }); test('a meeting bigger than the room is warned about by default', async ({ @@ -132,58 +155,64 @@ test.describe('room capacity', () => { const room = await roomForWorker(mine); const small = await roomForWorker(mine, 'small'); + const slot = slotFor(ROOM_SLOTS_2.capacity.warning, SECOND_DAY); const title = uniqueTitle('E2E Room Capacity Warn'); const guest = staffEmail(theirs); // The default: strict checking OFF, which is what ships. // - // The day is left at whatever the form offers, unlike the other room - // specs: this test asserts on the FORM, and the only booking it makes is - // in the small room, which nothing else in the suite ever touches — so - // there is no slot to collide over. It is swept either side regardless. + // The only booking this test makes is in the small room. The main room + // is selected for the control and then swapped out, but it has to be + // free for the window to be offered at all, so both are swept. await useSettings(staffPage, ROOM_BASE_SETTINGS); + await releaseRoom(staffApi, room.id, window_from(), window_to()); await releaseRoom(staffApi, small.id, window_from(), window_to()); - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await form.open(); - // Both rooms have to be on offer for the swap below, and the small one is - // only offered at the smallest size. See `setRoomSize`. - await form.setRoomSize(1); - await expect(async () => { - await form.title.fill(title); - if ((await form.chosenSpaces.count()) === 0) await form.chooseRoom(room.name); - await expect(form.title).toHaveValue(title, { timeout: 1_000 }); - expect(await form.chosenSpaces.count()).toBe(1); - }).toPass({ timeout: 45_000 }); + await form.title.fill(title); + await setDate(form, staffPage, slot.date_ms); + await form.setStartTime(onTheHour(ROOM_SLOTS_2.capacity.warning)); + await form.toRoomStep(); + await form.selectRoom(room.name); + await form.toOptionsStep(); await form.addAttendee(guest); - await staffPage.waitForTimeout(2_000); // CONTROL FIRST, with a room that fits: no warning. This is what makes // the assertion below about capacity rather than about the element - // simply always being there. + // simply always being there. The room in the summary proves the step + // has rendered, so "no warning" is not read off an empty page. + expect(await form.chosenRoomNames()).toEqual([room.name]); await expect( - staffPage.locator(CAPACITY_WARNING), + form.capacityWarning, `${room.name} holds ${room.capacity}, so a two-person meeting must not ` + 'be warned about', - ).toBeHidden({ timeout: 10_000 }); - - // Only the room changes. - await form.removeRoom(); - await form.chooseRoom(small.name); + ).toHaveCount(0); + + // Only the room changes. A click on a room in the list takes it in + // place of the one that was selected. + await form.stepLink(1).click(); + await form.selectRoom(small.name); + await form.toOptionsStep(); + expect(await form.chosenRoomNames()).toEqual([small.name]); + await expect( + form.attendeeChips, + 'the attendee must still be on the meeting after the room changed', + ).toHaveCount(1); await expect( - staffPage.locator(CAPACITY_WARNING), + form.capacityWarning, `${small.name} holds ${small.capacity}, so the same meeting must now be ` + 'warned about', ).toBeVisible({ timeout: 20_000 }); // And it is only a warning: the booking still goes through. That is the // shipped behaviour, and a user who ignores the warning must not end up - // with a form that silently refuses to submit. + // with a flow that silently refuses to submit. let booking_id: number | undefined; try { - await form.confirmAndSend(); + await form.confirm.click(); await expect( - form.successPanel, + form.successContinue, 'the default capacity check is advisory — the booking must still be ' + 'accepted after the warning', ).toBeVisible({ timeout: 30_000 }); @@ -201,6 +230,7 @@ test.describe('room capacity', () => { 'a booking should exist for the over-capacity meeting that was warned ' + 'about and confirmed anyway', ).toBeTruthy(); + expect(made?.asset_id, 'and it holds the small room').toBe(small.id); booking_id = made?.id; } finally { if (booking_id != null) await deleteBooking(staffApi, booking_id); diff --git a/apps/workplace/e2e/local/room-catering.spec.ts b/apps/workplace/e2e/local/room-catering.spec.ts index 18ac981f0a0..9fa1756054e 100644 --- a/apps/workplace/e2e/local/room-catering.spec.ts +++ b/apps/workplace/e2e/local/room-catering.spec.ts @@ -16,36 +16,35 @@ * on the ENGINE api, all needing admin. `catering.seed.ts` does it, idempotently * and once per process, and explains the shape. * - * ## Order of operations on the form + * ## Order of operations in the flow * - * The room goes first. "Add catering" is DISABLED until a room is chosen, since - * catering is delivered to a room — so this test cannot be written in the - * obvious order, and `MeetingForm.addCateringItem` says so where somebody will - * read it. + * The room goes first. Catering is delivered to a room, so the catering + * section is on the last step of the flow, after the room list. See + * `MeetingFlow.addCateringItem`. * * ## ROOM-B5, the worst of the room findings — fixed in the app * - * Ordering catering with a PlaceOS-native room booking cannot work at all. The - * meeting is created (`201`), and the catering order that follows is refused: + * Ordering catering with a PlaceOS-native room booking could not work at all. + * The meeting was created (`201`), and the catering order that followed was + * refused: * * POST /api/staff/v1/bookings (booking_type: catering-order) * -> 422 {"error":"error linking booking to event", * "failures":[{"field":"event_id", * "reason":"Could not find metadata for event ARRAY['1138']"}]} * - * The order is linked to a calendar EVENT by `event_id`, and in `use_bookings` + * The order was linked to a calendar EVENT by `event_id`, and in `use_bookings` * mode there is no event — the meeting is a booking, so the id handed over - * (1138 here) is a booking id and the lookup finds nothing. + * (1138 here) was a booking id and the lookup found nothing. * - * Measured what is left behind afterwards, because that is what decides how bad - * it is: **the room booking survives, undeleted, and no catering order exists.** - * The user is shown an error on the confirm screen, so they have every reason to - * believe nothing was booked — while the room is held and the food was never - * ordered. `postForm` does call `_removeBookingAfterError` on a catering - * failure, and it did not roll the room booking back. + * Measured what was left behind afterwards, because that is what decides how + * bad it is: **the room booking survived, undeleted, and no catering order + * existed.** The user was shown an error, so they had every reason to believe + * nothing was booked — while the room was held and the food was never ordered. * * The order is now linked to the native booking by `parent_id`, which is what - * the assertions below check. + * the assertions below check. Measured through the new meeting flow, + * 13 Oct 2026: the order is stored and names the item. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { diff --git a/apps/workplace/e2e/local/room-clash.spec.ts b/apps/workplace/e2e/local/room-clash.spec.ts index 3072a890c45..9b19755bc17 100644 --- a/apps/workplace/e2e/local/room-clash.spec.ts +++ b/apps/workplace/e2e/local/room-clash.spec.ts @@ -4,12 +4,12 @@ * The room equivalent of `desk-clash.spec.ts`, and the same reasoning: a * double-booked room is the failure users notice fastest, and it is the backend * that has to refuse it. The UI's own availability check (`queryResourceAvailability`) - * only filters the picker; it would be no defence against a second person + * only filters the room list; it would be no defence against a second person * booking at the same moment, or against any other client. * * Everything is created through the API, not the form. The subject is the rule, - * and driving the meeting form twice would add an unrelated way to fail — - * including hitting the picker's own filtering, which hides busy rooms and would + * and driving the meeting flow twice would add an unrelated way to fail — + * including hitting the room list's own filtering, which hides busy rooms and would * make "the second booking was refused" indistinguishable from "the room was * never offered". * diff --git a/apps/workplace/e2e/local/room-delegate.spec.ts b/apps/workplace/e2e/local/room-delegate.spec.ts index 72669e19280..e81874226f2 100644 --- a/apps/workplace/e2e/local/room-delegate.spec.ts +++ b/apps/workplace/e2e/local/room-delegate.spec.ts @@ -3,35 +3,35 @@ * * The visitor equivalent (VIS-09) found a real defect: the host picker rendered * from one setting while the choice was only KEPT when a second was also set, so - * the booking came back owned by whoever filled the form. Rooms have the same - * shape — `meeting-form-details.component.ts` renders a host field from - * `events.can_book_for_anyone` — and nothing has checked which user a room - * booking ends up against. + * the booking came back owned by whoever filled the form. Rooms had the same + * defect (ROOM-B8, PPT-2805): the chosen host was discarded. * * It matters beyond bookkeeping: a room booked "for" a colleague but stored * against the booker does not appear on the colleague's schedule, so as far as * the app is concerned they have no room, and the booker holds two. * - * ## The local user list, again + * ## `fixme` — the new meeting flow cannot name a colleague on this stack * - * `app.basic_user_search` is set with it, for the same reason DESK-14 needs it: - * without it the field searches `/api/staff/v1/people`, the calendar directory, - * which 500s on this stack. So this covers the PlaceOS path only. + * The flow that this one replaced rendered a user SEARCH field from + * `events.can_book_for_anyone`, and this test picked the colleague there. + * `meeting-flow-new` does not read that setting. Measured 13 Oct 2026: * - * ## ROOM-B8 (PPT-2805): the chosen host was discarded — fixed on develop + * `can_book_for_anyone` ..... the details step has no host field at all + * (0 `host-select-field`, 0 `a-user-search-field`) + * `can_book_for_others` ..... the step has a `host-select-field`. It is a + * select of the signed-in user and the CALENDARS + * that the user can edit. On this stack + * `GET /api/staff/v1/calendars` answers 500, so + * the select holds one option: the signed-in user. * - * Measured: the colleague was picked from the host field, **the field was proven - * to still show them at the moment the meeting was confirmed**, and the stored - * booking came back owned by the person who filled the form. + * So no colleague can be chosen, and the stored owner cannot be tested. The + * test body drives the `host-select-field`, which is the control that exists. + * It runs as far as `chooseHost`, which fails with the list that the select + * offers. * - * That "still showing" check is what makes this a finding rather than a test - * bug — it rules out the form reverting the field before sending, which looks - * identical from outside. `bookRoomViaUI` asserts it for this reason. - * - * The consequence is the same one VIS-09 used to have for visitors: the room - * does not appear on the colleague's schedule, so as far as the app is concerned - * they have no room, and the booker holds two. Desks get this right (DESK-14 is - * green), which is the useful comparison for whoever fixes it. + * To unblock it, one of these has to be true: the flow gets a host search for + * `events.can_book_for_anyone`, or the stack has a calendar tenant in which the + * staff user can edit the calendar of a colleague. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { @@ -45,7 +45,7 @@ import { ROOM_SLOTS_2, SECOND_DAY, slotFor } from '../../../../e2e/support/room/ import { roomForWorker } from '../../../../e2e/support/room/room.seed'; import { releaseRoom } from '../../../../e2e/support/room/room.api'; import { - BOOK_FOR_ANYONE, + BOOK_FOR_OTHERS, ROOM_BASE_SETTINGS, useSettings, } from '../../../../e2e/support/room/room.settings'; @@ -56,7 +56,8 @@ const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; test.describe('booking a room for a colleague', () => { - test('the chosen colleague is stored as the host, and you as the booker', async ({ + // See the file header: the flow offers no colleague to choose on this stack. + test.fixme('the chosen colleague is stored as the host, and you as the booker', async ({ staffPage, staffApi, }, testInfo) => { @@ -71,7 +72,7 @@ test.describe('booking a room for a colleague', () => { let booking_id: number | undefined; await releaseRoom(staffApi, room.id, window_from(), window_to()); - await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...BOOK_FOR_ANYONE }); + await useSettings(staffPage, { ...ROOM_BASE_SETTINGS, ...BOOK_FOR_OTHERS }); try { const me = await currentUser(staffApi); diff --git a/apps/workplace/e2e/local/room-edit.spec.ts b/apps/workplace/e2e/local/room-edit.spec.ts index dfe34b802b0..333b20c91af 100644 --- a/apps/workplace/e2e/local/room-edit.spec.ts +++ b/apps/workplace/e2e/local/room-edit.spec.ts @@ -20,14 +20,23 @@ * A room booking made straight through the API has no `extension_data`, and * that is where the room lives: the schedule rebuilds a room booking into a * CalendarEvent (`newCalendarEventFromBooking`) by spreading `extension_data` - * over it, so an API-made booking opens the edit form with NO room on it. - * Measured, and it cost a first draft of this file — the form was empty and it - * looked like the app losing the room on edit. It is not; it is test data the - * app would never have produced. So the booking under test here is made by the - * app, exactly as a user's would be, and only the edit is the subject. + * over it, so an API-made booking opens the edit flow with NO room on it. + * Measured, and it cost a first draft of this file — it looked like the app + * losing the room on edit. It is not; it is test data the app would never have + * produced. Measured again on the new flow, 13 Oct 2026: the edit opens with + * the title and the times, and the room step refuses to go on because no room + * is selected. So the booking under test here is made by the app, exactly as a + * user's would be, and only the edit is the subject. * * Both assert on the BACKEND afterwards. The schedule updates itself * optimistically, so the screen showing the new time proves nothing at all. + * + * ## The edit runs through all three steps + * + * Edit opens `meeting-flow-new` on its first step, with the booking loaded. + * That step does not show the room. The room shows in the summary of the last + * step, and Confirm there sends the PATCH. So each test walks the three steps + * and reads the summary before it sends. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; @@ -36,7 +45,7 @@ import { roomForWorker } from '../../../../e2e/support/room/room.seed'; import { releaseRoom } from '../../../../e2e/support/room/room.api'; import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; import { RoomSchedulePage } from '../../../../e2e/support/room/schedule.page'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; const DAY = 86_400; @@ -83,18 +92,24 @@ test.describe('editing a room booking', () => { await schedule.showDayOf(from.date_ms); await schedule.startEdit(booking_id); - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await expect( - form.chosenSpaces, - 'the edit form should open with the booked room already on it', - ).toHaveCount(1, { timeout: 30_000 }); + form.title, + 'the edit flow should open with the booking loaded', + ).toHaveValue(title, { timeout: 30_000 }); - // Same trap as booking: the time fields reach the model - // asynchronously and the confirm screen snapshots the model when it - // opens. Set, settle, then confirm. await form.setStartTime(hhmm(to.start)); - await staffPage.waitForTimeout(4_000); - await form.confirmAndSend(); + await form.toRoomStep(); + await form.toOptionsStep(); + expect( + await form.chosenRoomNames(), + 'the edit should carry the booked room through without a new choice', + ).toEqual([room.name]); + await form.confirm.click(); + await expect( + form.successContinue, + 'the edit was not accepted', + ).toBeVisible({ timeout: 30_000 }); await expect(async () => { const updated = await getBooking(staffApi, booking_id!); @@ -151,27 +166,29 @@ test.describe('editing a room booking', () => { await schedule.showDayOf(slot.date_ms); await schedule.startEdit(booking_id); - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await expect( - form.chosenSpaces, - 'the edit form should open with the booked room already on it', - ).toHaveCount(1, { timeout: 30_000 }); - // The form holds one room, so the old one comes off before the new - // one goes on — otherwise the picker treats the click as unselecting. - await form.removeRoom(); - await form.chooseRoom(other.name); - - // Wait for the NAME to change, not just for a room to be present. - // The count is 1 either way, so it cannot see this swap — and the - // choice reaches the model asynchronously while the confirm screen - // snapshots the model when it opens. A run under full parallel load - // saved the ORIGINAL room this way: the room was picked, the - // snapshot was taken first, and the PATCH carried the old asset id. - await expect(async () => { - expect(await form.chosenRoomNames()).toEqual([other.name]); - }).toPass({ timeout: 30_000 }); - await staffPage.waitForTimeout(4_000); - await form.confirmAndSend(); + form.title, + 'the edit flow should open with the booking loaded', + ).toHaveValue(title, { timeout: 30_000 }); + + // A click on a room in the list takes it in place of the room that + // was selected, so the old room does not have to come off first. + await form.toRoomStep(); + await form.selectRoom(other.name); + await form.toOptionsStep(); + + // Check the NAME in the summary, not just that a room is present. + // The count is 1 either way, so it cannot see this swap. + expect( + await form.chosenRoomNames(), + 'the summary must show the new room before the edit is sent', + ).toEqual([other.name]); + await form.confirm.click(); + await expect( + form.successContinue, + 'the edit was not accepted', + ).toBeVisible({ timeout: 30_000 }); await expect(async () => { const updated = await getBooking(staffApi, booking_id!); diff --git a/apps/workplace/e2e/local/room-favourites.spec.ts b/apps/workplace/e2e/local/room-favourites.spec.ts index fed0dbdc14b..723ebb2dd4e 100644 --- a/apps/workplace/e2e/local/room-favourites.spec.ts +++ b/apps/workplace/e2e/local/room-favourites.spec.ts @@ -8,14 +8,17 @@ * * So this asserts twice, on two different things: * - * 1. the BACKEND holds the room id in `favourite_spaces` afterwards. The write + * 1. the BACKEND holds the room id in `favourite_rooms` afterwards. The write * is debounced (~2.4 seconds in `SettingsService.saveUserSetting`) and sends * the whole settings blob as one `PUT /metadata/{user_id}`, so a spec that * read it immediately would see the old value and call it a bug. - * 2. the "Favorites Only" filter then narrows the picker to it. The list is - * asserted to hold more than one room BEFORE the filter goes on, so + * 2. the "Favorites Only" filter then narrows the room list to it. The list + * is asserted to hold more than one room BEFORE the filter goes on, so * "narrowed to one" cannot pass in a stack that only has one room to offer. * + * The list and its filters are the second step of the meeting flow, and that + * step needs a title. No booking is made. + * * ## Cleanup matters more here than anywhere else in these specs * * Every other room spec cleans up bookings. This one changes a USER's saved @@ -24,14 +27,16 @@ * `setFavouriteSpaces` deliberately reads-modifies-writes so it cannot wipe the * visitor data as collateral. */ +import { uniqueTitle } from '../../../../e2e/support/api'; import { test, expect } from '../../../../e2e/support/fixtures'; import { roomForWorker } from '../../../../e2e/support/room/room.seed'; import { + FAVOURITE_ROOMS_KEY, readUserSettings, setFavouriteSpaces, } from '../../../../e2e/support/room/room.api'; import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; test.describe('favourite rooms', () => { test('a room marked as a favourite is saved and can be filtered to', async ({ @@ -46,48 +51,41 @@ test.describe('favourite rooms', () => { await useSettings(staffPage, ROOM_BASE_SETTINGS); try { - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await form.open(); - await form.addSpaceButton.click(); - const rows = staffPage.locator('button[name="select-space"]'); - await expect(rows.first(), 'the room picker did not open').toBeVisible({ - timeout: 20_000, - }); - const offered_before = await rows.count(); + await form.title.fill(uniqueTitle('E2E Room Favourite')); + await form.toRoomStep(); + const offered_before = await form.offeredRoomNames(); expect( - offered_before, - 'the picker must offer more than one room, or filtering down to one ' + + offered_before.length, + 'the list must offer more than one room, or filtering down to one ' + 'proves nothing', ).toBeGreaterThan(1); - const row = staffPage.locator('li[space]').filter({ hasText: room.name }); - await expect( - row, - `no row for ${room.name} in the picker — it offers ${offered_before}`, - ).toHaveCount(1, { timeout: 20_000 }); - await row.locator('button[name="toggle-space-favourite"]').first().click(); + // The favourites were set to none above, so this click turns the + // favourite ON. + await form.showRoom(room.name); + await form.favouriteButton(room.name).click(); // The write is debounced, so poll rather than read once. await expect(async () => { const settings = await readUserSettings(staffApi); expect( - settings.favourite_spaces ?? [], + settings[FAVOURITE_ROOMS_KEY] ?? [], `${room.name} (${room.id}) should be saved as a favourite room`, ).toContain(room.id); }).toPass({ timeout: 30_000 }); - // "Favorites Only" is a toggle in the picker's filter panel. Its - // label is translated, so it is reached through the `favs` section - // attribute instead. - await staffPage.locator('section[favs] settings-toggle button').first().click(); - await expect( - rows, - 'with Favorites Only on, the picker should offer just the favourite', - ).toHaveCount(1, { timeout: 20_000 }); - await expect( - staffPage.locator('li[space]').filter({ hasText: room.name }), - 'and the one it offers should be the room that was favourited', - ).toHaveCount(1, { timeout: 20_000 }); + // "Favorites Only" is a toggle in the filter column. Polled: the + // list shows its old answer until the new query lands. + await form.setToggle(form.favouritesOnly, true); + await expect(async () => { + expect( + await form.offeredRoomNames(), + 'with Favorites Only on, the list should offer just the room ' + + 'that was favourited', + ).toEqual([room.name]); + }).toPass({ timeout: 30_000 }); } finally { // Not optional. This is a saved user setting, not a booking. await setFavouriteSpaces(staffApi, []); diff --git a/apps/workplace/e2e/local/room-features.spec.ts b/apps/workplace/e2e/local/room-features.spec.ts index 5bf36a1aac3..0af66c62b8c 100644 --- a/apps/workplace/e2e/local/room-features.spec.ts +++ b/apps/workplace/e2e/local/room-features.spec.ts @@ -1,31 +1,32 @@ /** - * ROOM-29 — the picker's facilities filter. + * ROOM-29 — the facilities filter of the room list. * * A user looking for "a room with a projector" uses this, and it is the only - * filter in the room picker driven by data on the ROOM rather than by the - * meeting. If it stopped narrowing the list, the picker would look perfectly + * filter of the room step driven by data on the ROOM rather than by the + * meeting. If it stopped narrowing the list, the list would look perfectly * healthy and quietly offer rooms without the equipment somebody needs. * * ## The feature has to exist before the filter can * * The filter section is built from whatever features the loaded rooms carry - * (`space-filters.component.ts`), so with no room carrying anything it is not - * rendered at all. `room.seed.ts` therefore puts one feature — `E2E Projector` — - * on the `alt` room and nothing else, which gives filtering by it a right - * answer: one room in, the rest out. + * (`meeting-flow-space-select.component.ts`), so with no room carrying anything + * it is not rendered at all. `room.seed.ts` therefore puts one feature — + * `E2E Projector` — on the `alt` rooms and nothing else, which gives filtering + * by it a right answer: the `alt` rooms in, the rest out. * * Two things learned putting it there, both recorded in the seeder: a System * PATCH needs `version` in the QUERY STRING (in the body it is ignored and the * request 422s), and `features` is an array — a space-separated string is a 400 * from the JSON parser. */ +import { uniqueTitle } from '../../../../e2e/support/api'; import { test, expect } from '../../../../e2e/support/fixtures'; import { ALT_ROOM_FEATURE } from '../../../../e2e/support/room/room.env'; import { roomForWorker } from '../../../../e2e/support/room/room.seed'; import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; -test.describe('the room picker facilities filter', () => { +test.describe('the room list facilities filter', () => { test('filtering by a feature narrows the list to the room that has it', async ({ staffPage, }, testInfo) => { @@ -34,74 +35,47 @@ test.describe('the room picker facilities filter', () => { await useSettings(staffPage, ROOM_BASE_SETTINGS); - const form = new MeetingForm(staffPage); + // The list and its filters are the second step of the flow, and that + // step needs a title. No booking is made. + const form = new MeetingFlow(staffPage); await form.open(); - // The small rooms are only offered at the smallest size, and more rooms - // on screen makes the "before" count below more meaningful. - await form.setRoomSize(1); - await form.addSpaceButton.click(); - await expect( - staffPage.locator('button[name="select-space"]').first(), - 'the room picker did not open', - ).toBeVisible({ timeout: 20_000 }); + await form.title.fill(uniqueTitle('E2E Room Features')); + await form.toRoomStep(); - const offered = async () => { - const texts = await staffPage - .locator('button[name="select-space"]') - .allInnerTexts(); - return texts.map((t) => t.split('\n')[0].trim()); - }; - - const before = await offered(); + const before = await form.offeredRoomNames(); expect( before.length, - 'the picker must offer more than one room, or narrowing to one proves ' + - 'nothing', + 'the list must offer more than one room, or narrowing it proves nothing', ).toBeGreaterThan(1); expect(before, 'precondition: both rooms are offered to begin with').toContain( featured.name, ); expect(before).toContain(plain.name); - // The filter lives in the picker's filters panel, in a `features` - // section, as a toggle per feature. Matched through the section - // attribute and the feature's own name — which is test data we seeded, - // not app copy. - const section = staffPage.locator('section[features]'); - const present = await section - .first() - .waitFor({ state: 'visible', timeout: 10_000 }) - .then(() => true) - .catch(() => false); - if (!present) { + // The filter column has a toggle per feature. It is matched on the + // name of the feature — which is test data we seeded, not app copy. + const toggle = form.featureToggle(ALT_ROOM_FEATURE); + if (!(await toggle.count())) { throw new Error( - `no facilities section in the picker. It is only rendered when the ` + - `loaded rooms carry features — check room.seed.ts put ` + - `"${ALT_ROOM_FEATURE}" on ${featured.name}. Rooms offered: ` + - `${JSON.stringify(before)}`, + `no toggle for "${ALT_ROOM_FEATURE}" in the filter column. The ` + + `facilities section is only rendered when the loaded rooms carry ` + + `features — check room.seed.ts put "${ALT_ROOM_FEATURE}" on ` + + `${featured.name}. Rooms offered: ${JSON.stringify(before)}`, ); } - const toggle = section - .locator('settings-toggle') - .filter({ hasText: ALT_ROOM_FEATURE }) - .first(); - await expect( - toggle, - `no toggle for "${ALT_ROOM_FEATURE}" in the facilities section`, - ).toBeVisible({ timeout: 10_000 }); - await toggle.locator('button').first().click({ timeout: 10_000 }); + await form.setToggle(toggle, true); await expect(async () => { - const after = await offered(); + const after = await form.offeredRoomNames(); expect( after, - `with "${ALT_ROOM_FEATURE}" required, only ${featured.name} should be ` + - `offered. Got ${JSON.stringify(after)}`, + `with "${ALT_ROOM_FEATURE}" required, ${featured.name} should still ` + + `be offered. Got ${JSON.stringify(after)}`, ).toContain(featured.name); expect( after, `and ${plain.name}, which has no features, must be filtered out`, ).not.toContain(plain.name); - }).toPass({ timeout: 20_000 }); + }).toPass({ timeout: 30_000 }); }); }); diff --git a/apps/workplace/e2e/local/room-recurring.spec.ts b/apps/workplace/e2e/local/room-recurring.spec.ts index 6852331e308..9d408bc9840 100644 --- a/apps/workplace/e2e/local/room-recurring.spec.ts +++ b/apps/workplace/e2e/local/room-recurring.spec.ts @@ -1,13 +1,23 @@ /** * ROOM-25 — a recurring room booking stores its pattern, and the instances exist. * - * ## The headline: a recurring meeting booked through the app disappears + * ## State on the new meeting flow * - * Measured 2026-09-17, and it is a NEW finding — **ROOM-B10**. It is the worst - * of the room findings by outcome, because the user is shown a success screen - * and then cannot find the booking anywhere. + * Measured 13 Oct 2026 through `meeting-flow-new`: the weekly meeting is + * stored with the weekday of its date in `recurrence_days`, and the listing + * returns it on its own day and one and two weeks later. Both tests are + * active. * - * Choosing "Weekly on Tuesday" on the form and confirming gives: + * The rest of this note is the finding that the first test was written for. + * It is kept because it says what the assertions guard against. + * + * ## ROOM-B10: a recurring meeting booked through the app disappeared + * + * Measured 2026-09-17 on the flow that this one replaced. It was the worst of + * the room findings by outcome, because the user was shown a success screen + * and then could not find the booking anywhere. + * + * Choosing "Weekly on Tuesday" on the form and confirming gave: * * POST /api/staff/v1/bookings -> 201 * { booking_type: 'room', recurrence_type: 'daily', @@ -15,21 +25,21 @@ * * `recurrence_type: 'daily'` is CORRECT and not the bug: `toBookingRecurrence` * deliberately encodes a weekly pattern as daily-plus-a-weekday-bitmask - * (`libs/common/src/lib/recurrence.ts`). The bug is that the bitmask is **0** — - * no weekday at all — so the pattern matches no day, and: + * (`libs/common/src/lib/recurrence.ts`). The bug was that the bitmask was **0** — + * no weekday at all — so the pattern matched no day, and: * * GET /bookings/ -> 200, the row is there * GET /bookings?type=room&period_start=… -> the booking is NOWHERE * * Tried with five different query shapes, including an eleven-day window with * `limit=500`, `include_deleted`, `include_checked_out`, an `email` filter and a - * `zones` filter. Only a direct fetch by id finds it. So the meeting is stored, - * the user is told it worked, and no listing, schedule or cancel screen can ever - * show it to them. + * `zones` filter. Only a direct fetch by id found it. So the meeting was stored, + * the user was told it worked, and no listing, schedule or cancel screen could + * ever show it to them. * - * ## Why the cause is certainly client-side, and that is what the control proves + * ## Why the cause was certainly client-side, and that is what the control proves * - * The second test below is ACTIVE and passing. It creates the same recurrence + * The second test below creates the same recurrence * **correctly formed** straight through the API — `recurrence_days: 8` * (Wednesday), a `recurrence_end`, and `timezone`, which is mandatory for a * recurring booking and is a 422 without it — and the backend then behaves @@ -40,20 +50,19 @@ * Wed +13d -> listed * Wed +20d -> listed * - * So recurrence expansion works. The app's own payload is what is malformed. + * So recurrence expansion works. The app's own payload was what was malformed. * Keeping that control in the file is the difference between "recurring bookings * are broken" and a finding a developer can act on in one sitting. * * Cause, in one line: `toBookingRecurrence` builds the bitmask from - * `r.weekdays`, and the form's model reaches it with `weekdays` empty — the UI - * derives the label "Weekly on Tuesday" from the chosen date, but never puts + * `r.weekdays`, and the form's model reached it with `weekdays` empty — the UI + * derived the label "Weekly on Tuesday" from the chosen date, but never put * that day in the model. * * ## Preconditions * * `app.events.allow_recurrence` (ROOM_RECURRENCE_MODE) or the control is not - * rendered. The component also hides it for meetings longer than 24 hours, so a - * recurrence test cannot also be an all-day test. + * rendered. The details step also hides it for meetings longer than 24 hours. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { STAFF_API, deleteBooking, uniqueTitle } from '../../../../e2e/support/api'; @@ -69,7 +78,7 @@ import { ROOM_RECURRENCE_MODE, useSettings, } from '../../../../e2e/support/room/room.settings'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; import { setDate } from '../../../../e2e/support/room/room.flows'; import { fromZonedTime } from 'date-fns-tz'; @@ -85,11 +94,15 @@ function dayBounds(days_ahead: number) { } /** Room bookings on one local day. */ -async function roomBookingsOn( +function roomBookingsOn(api: Parameters[0], days_ahead: number) { + return roomBookingsIn(api, dayBounds(days_ahead)); +} + +/** Room bookings in a window of unix seconds. */ +async function roomBookingsIn( api: Parameters[0], - days_ahead: number, + { from, to }: { from: number; to: number }, ) { - const { from, to } = dayBounds(days_ahead); const res = await api.get(`${STAFF_API}/bookings`, { params: { type: 'room', @@ -107,8 +120,8 @@ async function roomBookingsOn( test.describe('recurring room bookings', () => { /** - * `fixme` on ROOM-B10. The assertions are what SHOULD happen; drop the - * `fixme` once the form puts the chosen weekday into the model. + * The guard for ROOM-B10. The weekly option is picked on the details step, + * and the booking is then read from the listing, not by its id. */ test('ROOM-25: a recurring meeting stores its pattern and its instances appear', async ({ staffPage, @@ -130,57 +143,83 @@ test.describe('recurring room bookings', () => { ...ROOM_BASE_SETTINGS, ...ROOM_RECURRENCE_MODE, }); - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await form.open(); + await form.title.fill(title); // The DATE first: the recurrence options are named after the weekday of // the chosen date ("Weekly on Tuesday"), so they are wrong before it is // set, and the start-time list is for the wrong day too. await setDate(form, staffPage, slot.date_ms); - await staffPage.waitForTimeout(2_000); + await form.setStartTime( + `${String(ROOM_SLOTS_3.recurring.hour).padStart(2, '0')}:00`, + ); - const recurrence = staffPage.locator('recurrence-field mat-select'); await expect( - recurrence, + form.recurrence, 'the recurrence control is missing — is `app.events.allow_recurrence` ' + 'set? See ROOM_RECURRENCE_MODE', ).toBeVisible({ timeout: 20_000 }); - await recurrence.click(); - // Match the accessible name: the option's raw text starts with template - // whitespace, so a `^` anchor on `hasText` never matches it. - const weekly = staffPage.getByRole('option', { name: /^Weekly on/ }); + // The options are matched on their `value`, which is not translated. + const weekly = staffPage.locator('mat-option[value="weekly"]'); + await form.recurrence.click(); await expect( - weekly.first(), - 'the recurrence menu offered no "Weekly on " option', + weekly, + 'the recurrence menu offered no weekly option', ).toBeVisible({ timeout: 15_000 }); - await weekly.first().click(); - await staffPage.waitForTimeout(2_000); - - await form.title.fill(title); - await form.chooseRoom(room.name); - await staffPage.waitForTimeout(2_000); - await form.setStartTime( - `${String(ROOM_SLOTS_3.recurring.hour).padStart(2, '0')}:00`, - ); - await staffPage.waitForTimeout(4_000); + await weekly.click(); + await expect(weekly, 'the recurrence menu stayed open').toBeHidden({ + timeout: 10_000, + }); - // PROVE the form still holds the pattern immediately before sending, so - // "the app threw the choice away" can be told apart from "the form + // PROVE the flow still holds the pattern before the details are left, + // so "the app threw the choice away" can be told apart from "the form // reverted itself" — the same guard the rest of the room specs carry. + await form.recurrence.click(); + await expect( + weekly, + 'the recurrence field must still hold the weekly pattern', + ).toHaveAttribute('aria-selected', 'true', { timeout: 10_000 }); + await staffPage.keyboard.press('Escape'); + await expect(weekly, 'the recurrence menu stayed open').toBeHidden({ + timeout: 10_000, + }); + + await form.toRoomStep(); + await form.selectRoom(room.name); + await form.toOptionsStep(); + // The summary has a row with the `update` icon only when the meeting + // that Confirm sends has a recurrence pattern. await expect( - recurrence, - 'the recurrence field must still show a weekly pattern at send time', - ).toContainText(/Weekly on/, { timeout: 10_000 }); + form.options.locator('icon', { hasText: 'update' }), + 'the summary of the last step must show that the meeting repeats', + ).toBeVisible({ timeout: 10_000 }); try { - await form.confirmAndSend(); + // The id is read from the response, so that the booking is deleted + // afterwards even when no listing returns it. + const [response] = await Promise.all([ + staffPage.waitForResponse( + (r) => + r.url().includes(`${STAFF_API}/bookings`) && + r.request().method() === 'POST', + { timeout: 30_000 }, + ), + form.confirm.click(), + ]); + const sent = await response.text().catch(() => ''); + expect( + response.ok(), + `the meeting was refused outright: HTTP ${response.status()} ${sent}`, + ).toBe(true); + created.push(JSON.parse(sent).id); await expect( - form.successPanel, - 'the meeting was refused outright', + form.successContinue, + 'the flow did not reach its success screen', ).toBeVisible({ timeout: 30_000 }); // 1. The FIRST instance must be findable on its own day. This is the - // assertion that fails today: the booking exists but no listing - // returns it, because its weekday bitmask is empty. + // assertion that failed with ROOM-B10: the booking existed but no + // listing returned it, because its weekday bitmask was empty. await expect .poll( async () => { @@ -205,7 +244,6 @@ test.describe('recurring room bookings', () => { const [first] = (await roomBookingsOn(staffApi, THIRD_DAY)).filter( (booking) => booking.asset_id === room.id, ); - created.push(first.id); // 2. The pattern must name the weekday it was booked on. expect( @@ -247,29 +285,39 @@ test.describe('recurring room bookings', () => { /** * The control that makes ROOM-B10 actionable. * - * Active and passing. It sends the recurrence the app SHOULD have sent — - * straight through the API, no form — and proves the backend expands it - * correctly. Without this, ROOM-B10 reads as "recurring room bookings do not + * It sends the recurrence the app SHOULD send — straight through the API, + * no form — and proves the backend expands it correctly. Without this, a + * failure of the test above reads as "recurring room bookings do not * work", which would send somebody looking in the wrong place. */ test('ROOM-25b: a correctly formed weekly recurrence is expanded by the backend', async ({ staffApi, }) => { const room = await roomForWorker(test.info().parallelIndex); - // Two days clear of the fixme test's slot, so the two cannot contend for - // the same room. + // One day clear of the fixme test's slot, so the two cannot contend + // for the same room. const days_ahead = THIRD_DAY + 1; -const sydney_now = new Date( - new Date().toLocaleString('en-US', { - timeZone: 'Australia/Sydney', - }), -); -sydney_now.setDate(sydney_now.getDate() + days_ahead); -sydney_now.setHours(14, 0, 0, 0); + // The booking is at 14:00 in SYDNEY, the timezone it is sent with. + const sydney_now = new Date( + new Date().toLocaleString('en-US', { + timeZone: 'Australia/Sydney', + }), + ); + sydney_now.setDate(sydney_now.getDate() + days_ahead); + sydney_now.setHours(14, 0, 0, 0); -const start = fromZonedTime(sydney_now, 'Australia/Sydney'); -const booking_start = Math.floor(start.valueOf() / 1000); -const weekday_bit = 1 << sydney_now.getDay(); + const start = fromZonedTime(sydney_now, 'Australia/Sydney'); + const booking_start = Math.floor(start.valueOf() / 1000); + const weekday_bit = 1 << sydney_now.getDay(); + // The SYDNEY day of the booking, some days on. Not the local day of + // the machine: on a runner in UTC the two are different dates from + // 13:00 to 24:00 UTC, and the listing of the local day then misses + // the booking. 14:00 is far from both ends of the day, so the window + // also holds across a change of daylight saving. + const sydneyDay = (days_on: number) => { + const from = booking_start - 14 * 3600 + days_on * DAY; + return { from, to: from + DAY }; + }; const title = uniqueTitle('E2E Recurring API'); await releaseRoom( @@ -315,10 +363,7 @@ const weekday_bit = 1 << sydney_now.getDay(); // The same day, one and two weeks on: present. This is the behaviour // the app's own booking never gets. for (const week of [0, 1, 2]) { - const rows = await roomBookingsOn( - staffApi, - days_ahead + week * 7, - ); + const rows = await roomBookingsIn(staffApi, sydneyDay(week * 7)); expect( rows.some((booking) => booking.id === id), `a weekly recurrence must be returned for week ${week}. If this ` + @@ -328,7 +373,7 @@ const weekday_bit = 1 << sydney_now.getDay(); } // And NOT on the following day, or the pattern is really daily. - const next_day = await roomBookingsOn(staffApi, days_ahead + 1); + const next_day = await roomBookingsIn(staffApi, sydneyDay(1)); expect( next_day.some((booking) => booking.id === id), 'a weekly recurrence must NOT be returned for the day after. If it ' + diff --git a/apps/workplace/e2e/local/room-rules.spec.ts b/apps/workplace/e2e/local/room-rules.spec.ts index ef3e3af21b3..1f2e35eb19a 100644 --- a/apps/workplace/e2e/local/room-rules.spec.ts +++ b/apps/workplace/e2e/local/room-rules.spec.ts @@ -3,54 +3,35 @@ * * Booking rules are how a site says "this room is not for general booking" — * a boardroom, a room under repair, a floor reserved for an event. They live in - * `booking_rules` metadata on the zone and are applied client-side - * (`filterResourcesFromRules`), so a change that stopped reading them would - * offer restricted rooms to everybody, and the picker would look completely + * `room_booking_rules` metadata on the building and are applied client-side + * (`event-form.service.ts`), so a change that stopped reading them would offer + * restricted rooms to everybody, and the room list would look completely * normal doing it. * - * Nothing has ever tested them, for the same reason as several other rows: the - * stack seeds no rules, so there was nothing to observe. This spec writes one, + * Nothing else tests them, for the same reason as several other rows: the + * stack seeds no rules, so there is nothing to observe. This spec writes one, * asserts its effect, and clears it. * * The condition is `resource_ids`, the narrowest one available, so exactly one * room is hidden and the others are the control. Both halves matter: the hidden - * room disappears AND the rest stay, or "the picker is empty" would pass. + * room disappears AND the rest stay, or "the list is empty" would pass. * - * ## `fixme`, and UNRESOLVED — read this before trusting either answer + * The ruleset is `{ zone: '*', conditions: { resource_ids: [] }, + * rules: { hidden: true } }`. Measured on the room list of the new meeting + * flow, 13 Oct 2026: the room is offered before the rule is written and is + * not offered after it, and the other rooms stay. * - * The rule has no effect, and I have not established why. What is known: - * - * - the app fetches `GET /metadata/booking_rules/bulk?parent_ids=`, - * and the response comes back keyed by the BUILDING's id carrying the - * building's own document — despite `parent_ids` reading like a query about - * children. `event-form.service.ts` looks them up as `rules[building.id]`. - * - `setRoomBookingRules` writes the document to the building AND every level, - * so the app is definitely being served a ruleset. - * - the ruleset is `{ zone: '*', conditions: { resource_ids: [] }, - * rules: { hidden: true } }`. On paper that satisfies both - * `rulesForResource` (a `*` zone matches any resource) and - * `checkRulesMatch` (one condition, one match). - * - the room is still offered. - * - * So it is one of two things, and they need different owners: - * - * 1. the ruleset shape is wrong — most likely `resource_ids` wants something - * other than the system id (the room's email is the obvious candidate), or - * `zone` must name a real zone rather than `*`; - * 2. the picker does not apply `hidden` at all, which would be a real defect. - * - * The next step is to set `window.debug_booking_rules = true` in the page before - * opening the picker: `rulesForResource` then logs every ruleset it matched or - * rejected, per resource, which answers this in one run. I have not done that, - * so this test is parked rather than reported as a bug. + * The room list shows ten rooms a page, so "not offered" is read from every + * page (`offeredRoomNames`). */ +import { uniqueTitle } from '../../../../e2e/support/api'; import { test, expect } from '../../../../e2e/support/fixtures'; import { roomForWorker, setRoomBookingRules, } from '../../../../e2e/support/room/room.seed'; import { ROOM_BASE_SETTINGS, useSettings } from '../../../../e2e/support/room/room.settings'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; test.describe('zone booking rules', () => { test('a room hidden by a booking rule is not offered, and the others still are', async ({ @@ -61,25 +42,18 @@ test.describe('zone booking rules', () => { await useSettings(staffPage, ROOM_BASE_SETTINGS); + // The room list is the second step of the flow, and that step needs a + // title. No booking is made. + const title = uniqueTitle('E2E Room Rules'); + try { // No rules yet: both rooms are offered. This is the baseline the // assertion below is measured against. - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await form.open(); - await form.setRoomSize(1); - await form.addSpaceButton.click(); - await expect( - staffPage.locator('button[name="select-space"]').first(), - 'the room picker did not open', - ).toBeVisible({ timeout: 20_000 }); - - const offered = async () => { - const texts = await staffPage - .locator('button[name="select-space"]') - .allInnerTexts(); - return texts.map((t) => t.split('\n')[0].trim()); - }; - const before = await offered(); + await form.title.fill(title); + await form.toRoomStep(); + const before = await form.offeredRoomNames(); expect( before, `precondition: ${hidden.name} is offered before any rule exists. ` + @@ -89,17 +63,13 @@ test.describe('zone booking rules', () => { // Now hide it, and reload so the app reads the new metadata. await setRoomBookingRules([hidden.id]); await staffPage.reload(); - const after_form = new MeetingForm(staffPage); + const after_form = new MeetingFlow(staffPage); await after_form.open(); - await after_form.setRoomSize(1); - await after_form.addSpaceButton.click(); - await expect( - staffPage.locator('button[name="select-space"]').first(), - 'the room picker did not open after the rule was written', - ).toBeVisible({ timeout: 20_000 }); + await after_form.title.fill(title); + await after_form.toRoomStep(); await expect(async () => { - const after = await offered(); + const after = await after_form.offeredRoomNames(); expect( after, `${hidden.name} is hidden by a booking rule and must NOT be ` + @@ -108,7 +78,7 @@ test.describe('zone booking rules', () => { expect( after, `and ${visible.name}, which no rule mentions, must still be — ` + - `otherwise this is "the picker went empty" rather than a rule ` + + `otherwise this is "the list went empty" rather than a rule ` + `being applied`, ).toContain(visible.name); }).toPass({ timeout: 30_000 }); diff --git a/apps/workplace/e2e/local/room-times.spec.ts b/apps/workplace/e2e/local/room-times.spec.ts index de40794ecd7..30b739673da 100644 --- a/apps/workplace/e2e/local/room-times.spec.ts +++ b/apps/workplace/e2e/local/room-times.spec.ts @@ -4,7 +4,7 @@ * * Test 1 asks the plain question nothing else here asks: if a user picks a day, * a start time and a length, are THOSE the times that get stored? Everything - * else in the room specs takes whatever the form offers, so a form that ignored + * else in the room specs takes whatever the flow offers, so a flow that ignored * the pickers would have passed the lot. * * Test 2 covers the two settings that fence the pickers in — a maximum meeting @@ -12,7 +12,12 @@ * nothing else sees them, and both fail quietly in the same way: the option is * simply absent rather than refused. That is why this asserts on the options * OFFERED. "A three-hour meeting is rejected" would be testing something the - * form never lets you attempt. + * flow never lets you attempt. + * + * Test 3 asks the same question as test 1 for the LENGTH alone. + * + * All three set the day before they read or pick a time. The details step + * starts on today and offers only the times that are left of it. */ import { deleteBooking, @@ -20,10 +25,10 @@ import { uniqueTitle, } from '../../../../e2e/support/api'; import { expect, test } from '../../../../e2e/support/fixtures'; -import { MeetingForm } from '../../../../e2e/support/room/meeting-form.page'; +import { MeetingFlow } from '../../../../e2e/support/room/meeting-flow.page'; import { releaseRoom } from '../../../../e2e/support/room/room.api'; import { ROOM_SLOTS, slotFor } from '../../../../e2e/support/room/room.env'; -import { bookRoomViaUI } from '../../../../e2e/support/room/room.flows'; +import { bookRoomViaUI, setDate } from '../../../../e2e/support/room/room.flows'; import { roomForWorker } from '../../../../e2e/support/room/room.seed'; import { LIMITED_HOURS, @@ -58,16 +63,16 @@ test.describe('room booking times', () => { await useSettings(staffPage, ROOM_BASE_SETTINGS); try { - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await form.open(); + await setDate(form, staffPage, slot.date_ms); - // Pick from what the form itself offers rather than naming an hour: - // the options run on a fixed step, so a hardcoded "14:30" is a test - // that stops working after 14:30. + // Pick from what the flow itself offers rather than naming an hour. + // The second option, so the choice is not the default of the field. const times = await form.startTimeOptions(); expect( times.length, - 'the form offered no start times at all — nothing can be chosen', + 'the flow offered no start times at all — nothing can be chosen', ).toBeGreaterThan(1); const lengths = await form.durationOptions(); const chosen_time = times[1]; @@ -95,15 +100,12 @@ test.describe('room booking times', () => { ).toBe(new Date(slot.date_ms).toDateString()); expect( start.getHours() * 60 + start.getMinutes(), - `and start at the ${chosen_time} that was picked, not the form's default`, + `and start at the ${chosen_time} that was picked, not the flow's default`, ).toBe(minutesOfDay(chosen_time)); - // The LENGTH is deliberately not asserted here — see the `fixme` - // below. ROOM-B3: the form and the confirmation both show the length - // that was picked, and the booking is made for 60 minutes anyway. expect( (stored.booking_end - stored.booking_start) / 60, - 'the meeting has some length at all', - ).toBeGreaterThan(0); + `and run for the ${chosen_length} minutes that were picked`, + ).toBe(chosen_length); } finally { if (booking_id != null) await deleteBooking(staffApi, booking_id); } @@ -117,24 +119,27 @@ test.describe('room booking times', () => { ...LIMITED_HOURS, }); - const form = new MeetingForm(staffPage); + const form = new MeetingFlow(staffPage); await form.open(); + // A day with all of its hours ahead of it. Today offers only what is + // left of the bookable hours, which can be nothing. + await setDate(form, staffPage, slotFor(ROOM_SLOTS.times.limits).date_ms); const lengths = await form.durationOptions(); expect( lengths.length, - 'the form offered no meeting lengths at all, so the cap cannot be judged', + 'the flow offered no meeting lengths at all, so the cap cannot be judged', ).toBeGreaterThan(0); expect( Math.max(...lengths), - `with a ${MAX_MINUTES} minute cap nothing longer may be offered — the form ` + + `with a ${MAX_MINUTES} minute cap nothing longer may be offered — the flow ` + `is the only place this is enforced, the backend stores any length`, ).toBeLessThanOrEqual(MAX_MINUTES); const times = await form.startTimeOptions(); expect( times.length, - 'the form offered no start times at all, so the hours cannot be judged', + 'the flow offered no start times at all, so the hours cannot be judged', ).toBeGreaterThan(0); expect( Math.min(...times.map(minutesOfDay)), @@ -148,29 +153,17 @@ test.describe('room booking times', () => { }); /** - * ROOM-14, blocked by ROOM-B3 — the meeting length chosen on the form is not - * the length booked. - * - * Measured while writing the test above. The form is asked for a 90 minute - * meeting; the duration field shows "1 hour 30 minutes", the confirmation - * shows 6:00 PM – 7:30 PM, and the request sent is: - * - * booking_start ............... 6:00 PM - * booking_end ................. 7:00 PM <- 60 minutes - * extension_data.event_end .... 7:30 PM <- the event knows it is 90 - * - * So the CalendarEvent carries the right end time and the Booking built from - * it does not: `newBookingFromCalendarEvent` takes `event.duration`, which - * is still the default. **A user is shown one time range and the room is - * held for another** — anyone booking a long meeting loses the second half - * of it, and the room looks free to everybody else. + * ROOM-14 — the meeting length chosen on the form is the length booked. * - * Order does not help: setting the length before the start time and after it - * both post 60. Not a timing problem either — the field still reads 90 four - * seconds later, immediately before the confirmation is sent. + * The flow that this one replaced booked 60 minutes whatever length was + * picked (ROOM-B3): the request carried `booking_end` at one hour while + * `extension_data.event_end` held the length that was asked for. A user + * was shown one time range and the room was held for another. This test + * was blocked on it. * - * `fixme`, so it costs nothing per run. Drop the marker once the app carries - * the chosen length through, and fold it back into the test above. + * Measured on the new flow, 13 Oct 2026: a 90 minute meeting is stored as + * 90 minutes, so the test is active. Test 1 asserts the length too, with a + * start time picked next to it. */ test('the meeting length chosen on the form is what gets booked', async ({ staffPage, diff --git a/apps/workplace/e2e/local/schedule-views.spec.ts b/apps/workplace/e2e/local/schedule-views.spec.ts new file mode 100644 index 00000000000..119144215b7 --- /dev/null +++ b/apps/workplace/e2e/local/schedule-views.spec.ts @@ -0,0 +1,196 @@ +/** + * NEW-YB-01 … NEW-YB-04 — the day, week and list views of `/your-bookings`, + * as a NON-ADMIN, against a real local backend. + * + * The `bookings-*.spec.ts` files drive the list view, where each booking is a + * card. The page opens on the day view, and these specs hold the stories of + * the day and week views and of the change between the three. + * + * Bookings are made through the API: the subject here is the schedule, and the + * booking flows have their own specs. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { deskFor } from '../../../../e2e/support/env'; +import { deleteBooking, releaseAsset, uniqueTitle } from '../../../../e2e/support/api'; +import { isLive } from '../../../../e2e/support/bookings/bookings.api'; +import { ScheduleViewsPage } from '../../../../e2e/support/bookings/schedule-views.page'; +import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api'; + +/** A local day as a unix-second window. `0` is today. */ +function dayBounds(offset: number) { + const start = new Date(); + start.setDate(start.getDate() + offset); + start.setHours(0, 0, 0, 0); + const from = Math.floor(start.valueOf() / 1000); + return { from, to: from + 86_399 }; +} + +/** One hour at midday of a local day. Midday is clear of the day boundaries. */ +function middayOn(offset: number) { + const start = dayBounds(offset).from + 12 * 3600; + return { start, end: start + 3600 }; +} + +/** + * A window that is in progress now. A booking that has ended has no actions, + * so a spec that cancels one cannot use `middayOn`. The start does not go back + * past midnight: the day view lists a booking by the day it starts on. + */ +function inProgress() { + const now = Math.floor(Date.now() / 1000); + const start = Math.max(now - 5 * 60, dayBounds(0).from); + return { start, end: start + 3600 }; +} + +test.describe('your bookings — the day, week and list views', () => { + test('NEW-YB-01: a booking of today shows on the day, week and list views', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Views'); + const schedule = new ScheduleViewsPage(staffPage); + const { from, to } = dayBounds(0); + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + const created = await createDeskBookingViaApi(staffApi, { + desk, + title, + ...middayOn(0), + }); + + try { + // No `view` parameter: the page opens on the day view. + await schedule.openView(); + await expect(schedule.dayBooking(title)).toBeVisible({ timeout: 30_000 }); + await expect(schedule.weekView).toHaveCount(0); + + await schedule.viewLink('week').click(); + await expect(staffPage).toHaveURL(/view=week/); + await expect(schedule.weekBooking(title)).toBeVisible(); + await expect(schedule.dayView).toHaveCount(0); + + await schedule.viewLink('list').click(); + await expect(staffPage).toHaveURL(/view=list/); + await expect(schedule.card(created.id)).toBeVisible(); + await expect(schedule.card(created.id)).toContainText(title); + } finally { + await deleteBooking(staffApi, created.id); + } + }); + + test('NEW-YB-02: the day view shows only the day it is on, and the topbar moves it', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const today_title = uniqueTitle('E2E Views Today'); + const tomorrow_title = uniqueTitle('E2E Views Tomorrow'); + const schedule = new ScheduleViewsPage(staffPage); + + await releaseAsset(staffApi, 'desk', desk.id, dayBounds(0).from, dayBounds(1).to); + // The booking of today is the control. Without it "tomorrow's booking + // is not shown" also passes on a view that has not loaded. + const today = await createDeskBookingViaApi(staffApi, { + desk, + title: today_title, + ...middayOn(0), + }); + const tomorrow = await createDeskBookingViaApi(staffApi, { + desk, + title: tomorrow_title, + ...middayOn(1), + }); + + try { + await schedule.openView('day'); + await expect(schedule.dayBooking(today_title)).toBeVisible({ timeout: 30_000 }); + await expect( + schedule.dayBooking(tomorrow_title), + 'a booking of tomorrow is not on the view of today', + ).toHaveCount(0); + + await schedule.nextDate.click(); + await expect(schedule.dayBooking(tomorrow_title)).toBeVisible({ timeout: 20_000 }); + await expect(schedule.dayBooking(today_title)).toHaveCount(0); + + await schedule.today.click(); + await expect(schedule.dayBooking(today_title)).toBeVisible({ timeout: 20_000 }); + await expect(schedule.dayBooking(tomorrow_title)).toHaveCount(0); + } finally { + await deleteBooking(staffApi, today.id); + await deleteBooking(staffApi, tomorrow.id); + } + }); + + test('NEW-YB-03: a booking cancelled from the day view is removed on the backend', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Views Cancel'); + const schedule = new ScheduleViewsPage(staffPage); + const { from, to } = dayBounds(0); + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + const created = await createDeskBookingViaApi(staffApi, { + desk, + title, + ...inProgress(), + }); + const live = () => isLive(staffApi, 'desk', created.id, from, to); + + try { + await schedule.openView('day'); + await schedule.dayBooking(title).click(); + const modal = staffPage.locator('booking-details-modal'); + await expect(modal, 'the details are those of the booking').toContainText(title); + + await schedule.openOverflowMenu(modal); + const cancel = await schedule.menuAction('delete'); + if (!cancel) throw new Error('the booking menu offers no cancel action'); + await cancel.click(); + await schedule.acceptConfirm(); + + await expect + .poll(live, { + message: 'the booking is deleted on the backend, not only on the view', + timeout: 20_000, + }) + .toBe(false); + await expect(schedule.dayBooking(title)).toHaveCount(0); + } finally { + if (await live()) await deleteBooking(staffApi, created.id); + } + }); + + test('NEW-YB-04: the Desks filter of the sidebar hides and shows a desk booking', async ({ + staffPage, + staffApi, + }, testInfo) => { + const desk = deskFor(testInfo.parallelIndex); + const title = uniqueTitle('E2E Views Filter'); + const schedule = new ScheduleViewsPage(staffPage); + const { from, to } = dayBounds(0); + + await releaseAsset(staffApi, 'desk', desk.id, from, to); + const created = await createDeskBookingViaApi(staffApi, { + desk, + title, + ...middayOn(0), + }); + + try { + await schedule.openView('day'); + await expect(schedule.dayBooking(title)).toBeVisible({ timeout: 30_000 }); + + await schedule.setShown('desk', false); + await expect(schedule.dayBooking(title)).toHaveCount(0); + + await schedule.setShown('desk', true); + await expect(schedule.dayBooking(title)).toBeVisible(); + } finally { + await deleteBooking(staffApi, created.id); + } + }); +}); diff --git a/apps/workplace/e2e/local/team-schedule.spec.ts b/apps/workplace/e2e/local/team-schedule.spec.ts new file mode 100644 index 00000000000..887d46adfa7 --- /dev/null +++ b/apps/workplace/e2e/local/team-schedule.spec.ts @@ -0,0 +1,190 @@ +/** + * NEW-TEAM-01, NEW-TEAM-02 — `/team-schedule`, as a NON-ADMIN, against a real + * local backend. + * + * ## Isolation + * + * The colleague is the non-admin of ANOTHER worker, so a booking made for + * them shows in that worker's own pages. Two rules keep that from being a + * race: + * + * - The booking is four weeks on. No spec looks at that week: the home page + * shows today only, and the other areas keep their slots within three weeks + * (see `desk.env.ts` and `home.env.ts`). + * - It is on THIS worker's desk, so no other worker competes for the window. + * + * The contacts and the favourites are documents of this worker's own user. + */ +import { APIRequestContext } from '@playwright/test'; +import { test, expect } from '../../../../e2e/support/fixtures'; +import { WORKERS, deskFor } from '../../../../e2e/support/env'; +import { + ENGINE_API, + STAFF_API, + asList, + deleteBooking, + uniqueTitle, + type Booking, +} from '../../../../e2e/support/api'; +import { otherStaffEmail } from '../../../../e2e/support/bookings/bookings.env'; +import { createDeskBookingViaApi } from '../../../../e2e/support/desk/desk.api'; +import { + readContacts, + readUserSettings, + setContacts, + setUserSetting, +} from '../../../../e2e/support/home/home.api'; +import { TeamSchedulePage } from '../../../../e2e/support/team/team-schedule.page'; + +/** How many weeks on the colleague booking is. See the isolation note. */ +const WEEKS_ON = 4; + +/** The user setting that holds the favourite colleagues. */ +const FAVOURITES_KEY = 'favourite_team_members'; + +/** + * The name the row shows for a colleague. + * + * The page reads each contact from the directory by address, so the row shows + * the directory name and not the name in the contacts document. + */ +async function directoryName(api: APIRequestContext, email: string): Promise { + const res = await api.get(`${ENGINE_API}/users/${encodeURIComponent(email)}`); + if (!res.ok()) { + throw new Error(`read user ${email} failed: HTTP ${res.status()} ${await res.text()}`); + } + return (await res.json()).name; +} + +/** Midday of the Monday that is `WEEKS_ON` weeks on, as a one hour window. */ +function mondaySlot() { + const day = new Date(); + day.setHours(12, 0, 0, 0); + // getDay() has Sunday as 0. The table has Monday as the first day. + const days_from_monday = (day.getDay() + 6) % 7; + day.setDate(day.getDate() - days_from_monday + WEEKS_ON * 7); + const start = Math.floor(day.valueOf() / 1000); + return { start, end: start + 3600 }; +} + +/** + * Delete the desk bookings that a colleague has on a desk in a window. + * + * `releaseAsset` cannot do this: it lists the caller's own bookings, and + * these belong to the colleague. The listing by address returns them. + */ +async function releaseColleagueDesk( + api: APIRequestContext, + email: string, + desk_id: string, + from: number, + to: number, +): Promise { + const res = await api.get(`${STAFF_API}/bookings`, { + params: { type: 'desk', email, period_start: String(from), period_end: String(to) }, + }); + if (!res.ok()) return; + for (const booking of asList(await res.json())) { + if (booking.asset_id === desk_id && !booking.deleted) { + await deleteBooking(api, booking.id); + } + } +} + +test.describe('team schedule', () => { + test('NEW-TEAM-01: a colleague from the contacts is listed, with their desk booking on its day', async ({ + staffPage, + staffApi, + }, testInfo) => { + const colleague = otherStaffEmail(testInfo.parallelIndex, WORKERS); + const name = await directoryName(staffApi, colleague); + const desk = deskFor(testInfo.parallelIndex); + const slot = mondaySlot(); + const team = new TeamSchedulePage(staffPage); + const before = await readContacts(staffApi); + + await releaseColleagueDesk(staffApi, colleague, desk.id, slot.start - 3600, slot.end + 3600); + await setContacts(staffApi, [{ email: colleague, name }]); + const created = await createDeskBookingViaApi(staffApi, { + desk, + title: uniqueTitle('E2E Team'), + ...slot, + user: { email: colleague }, + }); + + try { + await team.open(); + await expect(team.member(name), 'the contact is a row of the table').toBeVisible({ + timeout: 30_000, + }); + // This week the colleague has no desk booking from this spec. + await expect(team.day(name, 0)).not.toContainText(desk.id); + + await team.goForward(WEEKS_ON); + const monday = team.day(name, 0); + // The booking is made through the API with no display name, so + // the day shows the desk id. A booking from the app shows the name. + await expect(monday, 'Monday shows the desk of the booking').toContainText( + new RegExp(`${desk.id}|${desk.name}`), + { timeout: 20_000 }, + ); + await expect( + team.day(name, 1), + 'Tuesday has no booking, so it does not show the desk', + ).not.toContainText(new RegExp(`${desk.id}|${desk.name}`)); + } finally { + await deleteBooking(staffApi, created.id); + await setContacts(staffApi, before); + } + }); + + test('NEW-TEAM-02: a favourite colleague is saved, and the Favorites view lists only them', async ({ + staffPage, + staffApi, + }, testInfo) => { + const colleague = otherStaffEmail(testInfo.parallelIndex, WORKERS); + const name = await directoryName(staffApi, colleague); + const other = 'support@place.tech'; + const other_name = await directoryName(staffApi, other); + const team = new TeamSchedulePage(staffPage); + const contacts_before = await readContacts(staffApi); + const favourites_before: unknown = (await readUserSettings(staffApi))[FAVOURITES_KEY]; + + // Two contacts, no favourites: the view has something to filter out. + await setContacts(staffApi, [ + { email: colleague, name }, + { email: other, name: other_name }, + ]); + await setUserSetting(staffApi, FAVOURITES_KEY, []); + + try { + await team.open(); + await expect(team.member(name)).toBeVisible({ timeout: 30_000 }); + await expect(team.member(other_name)).toBeVisible(); + await expect(team.favourite(name).locator('icon')).toHaveText('star_outline'); + + await team.favourite(name).click(); + await expect(team.favourite(name).locator('icon')).toHaveText('star'); + + await team.viewLink('favorites').click(); + await expect(staffPage).toHaveURL(/view=favorites/); + await expect(team.member(name)).toBeVisible(); + await expect(team.member(other_name)).toHaveCount(0); + + // The save to the user settings is delayed, so poll the backend. + await expect + .poll(async () => (await readUserSettings(staffApi))[FAVOURITES_KEY], { + message: 'the favourite is in the user settings, not only on the page', + timeout: 20_000, + }) + .toEqual([colleague]); + } finally { + await setContacts(staffApi, contacts_before); + await setUserSetting( + staffApi, + FAVOURITES_KEY, + Array.isArray(favourites_before) ? favourites_before : [], + ); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-concurrency.spec.ts b/apps/workplace/e2e/local/visitor-concurrency.spec.ts index 3b6b9e3057d..bee4ef42ae5 100644 --- a/apps/workplace/e2e/local/visitor-concurrency.spec.ts +++ b/apps/workplace/e2e/local/visitor-concurrency.spec.ts @@ -44,7 +44,7 @@ * spec and wrong for this one — a retry would hide the very thing being * measured. These writes go straight at the API. * - * ## Why these were `fixme` until the staff-api fix shipped + * ## Why these are `fixme` * * The plan's note on this row said "a burst spec would encode REG-09 flakiness; * the reproducer stays the record until the image is fixed". I tried to design @@ -61,9 +61,30 @@ * which is how a real finding gets trained out of a team's attention. * * Both tests were `test.fixme` until the stack ran a staff-api with the fix - * (placeos-2.2609.6, pg-orm 2.2.4): four consecutive runs held the invariant - * with 2 of 8 writes accepted each time. Two things worth taking from the - * measurement even so: + * (placeos-2.2609.6, pg-orm 2.2.4). They were made active on 2026-10-07, after + * four consecutive runs held the invariant with 2 of 8 writes accepted each + * time. + * + * Measured again on 2026-10-14, on that same image (`placeos-2.2609.6`, + * revision `e7257439`). The invariant still breaks: + * + * VIS-28 alone, ten times on one worker ....... 3 runs with a PHANTOM WRITE + * full local runs of 13 and 14 October ........ a phantom write in each + * (VIS-28, then VIS-28b) + * CI run 38386909981, images of 13 October .... VIS-28 failed 3 of 3 tries + * + * One phantom was checked by hand, to rule out a slow commit. Booking 2519 + * answered 201. Minutes later `GET /bookings/2519` as the admin answered 404, + * while the container and the other member of its group were stored. The + * write was never committed. + * + * So the fix is not in effect on the image that the suite runs, and both + * tests are `fixme` again for the reason the note gave. PPT-2642 is not done. + * To measure a new image, remove `.fixme` and run this file alone: + * `--repeat-each=10 --workers=1`. One clean run proves nothing: seven of the + * ten runs above were clean. + * + * Two things worth taking from the first measurement even so: * * - Only **2 or 3 of 8** concurrent writes are accepted at all. The other five * or six are refused `400 Expected BeginObject but was EOF` — staff-api @@ -107,7 +128,9 @@ interface Attempt { } test.describe('concurrent visitor writes', () => { - test('VIS-28: every accepted write is stored, and every refused one leaves nothing', async ({ + // FIXME (backend fault, measured 2026-10-14): phantom writes still occur + // on staff-api `placeos-2.2609.6`. See "Why these are `fixme`" above. + test.fixme('VIS-28: every accepted write is stored, and every refused one leaves nothing', async ({ staffApi, }) => { const me = await (await staffApi.get('/api/engine/v2/users/current')).json(); @@ -250,7 +273,9 @@ test.describe('concurrent visitor writes', () => { } }); - test('VIS-28b: a group container is never left holding fewer members than were written', async ({ + // FIXME (backend fault, measured 2026-10-14): the same phantom write, on a + // member of a group. See "Why these are `fixme`" above. + test.fixme('VIS-28b: a group container is never left holding fewer members than were written', async ({ staffApi, }) => { const me = await (await staffApi.get('/api/engine/v2/users/current')).json(); diff --git a/apps/workplace/e2e/local/visitor-delegate.spec.ts b/apps/workplace/e2e/local/visitor-delegate.spec.ts index 3c8c5ed8c61..334e01bdf42 100644 --- a/apps/workplace/e2e/local/visitor-delegate.spec.ts +++ b/apps/workplace/e2e/local/visitor-delegate.spec.ts @@ -21,6 +21,13 @@ * * Requires `visitors.can_book_for_anyone` AND `basic_user_search`; see * `invite-form.page.ts` for why the other host control cannot work locally. + * + * ## The visit is three days on, not today + * + * The host is the user of the NEXT worker. An invite of today would show on + * the next-booking card of that worker's home page, and the home specs of that + * worker delete every booking their user has today (`clearToday` in + * `home.api.ts`). A later day keeps the two apart. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { @@ -44,7 +51,15 @@ import { YourBookingsPage } from '../../../../e2e/support/visitor/your-bookings. const DAY = 86_400; const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; -const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 7 * DAY; + +/** The day of the visit, in local time. See the file header for why not today. */ +function visitDay(): number { + const day = new Date(); + day.setDate(day.getDate() + 3); + day.setHours(10, 0, 0, 0); + return day.valueOf(); +} const DELEGATE_MODE = { ...SINGLE_VISITOR_MODE, ...BOOK_FOR_ANYONE }; @@ -72,6 +87,7 @@ test.describe('visitor invite on behalf of another host', () => { const visitor = visitorFor(mine, VISITOR_SLOTS.delegate.single); const reason = uniqueTitle('E2E Delegate Visit'); + const visit_day = visitDay(); let booking_id: number | undefined; await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); @@ -81,10 +97,15 @@ test.describe('visitor invite on behalf of another host', () => { const me = await currentUser(staffApi); const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { host: { email: host.email, name: host.name }, + date: visit_day, }); booking_id = created.id; const stored = await getBooking(staffApi, booking_id); + expect( + new Date(stored.booking_start * 1000).toDateString(), + 'the invite is on the later day, clear of the home specs of the host', + ).toBe(new Date(visit_day).toDateString()); expect(stored.user_email, 'the chosen host owns the booking').toBe(host.email); expect(stored.booked_by_email, 'and we are recorded as the booker').toBe( me.email, @@ -113,6 +134,7 @@ test.describe('visitor invite on behalf of another host', () => { const visitor = visitorFor(mine, VISITOR_SLOTS.delegate.groupA); const reason = uniqueTitle('E2E Delegate View'); + const visit_day = visitDay(); let booking_id: number | undefined; await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); @@ -121,13 +143,16 @@ test.describe('visitor invite on behalf of another host', () => { try { const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { host: { email: host.email, name: host.name }, + date: visit_day, }); booking_id = created.id; // --- the BOOKER's view ------------------------------------------- + // The schedule opens on today, so both views go to the visit day. const mine_page = new YourBookingsPage(staffPage); await mine_page.open(); await mine_page.showVisitors(); + await mine_page.showDayOf(visit_day); await expect( mine_page.card(booking_id), @@ -137,8 +162,8 @@ test.describe('visitor invite on behalf of another host', () => { await expect( mine_page.associateBadge(booking_id), 'and it is badged "Associate", because the host is not you. If this ' + - 'fails, check whether a third badge was added to that corner of ' + - 'the card — the locator excludes only the checked-in one', + 'fails, check where the card draws its warning badges — the ' + + 'locator matches on their position classes', ).toBeVisible(); await expect( mine_page.bookedForLine(booking_id), @@ -156,6 +181,7 @@ test.describe('visitor invite on behalf of another host', () => { const theirs = new YourBookingsPage(host_page); await theirs.open(); await theirs.showVisitors(); + await theirs.showDayOf(visit_day); await expect( theirs.card(booking_id), diff --git a/apps/workplace/e2e/local/visitor-details.spec.ts b/apps/workplace/e2e/local/visitor-details.spec.ts index 47d5d903460..53288b1fe3f 100644 --- a/apps/workplace/e2e/local/visitor-details.spec.ts +++ b/apps/workplace/e2e/local/visitor-details.spec.ts @@ -14,9 +14,15 @@ * the visitor -> `asset_id` / `attendees`, and a separate guest record * So all three are asserted on what came back from the backend, not on the form. * - * Pass number and the international tick are both behind `visitors.allow_pass_number` - * / `visitors.allow_international`, off by default — the classic "only breaks for - * the customers who switched it on" shape. + * 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` + * + * 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. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { deleteBooking, getBooking, uniqueTitle } from '../../../../e2e/support/api'; @@ -39,7 +45,7 @@ const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; test.describe('visitor invite details', () => { - test('a pass number and the international flag are stored with the invite', async ({ + test('a pass number is stored with the invite', async ({ staffPage, staffApi, }, testInfo) => { @@ -54,7 +60,6 @@ test.describe('visitor invite details', () => { try { const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { passNumber: pass_number, - international: true, }); booking_id = created.id; @@ -67,13 +72,56 @@ test.describe('visitor invite details', () => { 'the pass number typed into the form must reach the backend — ' + 'reception reads this one', ).toBe(pass_number); + + // The visitor themselves, alongside the extra, so a booking that + // stored the pass against the wrong person cannot pass. + expect(stored.asset_id, 'the invite is still for this visitor').toBe( + visitor.email, + ); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + /** + * 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". + */ + test.fixme('the international flag is stored with the invite', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor( + testInfo.parallelIndex, + VISITOR_SLOTS.details.international, + ); + const reason = uniqueTitle('E2E Visit International'); + let booking_id: number | undefined; + + await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + await useSettings(staffPage, { ...SINGLE_VISITOR_MODE, ...ALLOW_EXTRAS }); + + try { + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { + international: true, + }); + booking_id = created.id; + + const stored = (await getBooking(staffApi, booking_id)) as VisitorBooking; expect( stored.extension_data?.international, - 'and the international tick must be stored as a real true, not dropped', + 'the international tick must be stored as a real true, not dropped', ).toBe(true); - - // The visitor themselves, alongside the extras, so a booking that - // stored the flags against the wrong person cannot pass. expect(stored.asset_id, 'the invite is still for this visitor').toBe( visitor.email, ); diff --git a/apps/workplace/e2e/local/visitor-edit.spec.ts b/apps/workplace/e2e/local/visitor-edit.spec.ts index 248e3e52da2..75cf380771e 100644 --- a/apps/workplace/e2e/local/visitor-edit.spec.ts +++ b/apps/workplace/e2e/local/visitor-edit.spec.ts @@ -1,32 +1,27 @@ /** * VIS-13 / VIS-14 / VIS-15 — editing an invite that already exists. * - * ## Which invites can actually be edited + * ## How an edit works * - * Editing works in MULTI-visitor mode, which is what the app ships with - * (`bookings.multiple_visitors` defaults to true). It is broken in - * SINGLE-visitor mode: pressing Update throws `_.toJSON is not a function`, no - * request is sent, and the user sees only that raw string. That is VIS-B8, and - * the third test here is the placeholder for it. + * Edit, in the booking's menu on the schedule, opens the page that makes an + * invite (`visitor-flow-new`) with the invite filled in. The Single/Multiple + * toggle and the recent visitors are not shown: an invite keeps the mode it + * was made in. Confirm saves with one `PATCH` per booking, and the page then + * shows the same success view as a new invite. * - * Measured, all three created and edited through the app — multi with one - * visitor and with two both save cleanly (`PATCH`, "updated successfully"); - * single fails every time, on today's date and on a future one alike. The mode - * decides it, not the date and not the number of people. - * - * Cause: `Booking` declares `attendees` as `User[]` but assigns the raw server - * JSON to it (`booking.class.ts`), so at runtime they are plain objects. In - * single-visitor mode the edit path hands that straight to the form's `assets` - * (`invite-visitor-form.component.ts`), and saving calls `.toJSON()` on every - * entry (`booking-form.service.ts`). Plain JSON has no such method. The - * multi-visitor paths escape it because they rebuild each person with - * `new User(...)` first. + * Every test saves through `saveEditViaUI`, which does the edit again when + * REG-09 refuses the save. The three tests start together on three workers, + * so their saves are concurrent writes: without it one of them was refused + * (`PATCH` 400 "Expected BeginObject but was EOF") in most parallel runs. * * ## Why THREE tests in this file, when every other spec has two * - * The third is a deliberate placeholder for VIS-B8 and is `fixme`, so it costs - * nothing per run. Fold it away once the app is fixed: at that point it is the - * same assertion as the first test with one setting changed. + * The third is the guard for VIS-B8. In the form that this page replaced + * (`invite-visitor-form`), saving an edit in SINGLE-visitor mode threw + * `_.toJSON is not a function` and sent no request, while the multi-visitor + * mode saved cleanly. On this page both modes save. Measured for single mode: + * the edit sends `PATCH /bookings/:id`, gets a 200, and the new reason is + * stored. It is the same assertion as the first test in the other mode. * * ## What the first two guard * @@ -51,7 +46,6 @@ import { } from '../../../../e2e/support/api'; import { WORKERS, staffEmail } from '../../../../e2e/support/env'; import { expect, test } from '../../../../e2e/support/fixtures'; -import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; import { deleteGuest, releaseGroupContainers, @@ -64,6 +58,7 @@ import { import { inviteVisitorViaUI, inviteVisitorsViaUI, + saveEditViaUI, } from '../../../../e2e/support/visitor/visitor.flows'; import { ALLOW_EDITING, @@ -130,8 +125,8 @@ test.describe('editing a visitor invite', () => { try { const me = await currentUser(staffApi); - // Multi-visitor mode with one guest: the mode the app ships with, - // and the one where saving an edit works at all (VIS-B8). + // Multi-visitor mode with one guest. The third test makes the + // same kind of edit in single mode. const created = await inviteVisitorsViaUI( staffPage, staffApi, @@ -145,7 +140,8 @@ test.describe('editing a visitor invite', () => { member, 'the invite created a visitor booking to edit', ).toBeTruthy(); - booking_id = member!.id; + const member_id = member!.id; + booking_id = member_id; const stored = await getBooking(staffApi, booking_id); expect( @@ -160,17 +156,25 @@ test.describe('editing a visitor invite', () => { // Re-open it and change only the reason. The schedule opens on // today, so it has to be walked to the visit's own day first. const bookings_page = new YourBookingsPage(staffPage); - await bookings_page.open(); - await bookings_page.showVisitors(); - await bookings_page.showDayOf(visit_day); - await bookings_page.startEdit(booking_id); + await saveEditViaUI(staffPage, async (form) => { + await bookings_page.open(); + await bookings_page.showVisitors(); + await bookings_page.showDayOf(visit_day); + await bookings_page.startEdit(member_id); - const form = new InviteVisitorForm(staffPage); - await expect(async () => { - await form.reason.fill(new_reason); - await expect(form.reason).toHaveValue(new_reason, { timeout: 1_000 }); - }).toPass({ timeout: 30_000 }); - await form.sendButton.click(); + // The page opens on the one booking and loads the rest of its + // group afterwards. Until then it is in single mode, and + // Confirm saves that booking alone: no request is made for + // the container. + await expect( + form.chips, + 'the edit form should load with the visitor listed', + ).toHaveCount(1, { timeout: 30_000 }); + await expect(async () => { + await form.reason.fill(new_reason); + await expect(form.reason).toHaveValue(new_reason, { timeout: 1_000 }); + }).toPass({ timeout: 30_000 }); + }); await expect(async () => { const updated = await getBooking(staffApi, booking_id!); @@ -251,27 +255,43 @@ test.describe('editing a visitor invite', () => { ).toBeTruthy(); // Edit the group from either member — they share a container. + const kept_id = kept_booking!.id; const bookings_page = new YourBookingsPage(staffPage); - await bookings_page.open(); - await bookings_page.showVisitors(); - await bookings_page.startEdit(kept_booking!.id); - - const form = new InviteVisitorForm(staffPage); - await expect( - form.chips, - 'the edit form should load with both visitors already listed', - ).toHaveCount(2, { timeout: 30_000 }); + await saveEditViaUI(staffPage, async (form, attempt) => { + await bookings_page.open(); + await bookings_page.showVisitors(); + await bookings_page.startEdit(kept_id); - // Not a `hasText` filter on the address: a chip renders only the - // part before the @, so that matched nothing and read as a broken - // remove button. `removeVisitorChip` matches what is really shown. - await form.removeVisitorChip(removed.email); - await expect( - form.chips, - 'one visitor was removed from the list', - ).toHaveCount(1); + if (attempt === 1) { + await expect( + form.chips, + 'the edit form should load with both visitors already listed', + ).toHaveCount(2, { timeout: 30_000 }); + } else { + // A save that REG-09 refused part-way can already have + // removed the visitor, so a retry can only demand the + // one that stays. + await expect(async () => { + await form.chipIndexFor(kept.email); + }, 'the edit form should load with the kept visitor listed').toPass({ + timeout: 30_000, + }); + } - await form.sendButton.click(); + // Not a `hasText` filter on the address: a chip renders only + // the part before the @, so that matched nothing and read as a + // broken remove button. `removeVisitorChip` matches what is + // really shown. + const still_listed = await form + .chipIndexFor(removed.email) + .then(() => true) + .catch(() => false); + if (attempt === 1 || still_listed) await form.removeVisitorChip(removed.email); + await expect( + form.chips, + 'one visitor was removed from the list', + ).toHaveCount(1); + }); // Read the backend, not the screen. A group edit rewrites several // rows and the screen shows only what the form thinks it did. @@ -315,14 +335,9 @@ test.describe('editing a visitor invite', () => { }); /** - * VIS-15 — the SAME edit, in single-visitor mode. This is the one that is - * broken (VIS-B8): `_.toJSON is not a function`, no request sent, nothing - * saved. Kept as `fixme` so the bug has a home in the suite rather than - * only in a document. - * - * When the app is fixed, drop the `fixme` and this should pass as written. - * Evidence it is the mode and not anything else: the first test above does - * the same thing in multi-visitor mode and is green. + * VIS-15 — the SAME edit, in single-visitor mode. The guard for VIS-B8: + * the old form threw `_.toJSON is not a function` here, sent no request + * and saved nothing. See the file header. */ test('an invite made in single-visitor mode can be re-saved', async ({ staffPage, @@ -357,23 +372,23 @@ test.describe('editing a visitor invite', () => { booking_id = created.id; const bookings_page = new YourBookingsPage(staffPage); - await bookings_page.open(); - await bookings_page.showVisitors(); - await bookings_page.startEdit(booking_id); + await saveEditViaUI(staffPage, async (form) => { + await bookings_page.open(); + await bookings_page.showVisitors(); + await bookings_page.startEdit(created.id); - const form = new InviteVisitorForm(staffPage); - await expect(async () => { - await form.reason.fill(new_reason); - await expect(form.reason).toHaveValue(new_reason, { timeout: 1_000 }); - }).toPass({ timeout: 30_000 }); - await form.sendButton.click(); + await expect(async () => { + await form.reason.fill(new_reason); + await expect(form.reason).toHaveValue(new_reason, { timeout: 1_000 }); + }).toPass({ timeout: 30_000 }); + }); await expect(async () => { const updated = await getBooking(staffApi, booking_id!); expect( updated.title, - 'the edit must reach the backend — in single-visitor mode it ' + - 'currently throws in the browser and sends nothing (VIS-B8)', + 'the edit must reach the backend — in single-visitor mode the ' + + 'old form threw in the browser and sent nothing (VIS-B8)', ).toBe(new_reason); }).toPass({ timeout: 30_000 }); } finally { diff --git a/apps/workplace/e2e/local/visitor-flow-new.spec.ts b/apps/workplace/e2e/local/visitor-flow-new.spec.ts new file mode 100644 index 00000000000..3667740f8be --- /dev/null +++ b/apps/workplace/e2e/local/visitor-flow-new.spec.ts @@ -0,0 +1,168 @@ +/** + * NEW-VIS-01 … NEW-VIS-03 — the `visitor-flow-new` invite flow, as a + * NON-ADMIN, against a real local backend. + * + * These were written when every other visitor spec still drove the flow that + * this one replaced. The other specs now drive this flow too, so NEW-VIS-01 + * overlaps `visitor-invite.spec.ts`; it adds the success view. NEW-VIS-02 (no + * address) and NEW-VIS-03 (the recent visitors) are covered nowhere else. + */ +import { test, expect } from '../../../../e2e/support/fixtures'; +import { + currentUser, + deleteBooking, + getBooking, + listBookings, + uniqueTitle, +} from '../../../../e2e/support/api'; +import { readUserSettings, setUserSetting } from '../../../../e2e/support/home/home.api'; +import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; +import { VISITOR_SLOTS, visitorFor } from '../../../../e2e/support/visitor/visitor.env'; +import { + deleteGuest, + releaseVisitor, + searchGuests, +} from '../../../../e2e/support/visitor/visitor.api'; +import { + ALLOW_ALL_DAY, + SINGLE_VISITOR_MODE, + useSettings, +} from '../../../../e2e/support/visitor/visitor.settings'; +import { inviteVisitorViaUI } from '../../../../e2e/support/visitor/visitor.flows'; + +/** Window wide enough to cover an invite made in any timezone. */ +const DAY = 86_400; +const window_from = () => Math.floor(Date.now() / 1000) - 2 * DAY; +const window_to = () => Math.floor(Date.now() / 1000) + 2 * DAY; + +/** The user setting that holds the recent visitors. */ +const INVITEES_KEY = 'visitor-invitees'; + +const SETTINGS = { ...SINGLE_VISITOR_MODE, ...ALLOW_ALL_DAY }; + +const isBookingPost = (url: string, method: string) => + url.includes('/api/staff/v1/bookings') && method === 'POST'; + +test.describe('visitor invite — the new flow', () => { + test('NEW-VIS-01: a non-admin invites a visitor and the backend stores it', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.new_flow.stores); + const reason = uniqueTitle('E2E New Visit'); + let booking_id: number | undefined; + + const swept = await releaseVisitor(staffApi, visitor.email, window_from(), window_to()); + if (swept) console.log(` swept ${swept} stale invite(s) for ${visitor.email}`); + // A guest left from an earlier run makes the guest check below pass + // with no invite at all. + expect( + await searchGuests(staffApi, visitor.email), + 'precondition: no guest record exists before the invite', + ).toHaveLength(0); + + await useSettings(staffPage, SETTINGS); + + try { + // Through the flow, not a bare click on Confirm: the flow retries a + // `POST /bookings` that REG-09 refused (see `visitor.flows.ts`), + // and a bare click failed on that 400 in a parallel run. + // All Day replaces the default slot, which is the next boundary of + // the clock and makes the invite depend on how fast the run is. + const created = await inviteVisitorViaUI(staffPage, staffApi, visitor, reason, { + allDay: true, + }); + const created_id = created.id; + booking_id = created_id; + + // The app reached its own success state, and names the visitor. + await expect(staffPage).toHaveURL(/#\/book\/visitor\/success/); + const form = new InviteVisitorForm(staffPage); + await expect(form.successPanel).toContainText(visitor.name); + + // Read it back independently of the response parsed above. + const stored = await getBooking(staffApi, created_id); + const me = await currentUser(staffApi); + expect(stored.booking_type, 'stored as a visitor booking').toBe('visitor'); + expect(stored.asset_id, 'against this worker’s visitor address').toBe(visitor.email); + expect(stored.title, 'the reason we typed reached the backend').toBe(reason); + expect(stored.deleted, 'not soft-deleted').toBeFalsy(); + expect( + stored.zones.length, + 'carries its zone hierarchy (org/building/level)', + ).toBeGreaterThan(0); + expect(stored.user_email, 'the signed-in user is the host').toBe(me.email); + + // The guest record lives outside the booking. Reception reads it. + const guests = await searchGuests(staffApi, visitor.email); + expect( + guests.map((g) => g.email), + 'a guest record was created for the visitor', + ).toContain(visitor.email); + expect(guests[0].name, 'carrying the name we typed').toBe(visitor.name); + + const listed = await listBookings(staffApi, 'visitor', window_from(), window_to()); + expect( + listed.map((b) => b.id), + 'the new invite appears in the visitor listing', + ).toContain(created_id); + } finally { + if (booking_id != null) await deleteBooking(staffApi, booking_id); + await deleteGuest(staffApi, visitor.email); + } + }); + + test('NEW-VIS-02: Confirm with no visitor email is refused and nothing is sent', async ({ + staffPage, + }) => { + const form = new InviteVisitorForm(staffPage); + const posts: string[] = []; + staffPage.on('request', (request) => { + if (isBookingPost(request.url(), request.method())) posts.push(request.url()); + }); + await useSettings(staffPage, SETTINGS); + + await form.open(); + await form.reason.fill(uniqueTitle('E2E New Visit None')); + await form.visitorName.fill('E2E Visitor With No Address'); + await expect(form.visitorEmail).toHaveValue(''); + await form.sendButton.click(); + + await expect(staffPage.locator('mat-snack-bar-container')).toBeVisible(); + await expect(staffPage).toHaveURL(/#\/book\/visitor\/form/); + expect(posts, 'no invite may leave the page without an address').toEqual([]); + }); + + test('NEW-VIS-03: a recent visitor from the user settings fills the visitor fields', async ({ + staffPage, + staffApi, + }, testInfo) => { + const visitor = visitorFor(testInfo.parallelIndex, VISITOR_SLOTS.new_flow.recent); + const form = new InviteVisitorForm(staffPage); + const before: unknown = (await readUserSettings(staffApi))[INVITEES_KEY]; + const history: unknown[] = Array.isArray(before) ? before : []; + + // The last item is the most recent visitor, and the panel shows the + // most recent first. Format: `email|name|company|international|phone`. + await setUserSetting(staffApi, INVITEES_KEY, [ + ...history.filter((item) => !`${item}`.startsWith(`${visitor.email}|`)), + `${visitor.email}|${visitor.name}|${visitor.company}|0|`, + ]); + await useSettings(staffPage, SETTINGS); + + try { + await form.open(); + await expect(form.visitorEmail, 'the fields start empty').toHaveValue(''); + + const recent = form.recentVisitors.filter({ hasText: visitor.email }); + await expect(recent, 'the panel offers the visitor from the settings').toBeVisible(); + await recent.click(); + + await expect(form.visitorEmail).toHaveValue(visitor.email); + await expect(form.visitorName).toHaveValue(visitor.name); + await expect(form.company).toHaveValue(visitor.company); + } finally { + await setUserSetting(staffApi, INVITEES_KEY, history); + } + }); +}); diff --git a/apps/workplace/e2e/local/visitor-group-time.spec.ts b/apps/workplace/e2e/local/visitor-group-time.spec.ts index 7f7963e53d2..8e9ac02fdb5 100644 --- a/apps/workplace/e2e/local/visitor-group-time.spec.ts +++ b/apps/workplace/e2e/local/visitor-group-time.spec.ts @@ -31,8 +31,10 @@ import { ALLOW_EDITING, useSettings, } from '../../../../e2e/support/visitor/visitor.settings'; -import { inviteVisitorsViaUI } from '../../../../e2e/support/visitor/visitor.flows'; -import { InviteVisitorForm } from '../../../../e2e/support/visitor/invite-form.page'; +import { + inviteVisitorsViaUI, + saveEditViaUI, +} from '../../../../e2e/support/visitor/visitor.flows'; import { YourBookingsPage } from '../../../../e2e/support/visitor/your-bookings.page'; const DAY = 86_400; @@ -148,20 +150,21 @@ test.describe('moving a group invite', () => { ).not.toBe(hhmm(Math.floor(moved_to / 1000))); // Re-open the invite from the schedule and change only the time. + // Through `saveEditViaUI`, which does the edit again if REG-09 + // refuses the save. const schedule = new YourBookingsPage(staffPage); - await schedule.open(); - await schedule.showVisitors(); - await schedule.showDayOf(visit_day); - await schedule.startEdit(member_ids[0]); + await saveEditViaUI(staffPage, async (form) => { + await schedule.open(); + await schedule.showVisitors(); + await schedule.showDayOf(visit_day); + await schedule.startEdit(member_ids[0]); - const form = new InviteVisitorForm(staffPage); - await expect( - form.chips, - 'the edit form should open with both visitors listed', - ).toHaveCount(2, { timeout: 30_000 }); - await form.setStartTime(hhmm(Math.floor(moved_to / 1000))); - await staffPage.waitForTimeout(3_000); - await form.sendButton.click(); + await expect( + form.chips, + 'the edit form should open with both visitors listed', + ).toHaveCount(2, { timeout: 30_000 }); + await form.setStartTime(hhmm(Math.floor(moved_to / 1000))); + }); // EVERY member, not just the one the edit was opened from. await expect(async () => { diff --git a/apps/workplace/e2e/local/visitor-group.spec.ts b/apps/workplace/e2e/local/visitor-group.spec.ts index fe4562e2f4f..8dd4cdec983 100644 --- a/apps/workplace/e2e/local/visitor-group.spec.ts +++ b/apps/workplace/e2e/local/visitor-group.spec.ts @@ -1,9 +1,9 @@ /** * VIS-03 / VIS-04 — invite SEVERAL visitors at once, through the full UI. * - * This is the mode workplace ships with (`bookings.multiple_visitors` defaults - * to true), and it is not "the single flow, twice". One send produces N+1 - * records: + * This is the Multiple side of the toggle on the invite page, which + * `inviteVisitorsViaUI` selects, and it is not "the single flow, twice". One + * send produces N+1 records: * * - a GROUP CONTAINER booking, `booking_type: 'group'`, carrying the shared * group reference in `extension_data`; diff --git a/apps/workplace/e2e/local/visitor-invite.spec.ts b/apps/workplace/e2e/local/visitor-invite.spec.ts index 3cf1eccf665..ee4ec3e240f 100644 --- a/apps/workplace/e2e/local/visitor-invite.spec.ts +++ b/apps/workplace/e2e/local/visitor-invite.spec.ts @@ -19,9 +19,10 @@ * - A GUEST record is created alongside the booking, from its `attendees`, and * it OUTLIVES the booking. Teardown has to remove both, or "was a guest * created?" starts passing on the last run's data. - * - The form has two modes and the app default is the MULTI-visitor one. This - * file covers single mode, so it pins the setting per test rather than - * trusting a default a settings change could flip underneath it. + * - The page has two modes, chosen with a toggle on the page itself. This + * file covers single mode, which `inviteVisitorViaUI` selects. + * `bookings.multiple_visitors` no longer chooses the mode: it only changes + * the wording of the success view. */ import { test, expect } from '../../../../e2e/support/fixtures'; import { diff --git a/apps/workplace/e2e/local/visitor-settings.spec.ts b/apps/workplace/e2e/local/visitor-settings.spec.ts index 7193551f972..3f6361eba16 100644 --- a/apps/workplace/e2e/local/visitor-settings.spec.ts +++ b/apps/workplace/e2e/local/visitor-settings.spec.ts @@ -3,7 +3,8 @@ * * Both of these are off by default, and both are the kind of thing that only * breaks for the customers who turned them on — which is exactly the class of - * bug an e2e suite is for. `visitors.reason_required` is PPT-2782. + * bug an e2e suite is for. `visitors.reason_required` is PPT-2782, and its + * test is `fixme`: the `visitor-flow-new` page does not read that setting. * * The settings are applied per BROWSER CONTEXT rather than seeded onto the org * zone (see `visitor.settings.ts`). Two specs can therefore demand opposite @@ -61,7 +62,22 @@ test.describe('visitor invite settings', () => { } }); - test('with a reason required, sending without one is refused', async ({ + /** + * VIS-08. `fixme`, because the `visitor-flow-new` page does not read + * `visitors.reason_required`. + * + * Measured with the setting on: the reason field starts as "Visit", not + * empty. With the field cleared by hand, Confirm sends `POST /bookings` + * with `title: "Visit"` and gets a 201 — `confirmBooking` puts the default + * back (`title || description || 'Visit'`). No file under + * `visitor-flow-new` reads the setting. The old form + * (`invite-visitor-form`) had the validator, but `/book/visitor` no longer + * opens that form. + * + * Written as it should pass once the page honours the setting. It fails + * today at the first assertion, because the field holds "Visit". + */ + test.fixme('with a reason required, sending without one is refused', async ({ staffPage, staffApi, }, testInfo) => { @@ -72,10 +88,10 @@ test.describe('visitor invite settings', () => { const form = new InviteVisitorForm(staffPage); await form.open(); - await form.expectSingleMode(); + await form.useSingleMode(); - // With the setting on, the component starts the reason EMPTY. Without it - // the reason defaults to "Visit", so a spec that forgot to clear the + // With the setting on, the old form started the reason EMPTY. Without + // it the reason defaults to "Visit", so a spec that forgot to clear the // field would send successfully and prove nothing. await expect( form.reason, diff --git a/apps/workplace/e2e/local/visitor-times.spec.ts b/apps/workplace/e2e/local/visitor-times.spec.ts index c71e4b6fb28..54b65ab090c 100644 --- a/apps/workplace/e2e/local/visitor-times.spec.ts +++ b/apps/workplace/e2e/local/visitor-times.spec.ts @@ -54,7 +54,7 @@ test.describe('visitor invite times', () => { try { const form = new InviteVisitorForm(staffPage); await form.open(); - await form.expectSingleMode(); + await form.useSingleMode(); // Pick from what the form itself offers rather than naming an hour: // the options run on a fixed step from the current time, so a @@ -103,7 +103,7 @@ test.describe('visitor invite times', () => { const form = new InviteVisitorForm(staffPage); await form.open(); - await form.expectSingleMode(); + await form.useSingleMode(); const lengths = await form.durationOptions(); expect( diff --git a/apps/workplace/e2e/local/visitor-validation.spec.ts b/apps/workplace/e2e/local/visitor-validation.spec.ts index 3f1988d690f..2a412fbc471 100644 --- a/apps/workplace/e2e/local/visitor-validation.spec.ts +++ b/apps/workplace/e2e/local/visitor-validation.spec.ts @@ -7,7 +7,7 @@ * the question is not "did a message appear" but "did anything leave". * * `asset_id` carries the visitor's address on a visitor booking and has an - * `email` validator on it (`booking.utilities.ts`), which is what test 1 + * `email` validator on it (`booking-form.model.ts`), which is what test 1 * exercises. The backend would happily store `not-an-email`, so if the form * lets it through nothing else will stop it. */ @@ -42,7 +42,7 @@ test.describe('visitor invite validation', () => { const form = new InviteVisitorForm(staffPage); await form.open(); - await form.expectSingleMode(); + await form.useSingleMode(); const posts: number[] = []; staffPage.on('response', (r) => { @@ -60,6 +60,14 @@ test.describe('visitor invite validation', () => { }).toPass({ timeout: 30_000 }); await form.sendButton.click(); + // The page answers Confirm with a snack bar. Waiting for it proves the + // click was handled, so the checks below are not made on a page that + // has not reacted yet. A sent invite shows a snack bar too: the checks + // below are what tell a refusal from a send. + await expect( + staffPage.locator('mat-snack-bar-container'), + 'Confirm must be answered with a snack bar', + ).toBeVisible(); // Long enough that a slow POST would still have been seen. Asserting // "nothing yet" straight after the click would pass either way. await staffPage.waitForTimeout(5_000); @@ -69,6 +77,7 @@ test.describe('visitor invite validation', () => { 'a malformed address must be caught in the form — the backend stores ' + 'whatever it is given, so nothing else will catch it', ).toHaveLength(0); + await expect(form.sendButton, 'the form must still be on screen').toBeVisible(); await expect( form.successPanel, 'and the invite must not reach its success screen', diff --git a/apps/workplace/e2e/schedule.spec.ts b/apps/workplace/e2e/schedule.spec.ts index e10fceece87..c4318388dec 100644 --- a/apps/workplace/e2e/schedule.spec.ts +++ b/apps/workplace/e2e/schedule.spec.ts @@ -79,14 +79,16 @@ test.describe('Schedule / Your Bookings', () => { { timeout: LOAD_TIMEOUT }, ); - const status_labels = page.getByText( - /approved|tentative|declined|waitlisted/i, - ); - const status_count = await status_labels.count(); - if (status_count > 0) { - await expect(status_labels.first()).toBeVisible(); - } else { - await expect(page.locator('schedule-day-view')).toBeVisible(); - } + // The day view shows the status of a booking as a coloured dot. The + // label of the dot is its tooltip, which is in the DOM as a hidden + // description for screen readers. So the label is attached, not + // visible. It renders a moment after the day view, so wait for it: a + // count taken at once is 0. + const status_labels = page.locator('[role="tooltip"]', { + hasText: /approved|tentative|declined|waitlisted/i, + }); + await expect(status_labels.first()).toBeAttached({ + timeout: LOAD_TIMEOUT, + }); }); }); diff --git a/e2e/README.md b/e2e/README.md index bfe91cb7df2..8434f573b35 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -107,7 +107,7 @@ e2e/ shared engine, all apps visitor.env.ts per-worker visitor addresses + slot table visitor.api.ts guest + visitor-booking helpers and sweeps visitor.settings.ts per-context app settings presets - invite-form.page.ts locators for the invite form + invite-form.page.ts locators for the invite page (`visitor-flow-new`) your-bookings.page.ts locators for the schedule + details modal visitor.flows.ts multi-step UI flows (inviteVisitorViaUI) room/ room-booking coverage, kept out of the files above @@ -115,7 +115,7 @@ e2e/ shared engine, all apps room.seed.ts creates the rooms as engine Systems (needs ADMIN) room.api.ts room-booking helpers, sweeps, tryRoomBooking room.settings.ts per-context app settings presets (use_bookings mode) - meeting-form.page.ts locators for the meeting form + room picker + meeting-flow.page.ts locators for the `meeting-flow-new` booking flow schedule.page.ts extends visitor/your-bookings.page.ts for event cards room.flows.ts multi-step UI flows (bookRoomViaUI) repro/ standalone reproducers for confirmed backend bugs @@ -334,4 +334,15 @@ failure that looked like something else entirely. - **Red-check new assertions.** Break the expected value once and confirm the test fails for the reason you think. An assertion that never observed real data is worse than no assertion. +- **"Nothing is shown" needs a control.** An empty state is also what a page shows + before its data loads. Hold a second item that the page can only show when it + has loaded, and assert on that. +- **A workaround for an app defect goes in the report.** A helper that retries past + a known defect adds an annotation to the test each time the retry fires: + `test.info().annotations.push({ type: 'NEW-F5', description })`. The type is the + id of the finding in `E2E_USER_STORIES.md`. A `console.warn` alone is not read on + a green run, and the defect can then get worse with no sign. +- **Do not make a booking of TODAY for the user of another worker.** The home page + shows the first booking a user has today, so the home specs delete every booking + their user has today (`clearToday`). Book a later day. - `.auth/` and `.env` are gitignored — those tokens are impersonation-grade. diff --git a/e2e/ROOM_E2E_HANDOVER.md b/e2e/ROOM_E2E_HANDOVER.md index f2ecf2faa0b..08fafd6e432 100644 --- a/e2e/ROOM_E2E_HANDOVER.md +++ b/e2e/ROOM_E2E_HANDOVER.md @@ -1,5 +1,10 @@ # Room booking e2e — handover +> **This document describes the meeting flow before PR #561.** That PR changed the view that the +> route opens, and the specs now drive the new view. The page object `e2e/support/room/meeting-flow.page.ts` and §1c of +> [`../E2E_USER_STORIES.md`](../E2E_USER_STORIES.md) have the current behaviour. The findings +> and the decisions below are kept as the record of how the coverage was built. + Working notes for the room-booking coverage, built after the visitor work and in the same shape. Written to be picked up cold. The coverage contract lives in [`../E2E_USER_STORIES.md`](../E2E_USER_STORIES.md) (§1b, ROOM-01 … ROOM-14); harness diff --git a/e2e/VISITOR_E2E_HANDOVER.md b/e2e/VISITOR_E2E_HANDOVER.md index 45939ad9322..616b2333b6b 100644 --- a/e2e/VISITOR_E2E_HANDOVER.md +++ b/e2e/VISITOR_E2E_HANDOVER.md @@ -1,5 +1,10 @@ # Visitor invite e2e — handover +> **This document describes the invite flow before PR #561.** That PR changed the view that the +> route opens, and the specs now drive the new view. The page object `e2e/support/visitor/invite-form.page.ts` and §1c of +> [`../E2E_USER_STORIES.md`](../E2E_USER_STORIES.md) have the current behaviour. The findings +> and the decisions below are kept as the record of how the coverage was built. + Working notes for the visitor-invite coverage added alongside the existing desk specs. Written to be picked up cold. Coverage contract lives in [`../E2E_USER_STORIES.md`](../E2E_USER_STORIES.md); harness conventions in @@ -62,7 +67,7 @@ Eight new files, no existing file changed except one line in `package.json` |---|---| | `calendar.ts` | driving the shared `date-calendar` widget (schedule sidebar AND the form's date pop-up) | | `visitor.env.ts` | `visitorFor(worker, slot)` + the `VISITOR_SLOTS` table | -| `visitor.api.ts` | guest search/delete, sweeps, `createBookingViaApi`, `checkInViaApi`, `isReg09` | +| `visitor.api.ts` | guest search/delete, sweeps, `createBookingViaApi`, `checkInViaApi`. The REG-09 check, `isReg09`, is in `../api.ts` | | `visitor.settings.ts` | `useSettings()` + named mode presets | | `invite-form.page.ts` | page object for `/#/book/visitor/form` | | `your-bookings.page.ts` | page object for `/#/your-bookings` + details modal | diff --git a/e2e/support/api.ts b/e2e/support/api.ts index 2b2188d50ab..667917117c5 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -271,6 +271,37 @@ export async function readBack( return 'unknown'; } +/** + * Is this response REG-09 (PPT-2642) and not a real rejection? + * + * A burst of booking writes poisons a staff-api connection, and the request + * that lands on it fails for a reason unrelated to what was sent. It has three + * signatures: + * + * 400 "Expected BeginObject but was EOF" — says the body was empty. It was not. + * 500 "could not serialize access" + * 422 {"error":"error validating booking data","failures":[]} + * + * The 422 is not proof on its own. A real validation error names the field it + * refuses, but an asset held by a leftover booking also answers 422 with an + * empty `failures` list (`e2e/README.md`). Every caller bounds its retries, so + * a real refusal still fails the test, only later. + * + * Deliberately narrow. Matching on the status alone would swallow genuine 400s + * and 500s, and a suite that retries real failures is worse than no suite. + * `e2e/support/repro/vis-b6-burst.ts` reproduces the first two signatures with + * no browser. + * + * Delete this, and each retry that calls it, once the stack runs a staff-api + * built on pg-orm >= 2.2.4. A green run is not evidence that the image is fixed. + */ +export function isReg09(status: number, body: string): boolean { + if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; + if (status === 500 && /could not serialize access/.test(body)) return true; + if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; + return false; +} + /** Best-effort cleanup — never throws, so teardown cannot fail a passing test. */ export async function deleteBooking(api: APIRequestContext, id: number): Promise { try { diff --git a/e2e/support/bookings/bookings.api.ts b/e2e/support/bookings/bookings.api.ts index 9dbaed4566c..be460441333 100644 --- a/e2e/support/bookings/bookings.api.ts +++ b/e2e/support/bookings/bookings.api.ts @@ -16,7 +16,15 @@ * only ever a row that ought to show up on a page. */ import { APIRequestContext } from '@playwright/test'; -import { Booking, STAFF_API, currentUser, readBack, releaseAsset, zonesWithTag } from '../api'; +import { + Booking, + STAFF_API, + currentUser, + isReg09, + readBack, + releaseAsset, + zonesWithTag, +} from '../api'; export interface ScheduleBooking extends Booking { checked_in?: boolean; @@ -39,30 +47,6 @@ async function allZones(api: APIRequestContext): Promise { * under test does not care which — it cares that the card renders on the right * day under the right filter. */ -/** - * Is this failure the known concurrent-write defect rather than a bad request? - * - * REG-09 / PPT-2642: a burst of booking writes poisons a staff-api connection, - * and the request that lands on it fails in a way that has nothing to do with - * what was sent. The visitor area found the two 400/500 shapes; a THIRD turned - * up here, under a fifteen-test parallel run: - * - * 422 {"error":"error validating booking data","failures":[]} - * - * An empty `failures` list is the tell. A genuine validation error names the - * field it is unhappy about; this one has nothing to say, because nothing was - * actually wrong with the payload — the same payload succeeds on retry. - * - * Delete this whole retry once the stack runs a staff-api built on - * pg-orm >= 2.2.4, and do not read a green run as evidence the image is fixed. - */ -function isConcurrentWriteFailure(status: number, body: string): boolean { - if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; - if (status === 500 && /could not serialize access/.test(body)) return true; - if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; - return false; -} - export async function createBookingViaApi( api: APIRequestContext, options: { @@ -107,7 +91,7 @@ export async function createBookingViaApi( } last = `HTTP ${res.status()} ${body.slice(0, 200)}`; - if (!isConcurrentWriteFailure(res.status(), body)) { + if (!isReg09(res.status(), body)) { throw new Error( `create ${options.type} booking failed: ${last}. ` + `A 409 means the asset is already held for that window — check the ` + diff --git a/e2e/support/bookings/schedule-views.page.ts b/e2e/support/bookings/schedule-views.page.ts new file mode 100644 index 00000000000..6e055ade512 --- /dev/null +++ b/e2e/support/bookings/schedule-views.page.ts @@ -0,0 +1,73 @@ +/** + * Page object for `/your-bookings` — what the day and week views add. + * + * `schedule.page.ts` and `visitor/your-bookings.page.ts` drive the LIST view, + * where each booking is a card with its id. This class inherits all of that + * (the view links, the type filters, the cards, the details modal and its + * menu) and adds the day and week views. The page opens on the DAY view, + * which has no cards. + * + * ## Three views, one route + * + * The view is the `view` query parameter: `day` (the default), `week` and + * `list`. + * + * - The day and week views draw each booking as a button with no id on it. + * The only handle is the title, so specs give each booking a unique title. + * - The list view draws a `booking-card` for each booking, whose link has + * the booking id in its `href`. Use the inherited `card`. + * + * ## Selector policy + * + * No translated text. Controls with no `name` attribute are matched on their + * icon ligature, which is not translated. + */ +import { Locator, expect } from '@playwright/test'; +import { SchedulePage } from './schedule.page'; + +export type ScheduleView = 'day' | 'week' | 'list'; + +export class ScheduleViewsPage extends SchedulePage { + get topbar(): Locator { + return this.page.locator('schedule-topbar'); + } + + get dayView(): Locator { + return this.page.locator('schedule-day-view'); + } + + get weekView(): Locator { + return this.page.locator('schedule-week-view'); + } + + /** The topbar control that moves the day and week views one period on. */ + get nextDate(): Locator { + return this.topbar + .locator('button[btn]') + .filter({ has: this.page.locator('icon', { hasText: 'keyboard_arrow_right' }) }); + } + + /** The topbar control that goes back to today. It is the first control. */ + get today(): Locator { + return this.topbar.locator('button[btn]').first(); + } + + /** A booking on the day view, by its title. */ + dayBooking(title: string): Locator { + return this.dayView.locator('button').filter({ hasText: title }); + } + + /** A booking on the week view, by its title. */ + weekBooking(title: string): Locator { + return this.weekView.locator('div[body] button').filter({ hasText: title }); + } + + /** + * Open the page on a view. With no view the page opens on its default, + * the day view. The inherited `open` always asks for the list view. + */ + async openView(view?: ScheduleView): Promise { + await this.page.goto(`/#/your-bookings${view ? `?view=${view}` : ''}`); + await expect(this.topbar).toBeVisible({ timeout: 30_000 }); + } +} diff --git a/e2e/support/bookings/schedule.page.ts b/e2e/support/bookings/schedule.page.ts index 142625c6236..2df91f47f64 100644 --- a/e2e/support/bookings/schedule.page.ts +++ b/e2e/support/bookings/schedule.page.ts @@ -19,38 +19,22 @@ * that are already green for no behavioural gain. */ import { Locator, expect } from '@playwright/test'; -import { YourBookingsPage } from '../visitor/your-bookings.page'; +import { YourBookingsPage, type ScheduleType } from '../visitor/your-bookings.page'; /** The five type filters the schedule offers, by the name in their markup. */ -export type ScheduleType = 'event' | 'desk' | 'parking' | 'visitor' | 'locker'; +export type { ScheduleType } from '../visitor/your-bookings.page'; export class SchedulePage extends YourBookingsPage { /** - * A type filter toggle — the VISIBLE one. + * A type filter toggle of the sidebar. * - * Every filter is in the DOM more than once: the sidebar renders a set for - * wide screens and the filter card renders another for narrow ones, and the - * locker filter appears twice within the card alone. Only one copy is on - * screen at a time. - * - * `:visible` is load-bearing, not tidiness. A plain `.first()` picks - * whichever copy comes first in the DOM, which is the HIDDEN one for parking - * and lockers — and clicking a hidden element does not fail fast, it waits. - * Measured: a spec sat on that click for **16 minutes** until the test - * timeout, then reported "the filter did not turn off", which is true and - * says nothing about why. + * The filters are in the DOM more than once: the sidebar has the toggles + * for wide screens, and `schedule-filters` has chips and a filter card for + * narrow ones. The toggles are the sidebar copy only, which is the one on + * screen at the size the suite runs at. */ filterToggle(type: ScheduleType): Locator { - return this.page - .locator(`button[name="schedule-toggle-${type}-filter"]:visible`) - .first(); - } - - /** Is a type currently being shown? Read from the checkbox, not the class. */ - async isShown(type: ScheduleType): Promise { - const checkbox = this.filterToggle(type).locator('input[type="checkbox"]'); - if (!(await checkbox.count())) return false; - return checkbox.isChecked().catch(() => false); + return this.typeFilter(type); } /** @@ -63,18 +47,10 @@ export class SchedulePage extends YourBookingsPage { async setShown(type: ScheduleType, shown: boolean): Promise { const toggle = this.filterToggle(type); if (!(await toggle.count())) { - const in_dom = await this.page - .locator(`button[name="schedule-toggle-${type}-filter"]`) - .count(); throw new Error( - in_dom - ? `the ${type} filter is in the DOM ${in_dom} time(s) but none of ` + - `them is visible, so it cannot be clicked. The schedule renders ` + - `one set of filters for wide screens and another for narrow ones.` - : `the schedule has no ${type} filter at all. It is only rendered ` + - `when that booking type is enabled for the org ` + - `(schedule-state.service.ts: \`_canLoadBookingType\`), so this is ` + - `a configuration difference, not a selector problem.`, + `the schedule sidebar has no ${type} filter. It is only rendered when ` + + `that booking type is enabled for the org, and the sidebar itself ` + + `only from the \`sm\` breakpoint up.`, ); } // CONVERGE, do not click once and hope. The schedule re-renders its @@ -89,7 +65,7 @@ export class SchedulePage extends YourBookingsPage { if ((await this.isShown(type)) !== shown) { // An explicit timeout, so a control that cannot be clicked says // so in seconds rather than sitting until the test times out. - await toggle.click({ timeout: 10_000 }); + await toggle.locator('button').first().click({ timeout: 10_000 }); } expect( await this.isShown(type), @@ -115,8 +91,9 @@ export class SchedulePage extends YourBookingsPage { /** The "remove this filter" chip for a type, shown above the list. */ filterChip(type: ScheduleType): Locator { + // The chips keep the `-mobile` suffix at every screen size. return this.page - .locator(`button[name="schedule-remove-${type}-filter"]`) + .locator(`button[name="schedule-remove-${type}-filter-mobile"]:visible`) .first(); } @@ -177,7 +154,7 @@ export class SchedulePage extends YourBookingsPage { * so the expectation belongs to the test rather than to the class. Setting it * lets the inherited `startEdit` be reused as-is instead of copied. */ - private _edit_form = 'invite-visitor-form'; + private _edit_form = 'visitor-flow-new'; expectEditForm(selector: string): void { this._edit_form = selector; diff --git a/e2e/support/concierge/concierge.api.ts b/e2e/support/concierge/concierge.api.ts index 805b4a72bfe..fd5280a107f 100644 --- a/e2e/support/concierge/concierge.api.ts +++ b/e2e/support/concierge/concierge.api.ts @@ -23,8 +23,9 @@ * * Same retry, for the same reason, as the desk/visitor/parking helpers * (REG-09 / PPT-2642: a burst of booking writes poisons a staff-api connection - * and an unrelated request fails on it). Duplicated rather than shared, like the - * rest of the per-area support, so no area can break another. + * and an unrelated request fails on it). The retry is duplicated, like the rest + * of the per-area support, so no area can break another. The check for REG-09 + * is `isReg09` in `../api.ts`. */ import { APIRequestContext } from '@playwright/test'; import { @@ -33,6 +34,7 @@ import { STAFF_API, currentUser, deleteBooking, + isReg09, readBack, zonesWithTag, } from '../api'; @@ -52,15 +54,6 @@ export async function withZoneChain(api: APIRequestContext): Promise { return [...new Set(ids)]; } -function isConcurrentWriteFailure(status: number, body: string): boolean { - if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; - if (status === 500 && /could not serialize access/.test(body)) return true; - // An empty `failures` list is the tell: a genuine validation error names the - // field it is unhappy about. - if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; - return false; -} - export interface BookingForOptions { /** `desk`, `parking`, `locker`, … */ type: string; @@ -120,7 +113,7 @@ export async function createBookingFor( last = `201 for id ${booking.id}, but GET /bookings/${booking.id} then 404'd (REG-09)`; } else { last = `HTTP ${res.status()} ${body.slice(0, 300)}`; - if (!isConcurrentWriteFailure(res.status(), body)) break; + if (!isReg09(res.status(), body)) break; } await new Promise((r) => setTimeout(r, 400 * attempt)); } diff --git a/e2e/support/desk/GROUP_DESK.md b/e2e/support/desk/GROUP_DESK.md index 20c8bf33dad..a8655edd6de 100644 --- a/e2e/support/desk/GROUP_DESK.md +++ b/e2e/support/desk/GROUP_DESK.md @@ -2,8 +2,9 @@ `apps/workplace/e2e/local/desk-group.spec.ts` implements DESK-GROUP-01–06. It extends the existing local auth fixtures and uses `DeskGroupPage`, which -composes the existing `DeskForm`. API setup and assertions remain in support -helpers. Booking requests use the real local backend. +composes `DeskFlow`, the page object of the `desk-flow-new` booking flow. API +setup and assertions remain in support helpers. Booking requests use the real +local backend. Each test attempt (including retries) creates three temporary non-admin users, its own level, desk metadata and SVG map, then removes its bookings, level and @@ -33,14 +34,22 @@ stack port. The config rejects non-local backends. The selection runs six group tests and one backend preflight. Results are in `reports/e2e/workplace/index.html`. -The group page selects Group again after date initialisation as a safeguard. -Form initialisation keeps a Group selection since the PPT-2643 fix. This suite -does not cover form initialisation. It waits for actual directory results rather than selecting the -similarly labelled external-user option. +The flow has one page and no confirm dialog. The group page selects Group, +sets the date, the time and the members, limits the desk list to the test +level, and selects the first desk of that level. Confirm saves at once. The +flow gives the other members the desks nearest to the selected desk on the map. +A refusal shows as an error notice and the form stays on the page. This suite does +not cover form initialisation. It waits for actual directory results rather +than selecting the similarly labelled external-user option. -Group selection uses the existing English button label. It does not require the -uncommitted `desk-mode-group`/`desk-mode-lone` application attributes. Run with -the English UI, as required by other text locators in the existing desk helper. +Group selection matches the Group button on its icon ligature (`group_add`), +which is not translated. No locator of the group page depends on the language +of the UI. + +Each seeded desk has a `features` list, also when the list is empty. The desk +select of the flow reads the list from every desk of the building. A desk +without it stops the view from rendering a desk selection, in every page that +has the building open. Cleanup checks cancellation responses and verifies captured bookings are deleted. It also queries dedicated actors' desk/group bookings in the scenario window to @@ -58,10 +67,11 @@ repository's disposable isolated stack lifecycle to recover such an interrupted run. This does not validate coexistence with investigation tests that overwrite shared zone metadata/settings. -DESK-GROUP-04 deliberately retains the product-behaviour assertion for the refusal -reason. It currently fails because the application writes to a Fetch Response's -readonly `status`, masking the refusal reason. A failing CI result for this defect -is expected; no `fixme`, expected-failure marker or softened assertion is used. +DESK-GROUP-04 asserts that the refusal reason of the member shows. It passes +against `desk-flow-new`: the success view lists each member, and a member whose +write was refused shows with the reason. It failed against the flow that this +one replaced, because that flow wrote to the readonly `status` of a Fetch +Response and masked the reason. The suite uses Playwright's managed page/context, retaining configured screenshot and video handling. On the first retry it records `group-browser-trace.zip` diff --git a/e2e/support/desk/desk-flow.page.ts b/e2e/support/desk/desk-flow.page.ts new file mode 100644 index 00000000000..8c88ab3320a --- /dev/null +++ b/e2e/support/desk/desk-flow.page.ts @@ -0,0 +1,716 @@ +/** + * Page object for `/book/desk/form` — the `desk-flow-new` booking flow. + * + * ## One page, no modal + * + * The flow that this one replaced had a form, a desk picker modal and a + * confirm overlay. This flow has one page: `desk-flow-details` holds who and + * what, `desk-flow-select` holds when and which desk, and the Confirm button + * sends the booking at once. There is no second confirmation step, so a spec + * must read the form back BEFORE it clicks Confirm. + * + * ## Selector policy + * + * The same as the rest of the suite: no translated text. Most controls here + * have no `name` attribute. They are matched on the element structure and on + * their icon ligature, which is not translated. + * + * ## The filters are in the DOM two times + * + * `desk-flow-select` renders a desktop filter column and a mobile filter sheet + * with the same fields. `desk-flow-details` has a third copy of the date and + * time fields, which shows below the `sm` breakpoint only. One copy is + * visible for a viewport, so every filter locator here is limited to visible + * elements. All copies write to the same form model. + * + * ## The page renders after the action returns + * + * The app runs without zone.js, so it renders the result of a click a moment + * after the click returns. After each action, wait on the thing that changes. + * A read of the page directly after the action can see the old state. + * + * ## The desk list has pages + * + * The list shows ten desks on a page. Other specs add levels and desks while + * this one runs, so a desk can be on a later page. `selectDesk` and + * `deskNames` go through the pages. + * + * ## The form reset of the initialisation (REG-10 / PPT-2643) + * + * This flow resets its form when the component starts, which is before the + * first render. The reset comes later only when the signed-in user is not + * loaded at that time. Then the reset waits for `GET /users/current`, and it + * keeps the edits that the user made in the meantime. The specs do not + * converge on the form state. `desk-initialisation.spec.ts` holds that request + * to put its edits before the reset. + */ +import { Locator, Page, expect } from '@playwright/test'; + +/** Upper limit for a walk through the pages of the desk list. */ +const MAX_LIST_PAGES = 30; + +/** + * The name of a desk, inside its row of the list. The row has no handle for + * it, and the text of the row starts with icon ligatures. + */ +const ROW_NAME = 'button[name="select-item"] > div:nth-child(2) > div:first-child'; + +export class DeskFlow { + constructor(private readonly page: Page) {} + + get root(): Locator { + return this.page.locator('desk-flow-new'); + } + + private get details(): Locator { + return this.root.locator('desk-flow-details'); + } + + /** The visible copy of a control in the filters of `desk-flow-select`. */ + private filter(selector: string): Locator { + return this.root + .locator('desk-flow-select') + .locator(selector) + .filter({ visible: true }); + } + + /** An icon, by its ligature. Anchored, so `person` is not `person_add`. */ + private icon(name: string): Locator { + return this.page.locator('icon', { hasText: new RegExp(`^\\s*${name}\\s*$`) }); + } + + /** The title input. `name$=".title"` because the name is form-scoped. */ + get title(): Locator { + return this.details.locator('input[name$=".title"]'); + } + + /** The button of the date field in the filters. */ + get dateButton(): Locator { + return this.filter('date-field button').first(); + } + + get datePicker(): Locator { + return this.page.locator('.cdk-overlay-container date-calendar'); + } + + /** Rendered only while All Day is off. */ + get startTimeTrigger(): Locator { + return this.filter('button[time-field]'); + } + + /** Rendered only while All Day is off. */ + get durationTrigger(): Locator { + return this.filter('button[duration-field]'); + } + + /** + * The All Day toggle of the filters: the `settings-toggle` directly after + * the date field. This flow shows it with or without `allow_all_day`. + */ + get allDay(): Locator { + return this.filter('date-field + settings-toggle'); + } + + /** The native checkbox of the All Day toggle. Read the state from this. */ + get allDayInput(): Locator { + return this.allDay.locator('input[type="checkbox"]'); + } + + /** + * The native checkbox of the Favorites Only toggle: the other + * `settings-toggle` of the filter column. The feature toggles are one + * level down, so the sibling selector does not match them. + */ + get favouritesOnly(): Locator { + return this.filter('date-field + settings-toggle ~ settings-toggle').locator( + 'input[type="checkbox"]', + ); + } + + /** + * The "Require locker" checkbox, rendered when `desks.can_book_lockers` + * is on. It defaults to unchecked. + * + * It is the only checkbox of the details that has no `name`: the other + * ones (`all_day`, `update_master`) are bound to a form field, and this + * one is not. + */ + get requireLocker(): Locator { + return this.details.locator('mat-checkbox input[type="checkbox"]:not([name])'); + } + + /** + * The booking type buttons. They show only when `desks.allow_groups` or + * `can_book_for_others` is on. + */ + get groupModeButton(): Locator { + return this.details.locator('button[btn]').filter({ has: this.icon('group_add') }); + } + + get bookForOtherButton(): Locator { + return this.details.locator('button[btn]').filter({ has: this.icon('person_add') }); + } + + /** The host field, rendered only while "Book for other" is active. */ + get userField(): Locator { + return this.details.locator('a-user-search-field'); + } + + /** The member field, rendered only while Group is active. */ + get groupField(): Locator { + return this.details.locator('a-user-list-field'); + } + + get groupMembers(): Locator { + return this.groupField.locator('mat-chip-row[user]'); + } + + /** The desk filter for levels. Rendered only in the list view. */ + get levelSelect(): Locator { + return this.root.locator('mat-select[name="location-multi"]'); + } + + private get list(): Locator { + return this.root.locator('desk-flow-select-list'); + } + + /** The desks the list offers on the current page. */ + get desks(): Locator { + return this.list.locator('li[item]'); + } + + /** The placeholder the list shows when it offers no desk. */ + get noDesks(): Locator { + return this.list.locator('[empty]'); + } + + /** + * One desk on the current page of the list, by the name it shows. + * + * `.first()`, because a long-lived stack can list the same desk id on two + * levels. Assert the stored `asset_id` when the row is used to book. + */ + desk(name: string): Locator { + return this.desks + .filter({ has: this.page.getByText(name, { exact: true }) }) + .first(); + } + + /** The favourite control of a desk row. */ + favouriteToggle(row: Locator): Locator { + return row.locator('button[name="toggle-item-favourite"]'); + } + + /** The desks on the current page of the list that show as selected. */ + get chosenDesks(): Locator { + return this.desks.filter({ has: this.icon('task_alt') }); + } + + private get nextPage(): Locator { + return this.list.locator('button[icon]').filter({ + has: this.icon('keyboard_arrow_right'), + }); + } + + private get previousPage(): Locator { + return this.list.locator('button[icon]').filter({ + has: this.icon('keyboard_arrow_left'), + }); + } + + /** The Confirm button. It shows a spinner icon while the booking saves. */ + get confirm(): Locator { + return this.root.locator('button[btn]').filter({ has: this.icon('task_alt') }); + } + + /** + * The success view. The host element has no box of its own, so use + * `successContinue` to assert that the view shows. + */ + get success(): Locator { + return this.page.locator('desk-flow-success'); + } + + get successContinue(): Locator { + return this.success.locator('[name="desk-confirm-continue"]'); + } + + async open(query = ''): Promise { + await this.page.goto(`/#/book/desk/form${query}`); + await expect( + this.title, + 'the desk flow never rendered its title field — is the `desks` feature enabled?', + ).toBeVisible({ timeout: 30_000 }); + } + + /** + * Set a checkbox to a state. Reads the real state first: a click on a box + * that is already in the target state inverts the intent. + * + * A box that is not in the page is off already, so `false` passes for it. + * `true` fails for it. + */ + async setChecked(box: Locator, want: boolean): Promise { + if (!want && !(await box.count())) return; + if ((await box.isChecked()) !== want) { + // A `settings-toggle` takes the click on its button: its checkbox + // ignores pointer events. A plain `mat-checkbox` takes it on the + // native input, which lies over the box. Never `force` the click. + // A forced click goes to the element on top, and while the app + // starts that is its loading screen. + const toggle = box.locator('xpath=ancestor::settings-toggle/button'); + await ((await toggle.count()) ? toggle : box).click({ timeout: 20_000 }); + } + await expect(box, `the box should be ${want ? 'on' : 'off'}`).toBeChecked({ + checked: want, + }); + } + + async setAllDay(want: boolean): Promise { + await this.setChecked(this.allDayInput, want); + } + + /** + * Turn the page of the desk list with one of its two buttons, and wait + * for the list to show the new page. Returns false when the list has no + * such page. + * + * The wait is on the bar that holds the buttons: its text has the range + * of the page. Only a change of that text is used, not the words in it. + */ + private async turnListPage(button: Locator): Promise { + if (!(await button.isVisible()) || !(await button.isEnabled())) return false; + const bar = button.locator('xpath=..'); + const before = (await bar.textContent()) ?? ''; + await button.click({ timeout: 5_000 }); + await expect(bar).not.toHaveText(before, { timeout: 5_000 }); + return true; + } + + /** + * Run `visit` on each page of the desk list, from the first page. Stops + * when `visit` returns true or after the last page. Ends on the page of + * the last visit. + */ + private async eachListPage(visit: () => Promise): Promise { + for (let page = 0; page < MAX_LIST_PAGES; page++) { + if (!(await this.turnListPage(this.previousPage))) break; + } + for (let page = 0; page < MAX_LIST_PAGES; page++) { + if (await visit()) return true; + if (!(await this.turnListPage(this.nextPage))) return false; + } + return false; + } + + /** + * Wait until the list has an answer for the current filters: desks, or + * the empty placeholder. The list shows a spinner while it loads. + * + * The placeholder also shows for a moment after the page opens, before + * the first desks load. So an empty answer is not final: converge on the + * desks that a spec expects. + */ + async waitForDesks(timeout = 20_000): Promise { + await expect( + this.desks.first().or(this.noDesks), + 'the desk list did not finish loading', + ).toBeVisible({ timeout }); + } + + /** + * The names of all desks the list offers, on all of its pages. + * + * Converging, because the list loads again after each change to the date, + * the time or a filter (debounced by 500 ms), and a walk through the pages + * cannot continue across a reload. A reload can still fall between two + * pages and change the result, and the list is empty for a moment after + * the page opens. So poll this for the names that a spec expects. + */ + async deskNames(): Promise { + let names: string[] = []; + await expect(async () => { + await this.waitForDesks(5_000); + names = []; + await this.eachListPage(async () => { + const page_names = await this.desks.locator(ROW_NAME).allInnerTexts(); + names.push(...page_names.map((name) => name.trim())); + return false; + }); + }).toPass({ timeout: 30_000 }); + return names; + } + + /** + * Bring the row of a desk onto the current page of the list. + * + * Converging, for the same reason as `deskNames`: a walk through the + * pages that crosses a reload of the list can miss the desk. + */ + async findDesk(name: string): Promise { + const row = this.desk(name); + const found = await expect(async () => { + await this.waitForDesks(5_000); + expect(await this.eachListPage(() => row.isVisible())).toBe(true); + }) + .toPass({ timeout: 20_000 }) + .then(() => true) + .catch(() => false); + if (!found) { + // Say what IS offered. "Not found" cannot tell apart a desk that + // was never seeded, one that a filter hides, and one that is + // already booked at the chosen time. + const offered = await this.deskNames(); + throw new Error( + `no desk called "${name}" in the list. It offers ${offered.length}: ` + + `${JSON.stringify(offered)}. Check the desk is seeded (seed.ts ` + + `writes WORKERS + 1 of them into the level zone's \`desks\` ` + + `metadata), and that it is free at the time chosen — a booked ` + + `desk is simply absent.`, + ); + } + return row; + } + + /** + * Select a desk by name in the list, and wait for the list to show it as + * selected. The desk is matched on its NAME, which is test data we seeded + * (`E2E Desk 0`), not app copy. + * + * The flow holds one desk (`desks.allow_multiple` is off), so this + * replaces a desk that was selected before. + */ + async selectDesk(name: string): Promise { + const row = await this.findDesk(name); + await row.locator('button[name="select-item"]').click(); + await expect( + row.locator('icon', { hasText: 'task_alt' }), + `desk "${name}" should show as selected`, + ).toBeVisible(); + } + + /** + * The names of the desks that show as selected on the current page. + * + * `chosenDesks` is enough to know a desk is chosen and NOT enough to know + * WHICH — and when a booking is moved from one desk to another the count + * never changes. Asserting on the name is the only way to see the swap + * land. + * + * A locator, so that `toHaveText([...])` waits: the list shows a spinner + * in place of its rows while it loads again. + */ + get chosenDeskNames(): Locator { + return this.chosenDesks.locator(ROW_NAME); + } + + /** + * Limit the desk list to one level, by the name the level shows. + * + * The filter is a multiple `mat-select`: its panel stays open after a + * choice, and a click on a chosen option removes it. So this reads + * `aria-selected` before each click. + * + * Converging, because the panel does not always stay open: seen under a + * full parallel run as a select that has the focus and shows no options. + * Each step reads the state first, so a second pass is safe. + * + * The options are matched in the page and not in `.cdk-overlay-container`: + * the select renders its panel as a popover inside its own element. + */ + async chooseLevel(name: string): Promise { + const options = this.page.locator('mat-option'); + // `.first()`: a run that died before teardown can leave a second + // level with this name. + const wanted = options + .filter({ has: this.page.getByText(name, { exact: true }) }) + .first(); + // The levels that an earlier choice left selected. + const others = options + .and(this.page.locator('[aria-selected="true"]')) + .filter({ hasNot: this.page.getByText(name, { exact: true }) }); + await expect(async () => { + if ((await this.levelSelect.getAttribute('aria-expanded')) !== 'true') { + await this.levelSelect.click({ timeout: 5_000 }); + } + await expect( + wanted, + `the level "${name}" is not among the options of the level filter. It ` + + `offers the levels of the building that have bookable desks`, + ).toBeVisible({ timeout: 3_000 }); + for (let left = await others.count(); left > 0; left--) { + await others.first().click({ timeout: 3_000 }); + await expect(others).toHaveCount(left - 1, { timeout: 3_000 }); + } + if ((await wanted.getAttribute('aria-selected')) !== 'true') { + await wanted.click({ timeout: 3_000 }); + } + await expect(wanted).toHaveAttribute('aria-selected', 'true', { timeout: 3_000 }); + }).toPass({ timeout: 30_000 }); + await this.page.keyboard.press('Escape'); + await expect(options.first()).toBeHidden(); + await expect(this.levelSelect).toContainText(name); + } + + /** Make "Book for other" the booking type, if it is not already. */ + async bookForOther(): Promise { + // A second click on the button clears the chosen person, so click + // only when the host field is not there. + if (!(await this.userField.isVisible())) await this.bookForOtherButton.click(); + await expect(this.userField).toBeVisible(); + } + + /** Make Group the booking type, if it is not already. */ + async bookForGroup(): Promise { + if (!(await this.groupField.isVisible())) await this.groupModeButton.click(); + await expect(this.groupField).toBeVisible(); + } + + /** + * Type an address into a user autocomplete and pick the directory user + * that has it. + * + * ## It has to be TYPED, not filled + * + * `fill()` sets the value and dispatches an input event, and these fields + * ignore it: nothing searched, no panel, no requests to `/users` at all. + * Measured — the probe that found this saw zero network calls. The search + * is driven from real key events, so `pressSequentially` is the only + * thing that works. + * + * ## Wait for the PERSON, not for an option + * + * The panel opens with a placeholder option ("No users found.", or an + * "add external" offer) while the search is on its way: the search is + * debounced by 300 ms and then asks the backend. So this waits for the + * option of a directory user, which has an avatar, that shows the address. + * The address is data and each option renders it below the name. + */ + private async pickDirectoryUser(input: Locator, email: string): Promise { + const option = this.page + .locator('mat-option') + .filter({ has: this.page.locator('a-user-avatar') }) + .filter({ hasText: email }) + .first(); + + for (let attempt = 1; attempt <= 3; attempt++) { + await input.click(); + // Select-all and overwrite, so text from an earlier attempt or an + // earlier choice cannot stay in front of the search. + await input.press('ControlOrMeta+a'); + await input.press('Backspace'); + await input.pressSequentially(email, { delay: 30 }); + // The host field selects its text a moment after it gets the + // focus. When the page is busy that select comes after the first + // key, and the second key then replaces the first. Type again: + // the field still has the focus, so it does not select again. + if ((await input.inputValue()) !== email) continue; + const appeared = await option + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!appeared) continue; + await option.click(); + return; + } + + const offered = await this.page.locator('mat-option').allInnerTexts(); + throw new Error( + `no autocomplete option for ${email} after 3 tries. The panel offered ` + + `${offered.length}: ${JSON.stringify( + offered.map((t) => t.replace(/\s+/g, ' ').trim()).slice(0, 8), + )}. The user has to exist in the directory — seed.ts creates one staff ` + + `user per worker — and the field only searches on real key events, so ` + + `check the typing reached it.`, + ); + } + + /** + * Choose who the booking is for, from the host field's autocomplete. The + * field is there only while "Book for other" is active, see `bookForOther`. + * + * The choice only lands when an option is PICKED. Text left in the box is + * not a person, and the flow then refuses to confirm. + */ + async chooseUser(email: string): Promise { + await this.pickDirectoryUser(this.userField.locator('input').first(), email); + // The field shows the avatar of the person once it holds one. + await expect( + this.userField.locator('a-user-avatar'), + `the host field did not take ${email}`, + ).toBeVisible(); + } + + /** + * Add a colleague to the group member list from the user autocomplete. + * The list is there only while Group is active, see `bookForGroup`. + */ + async addGroupMember(email: string): Promise { + const input = this.groupField.locator('input[name="user_email"]'); + await this.pickDirectoryUser(input, email); + // The component clears search asynchronously after selection. + await expect(input).toHaveValue(''); + } + + /** + * Open the date picker. The picker is a `customTooltip` on the date + * button, and a click on the button of an open picker closes it. + */ + async openDatePicker(): Promise { + if (!(await this.datePicker.first().isVisible())) await this.dateButton.click(); + await expect(this.datePicker.first(), 'the date picker did not open').toBeVisible(); + } + + /** + * Drive the date picker to a given day. + * + * The calendar is a month grid of day cells with exactly one carrying the + * "today" ring, so the only reliable way to reach a date is to find that + * cell and count from it. The ring is drawn from a value captured at page + * load, which is why its own day-of-month is read back and compared with the + * browser clock rather than assumed — that is what makes this survive a run + * crossing midnight. + * + * Cells outside the bookable range are DISABLED rather than absent + * (`date-calendar.component.ts`: `[disabled]="day.id < from() || day.id > to()"`), + * and for desks the range is 90 days by default (`desks.available_period`). + * A disabled cell is reported as such instead of timing out on a click. + */ + async pickDate(timestamp_ms: number): Promise { + const target = new Date(timestamp_ms); + target.setHours(0, 0, 0, 0); + + await this.openDatePicker(); + const cells = this.datePicker.locator('button[name="schedule-set-date"]'); + await expect(cells.first(), 'the calendar rendered no day cells').toBeVisible(); + const grid = await cells.evaluateAll((els) => + els.map((el) => ({ + text: (el.textContent ?? '').trim(), + disabled: (el as HTMLButtonElement).disabled, + today: !!el.querySelector('div.border-secondary'), + })), + ); + + const today_index = grid.findIndex((cell) => cell.today); + expect( + today_index, + `no "today" cell to count from. The grid holds ${grid.length} cells: ` + + `${JSON.stringify(grid.map((c) => c.text))}`, + ).toBeGreaterThanOrEqual(0); + + const label = Number(grid[today_index].text); + const browser_now = await this.page.evaluate(() => Date.now()); + const anchor = [0, -1] + .map((offset) => { + const day = new Date(browser_now); + day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() + offset); + return day; + }) + .find((day) => day.getDate() === label); + expect( + anchor, + `the picker marks day ${label} as today, which is neither today nor ` + + `yesterday by the browser clock`, + ).toBeTruthy(); + + const days = Math.round((target.valueOf() - anchor!.valueOf()) / 86_400_000); + const index = today_index + days; + expect( + index >= 0 && index < grid.length, + `${target.toDateString()} is ${days} day(s) from the cell marked today ` + + `(index ${today_index} of ${grid.length}), outside the month grid ` + + `the picker shows`, + ).toBe(true); + expect( + grid[index].disabled, + `${target.toDateString()} is offered but DISABLED. Desks can be booked ` + + '`desks.available_period` days ahead (90 by default), so the day is ' + + 'either too far out or in the past', + ).toBe(false); + + await cells.nth(index).click({ timeout: 10_000 }); + await expect( + this.datePicker.first(), + 'the date picker stayed open after choosing a day', + ).toBeHidden({ timeout: 10_000 }); + } + + /** Set the start time from the options offered, as "HH:mm". */ + async setStartTime(time: string): Promise { + await this.startTimeTrigger.click(); + const option = this.page.locator(`button[data-time="${time}"]`); + await expect( + option, + `no start-time option for ${time} — the form offers times on a fixed step ` + + `and only inside its bookable hours, so pick one it lists`, + ).toBeVisible({ timeout: 10_000 }); + await option.click(); + await expect(option).toBeHidden(); + } + + /** Set the length in minutes, from the options offered. */ + async setDuration(minutes: number): Promise { + await this.durationTrigger.click(); + const option = this.page.locator(`button[data-duration="${minutes}"]`); + await expect( + option, + `no ${minutes} minute option — lengths come on a fixed step up to ` + + '`max_duration`', + ).toBeVisible({ timeout: 10_000 }); + // CDK places this menu's backdrop immediately before its overlay host. + const backdrop = await option.evaluateHandle( + (el) => + el.closest('.cdk-overlay-pane')!.parentElement! + .previousElementSibling, + ); + await option.click(); + if ( + (await this.durationTrigger.getAttribute('aria-expanded')) === + 'true' + ) { + await this.page.keyboard.press('Escape'); + } + await expect(option).toBeHidden(); + await expect + .poll(() => backdrop.evaluate((el) => el?.isConnected ?? false)) + .toBe(false); + await backdrop.dispose(); + } + + /** Every start time on offer, read from `data-time` rather than the label. */ + async startTimeOptions(): Promise { + await this.startTimeTrigger.click(); + const options = this.page.locator('button[data-time]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => el.getAttribute('data-time') ?? ''), + ); + await this.page.keyboard.press('Escape'); + await expect(options.first()).toBeHidden(); + return values.filter(Boolean); + } + + /** Every length on offer, in minutes. */ + async durationOptions(): Promise { + await this.durationTrigger.click(); + const options = this.page.locator('button[data-duration]'); + await expect(options.first()).toBeVisible({ timeout: 10_000 }); + const values = await options.evaluateAll((els) => + els.map((el) => Number(el.getAttribute('data-duration'))), + ); + await this.page.keyboard.press('Escape'); + await expect(options.first()).toBeHidden(); + return values.filter((v) => Number.isFinite(v)); + } + + /** + * Send the booking. Confirm saves at once: there is no confirm dialog. + * + * A form that is not valid gets an error notice and no request, so a spec + * that waits for the response of this click times out on that wait. + */ + async confirmAndSend(): Promise { + await this.confirm.click(); + } +} diff --git a/e2e/support/desk/desk-form.page.ts b/e2e/support/desk/desk-form.page.ts deleted file mode 100644 index 0734d09d90d..00000000000 --- a/e2e/support/desk/desk-form.page.ts +++ /dev/null @@ -1,521 +0,0 @@ -/** - * Page object for `/book/desk/form` — the desk booking flow. - * - * SELECTOR POLICY, the same one the rest of the suite settled on: nothing here - * depends on visible text, because every label is translated. The exceptions are - * called out where they occur. - * - * ## What the dev's `bookDeskViaUI` already does, and why this exists anyway - * - * `e2e/support/flows.ts` books a desk end to end and is left untouched: it is - * what the original desk specs use. It also fixes two choices — All Day on, - * Require locker off — which is right for "prove a desk can be booked" and wrong - * for everything added here, where the TIMES and the ALL-DAY flag are the - * subject rather than a means to an end. - * - * So this is a page object rather than a flow: it exposes the fields and lets - * each spec drive them. Anything about the form that was learned the hard way is - * recorded here. - * - * ## The form is rebuilt while it initialises (REG-10 / PPT-2643) - * - * When async init completes the form is rebuilt and defaults are restored — - * title back to "Booking", All Day back off. It is a - * RACE, not a step: on a warm run it lands before anything is typed. Every - * setter here is therefore used inside a converging block by its callers, the - * same workaround `bookDeskViaUI` uses, and for the same reason it means these - * specs cannot DETECT that bug (REG-10 has its own row saying so). The one - * exception is `desk-initialisation.spec.ts`, which holds the settle timer of - * the desk flow so that its edits come before the rebuild. - */ -import { Locator, Page, expect } from '@playwright/test'; - -export class DeskForm { - constructor(private readonly page: Page) {} - - get root(): Locator { - return this.page.locator('desk-flow-form'); - } - - /** The title input. `name$=".title"` because the name is form-scoped. */ - get title(): Locator { - return this.page.locator('input[name$=".title"]').first(); - } - - /** - * The date field's button. - * - * NOT `#date button`: that id belongs to the MEETING form's own markup. The - * desk form uses the shared `a-date-field` component, which renders a bare - * button with no id and no name — so the field element is the handle and the - * button is its only child of that kind. Measured; the borrowed meeting - * selector simply timed out against an element that does not exist here. - */ - get dateButton(): Locator { - return this.root.locator('a-date-field button, date-field button').first(); - } - - get datePicker(): Locator { - return this.page.locator('.cdk-overlay-container date-calendar'); - } - - /** - * Scoped to the FORM, not the page. - * - * The desk picker modal carries its own date and time fields, so an - * unscoped `button[time-field]` can match the modal's copy instead — and - * then the click changes nothing about what gets booked. - */ - get startTimeTrigger(): Locator { - return this.root.locator('button[time-field]').first(); - } - - get durationTrigger(): Locator { - return this.root.locator('button[duration-field]').first(); - } - - /** The All Day checkbox — matched through its label's text, see below. */ - get allDay(): Locator { - return this.page.locator( - 'mat-checkbox:has-text("All Day") input[type="checkbox"]', - ); - } - - /** - * The "Require locker" checkbox. - * - * It defaults to unchecked. Specs clear it as a safeguard, because no - * lockers are seeded. - * - * Matched on label text, which breaks the no-visible-text rule and is - * inherited from the dev's `setCheckbox` helper: these two checkboxes carry - * no name or id at all. Changing that is an app change, not a test change. - */ - get requireLocker(): Locator { - return this.page.locator( - 'mat-checkbox:has-text("Require locker") input[type="checkbox"]', - ); - } - - /** The user field, rendered only when booking for others is enabled. */ - get userField(): Locator { - return this.root.locator('a-user-field, a-user-search-field').first(); - } - - /** - * Choose who the booking is for, from the host field's autocomplete. - * - * ## It has to be TYPED, not filled - * - * `fill()` sets the value and dispatches an input event, and this field - * ignores it: nothing searched, no panel, no requests to `/users` at all. - * Measured — the probe that found this saw zero network calls. The input - * carries a `keyboard` directive and the search is driven from real key - * events, so `pressSequentially` is the only thing that works. - * - * ## And the option shows the NAME - * - * Each option renders the user's name above their email, so an option for - * `e2e-staff-1@place.tech` reads "E2E Staff 1 (non-admin)". Matching is done - * on the email, which is in the option too, with punctuation stripped from - * both sides so a display style like "E2E Staff 1" cannot cause a miss. - * - * The choice only lands when an option is PICKED. Leaving the text in the - * box books the desk for whoever filled the form, silently — the same trap - * the visitor and attendee fields have. - */ - async chooseUser(email: string): Promise { - const flat = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, ''); - const input = this.userField.locator('input').first(); - const options = this.page.locator('mat-option'); - - for (let attempt = 1; attempt <= 3; attempt++) { - await input.click(); - // CLEAR IT PROPERLY. The field arrives pre-filled with the signed-in - // user's name and `fill('')` does not stick — the model writes it - // straight back. Typing then APPENDS, and the search goes out as - // "e2e staff 0 (non-admin)e-staff-1", which finds nobody. Select-all - // and overwrite is what works. - await input.press('ControlOrMeta+a'); - await input.press('Backspace'); - await input.pressSequentially(email, { delay: 30 }); - const appeared = await options - .first() - .waitFor({ state: 'visible', timeout: 10_000 }) - .then(() => true) - .catch(() => false); - if (!appeared) continue; - - const texts = await options.allInnerTexts(); - const index = texts.findIndex((text) => flat(text).includes(flat(email))); - if (index < 0) continue; - await options.nth(index).click(); - return; - } - - const offered = await options.allInnerTexts(); - throw new Error( - `no autocomplete option for ${email} after 3 tries. The panel offered ` + - `${offered.length}: ${JSON.stringify( - offered.map((t) => t.replace(/\s+/g, ' ').trim()).slice(0, 8), - )}. The user has to exist in the directory — seed.ts creates one staff ` + - `user per worker — and the field only searches on real key events, so ` + - `check the typing reached it.`, - ); - } - - /** Add a colleague to the group member list from the user autocomplete. */ - async addGroupMember(email: string): Promise { - const field = this.root.locator('a-user-list-field'); - const input = field.locator('input[name="user_email"]'); - // Wait for actual directory users, not the immediately offered - // "Add external" option containing the same email. - const options = this.page.locator('mat-option').filter({ - has: this.page.locator('a-user-avatar'), - }); - const flat = (value: string) => - value.toLowerCase().replace(/[^a-z0-9]/g, ''); - - for (let attempt = 1; attempt <= 3; attempt++) { - await input.click(); - await input.press('ControlOrMeta+a'); - await input.press('Backspace'); - await input.pressSequentially(email, { delay: 30 }); - const appeared = await options - .first() - .waitFor({ state: 'visible', timeout: 10_000 }) - .then(() => true) - .catch(() => false); - if (!appeared) continue; - - const texts = await options.allInnerTexts(); - const index = texts.findIndex((text) => - flat(text).includes(flat(email)), - ); - if (index >= 0) { - await options.nth(index).click(); - // The component clears search asynchronously after selection. - await expect(input).toHaveValue(''); - return; - } - } - - throw new Error(`no group member autocomplete option for ${email}`); - } - - get groupMembers(): Locator { - return this.root.locator('a-user-list-field mat-chip-row[user]'); - } - - get groupModeButton(): Locator { - // English-label exception: no additional application selector is needed. - return this.root.getByRole('button', { name: /\bGroup$/ }); - } - - get addDeskButton(): Locator { - return this.page.locator('button[name="add-desk"]'); - } - - /** The desks currently chosen on the form. */ - get chosenDesks(): Locator { - return this.root.locator('[name="remove-desk"], button[name="remove-desk"]'); - } - - get confirmViewButton(): Locator { - return this.page.locator('button[name="open-desk-confirm"]'); - } - - /** The final confirm, which lives in the overlay rather than in a dialog. */ - get confirmButton(): Locator { - return this.page.locator('.cdk-overlay-container button[name="confirm-desk"]'); - } - - async open(): Promise { - await this.page.goto('/#/book/desk/form'); - await expect( - this.root, - 'the desk form never rendered — is the `desks` feature enabled?', - ).toBeVisible({ timeout: 30_000 }); - await expect( - this.title, - 'the desk form rendered without its title field', - ).toBeVisible({ timeout: 30_000 }); - } - - /** Set a checkbox to a state, never blindly toggling it. */ - async setChecked(box: Locator, want: boolean): Promise { - if (!(await box.count())) return; - if ((await box.isChecked()) === want) return; - // `force`, because Material hides the real input behind its own markup. - await box.click({ force: true, timeout: 10_000 }); - } - - /** - * Pick a desk by name from the picker. - * - * Three steps: `add-desk` opens it, `select-desk` chooses a row, and - * `toggle-desk` confirms — the last is only enabled once a row is selected. - * The desk is matched on its NAME, which is test data we seeded - * (`E2E Desk 0`), not app copy. - */ - async chooseDesk(name: string): Promise { - await this.addDeskButton.click(); - const option = this.page - .locator('button[name="select-desk"]') - .filter({ hasText: name }) - .first(); - const found = await option - .waitFor({ state: 'visible', timeout: 20_000 }) - .then(() => true) - .catch(() => false); - if (!found) { - // Say what IS offered. "Not found" cannot tell apart a desk that was - // never seeded, one on the wrong zone, and one the picker is hiding - // because it is already booked at the chosen time. - const offered = await this.page - .locator('button[name="select-desk"]') - .allInnerTexts(); - throw new Error( - `no desk called "${name}" in the picker. It offers ${offered.length}: ` + - `${JSON.stringify(offered.map((t) => t.split('\n')[0].trim()))}. ` + - `Check the desk is seeded (seed.ts writes WORKERS + 1 of them into ` + - `the level zone's \`desks\` metadata), and that it is free at the ` + - `time chosen — a booked desk is simply absent.`, - ); - } - await option.click(); - const confirm = this.page.locator('button[name="toggle-desk"]'); - await expect( - confirm, - 'the desk picker has no confirm button', - ).toBeEnabled({ timeout: 10_000 }); - await confirm.click(); - } - - /** - * The names of the desks currently on the form. - * - * `chosenDesks` counts the remove buttons, which is enough to know a desk is - * chosen and NOT enough to know WHICH — and when a booking is moved from one - * desk to another the count never changes. Asserting on the name is the only - * way to see the swap land. - */ - async chosenDeskNames(): Promise { - const rows = this.root.locator('desk-list-field div[desk]'); - const texts = await rows.allInnerTexts(); - return texts.map((t) => t.split('\n')[0].trim()); - } - - /** - * Take the chosen desk off the form, so another can be picked. - * - * CONVERGING, because the form is rebuilt while it initialises and a click - * that lands mid-rebuild does nothing — seen under a full parallel run as - * "the desk was not removed from the form" on a click that plainly - * happened. Re-clicking is safe: the loop stops as soon as the row is gone. - */ - async removeDesk(): Promise { - const before = await this.chosenDesks.count(); - if (!before) return; - await expect(async () => { - if ((await this.chosenDesks.count()) >= before) { - await this.chosenDesks.first().click({ timeout: 10_000 }); - } - expect( - await this.chosenDesks.count(), - 'the desk was not removed from the form', - ).toBeLessThan(before); - }).toPass({ timeout: 30_000 }); - } - - /** - * Open the date picker, and keep trying until it is actually open. - * - * The picker is a `customTooltip` attached to the date button, which opens on - * click — so one click SHOULD be enough. It is not: the desk form is rebuilt - * when its async initialisation completes (REG-10), and a click that lands on - * the old element does nothing at all, silently. Measured: the calendar - * failed to appear about one attempt in two on a cold page, and the failure - * surfaced later as a timeout clicking a day cell that was never there. - */ - async openDatePicker(): Promise { - await expect(async () => { - if (!(await this.datePicker.first().isVisible().catch(() => false))) { - await this.dateButton.click({ timeout: 10_000 }); - } - await expect( - this.datePicker.first(), - 'the date picker did not open', - ).toBeVisible({ timeout: 5_000 }); - }).toPass({ timeout: 45_000 }); - } - - /** - * Drive the date picker to a given day. - * - * The calendar is a month grid of day cells with exactly one carrying the - * "today" ring, so the only reliable way to reach a date is to find that - * cell and count from it. The ring is drawn from a value captured at page - * load, which is why its own day-of-month is read back and compared with the - * browser clock rather than assumed — that is what makes this survive a run - * crossing midnight. - * - * Cells outside the bookable range are DISABLED rather than absent - * (`date-calendar.component.ts`: `[disabled]="day.id < from() || day.id > to()"`), - * and for desks the range is 90 days by default (`desks.available_period`). - * A disabled cell is reported as such instead of timing out on a click. - */ - async pickDate(timestamp_ms: number): Promise { - // ONE converging attempt covering open, read, click and close. - // - // Every step here has been seen to fail on its own: the picker does not - // open (the form rebuilt underneath the click), it opens with an EMPTY - // grid for a tick, or it is torn down and recreated between reading the - // cells and clicking one — which leaves the locator pointing at a - // detached node and reports "0 cells" for a calendar that is plainly on - // screen. Retrying the whole sequence is the only thing that is stable, - // and it is safe because picking a date is idempotent. - const target = new Date(timestamp_ms); - target.setHours(0, 0, 0, 0); - - await expect(async () => { - await this.openDatePicker(); - const cells = this.datePicker.locator('button[name="schedule-set-date"]'); - const grid = await cells.evaluateAll((els) => - els.map((el) => ({ - text: (el.textContent ?? '').trim(), - disabled: (el as HTMLButtonElement).disabled, - today: !!el.querySelector('div.border-secondary'), - })), - ); - expect(grid.length, 'the calendar rendered no day cells').toBeGreaterThan(0); - - const today_index = grid.findIndex((cell) => cell.today); - expect( - today_index, - `no "today" cell to count from. The grid holds ${grid.length} cells: ` + - `${JSON.stringify(grid.map((c) => c.text))}`, - ).toBeGreaterThanOrEqual(0); - - const label = Number(grid[today_index].text); - const browser_now = await this.page.evaluate(() => Date.now()); - const anchor = [0, -1] - .map((offset) => { - const day = new Date(browser_now); - day.setHours(0, 0, 0, 0); - day.setDate(day.getDate() + offset); - return day; - }) - .find((day) => day.getDate() === label); - expect( - anchor, - `the picker marks day ${label} as today, which is neither today nor ` + - `yesterday by the browser clock`, - ).toBeTruthy(); - - const days = Math.round((target.valueOf() - anchor!.valueOf()) / 86_400_000); - const index = today_index + days; - expect( - index >= 0 && index < grid.length, - `${target.toDateString()} is ${days} day(s) from the cell marked today ` + - `(index ${today_index} of ${grid.length}), outside the month grid ` + - `the picker shows`, - ).toBe(true); - expect( - grid[index].disabled, - `${target.toDateString()} is offered but DISABLED. Desks can be booked ` + - '`desks.available_period` days ahead (90 by default), so the day is ' + - 'either too far out or in the past', - ).toBe(false); - - await cells.nth(index).click({ timeout: 10_000 }); - await expect( - this.datePicker.first(), - 'the date picker stayed open after choosing a day', - ).toBeHidden({ timeout: 10_000 }); - }).toPass({ timeout: 60_000 }); - } - - /** Set the start time from the options offered, as "HH:mm". */ - async setStartTime(time: string): Promise { - await this.startTimeTrigger.click(); - const option = this.page.locator(`button[data-time="${time}"]`); - await expect( - option, - `no start-time option for ${time} — the form offers times on a fixed step ` + - `and only inside its bookable hours, so pick one it lists`, - ).toBeVisible({ timeout: 10_000 }); - await option.click(); - } - - /** Set the length in minutes, from the options offered. */ - async setDuration(minutes: number): Promise { - await this.durationTrigger.click(); - const option = this.page.locator(`button[data-duration="${minutes}"]`); - await expect( - option, - `no ${minutes} minute option — lengths come on a fixed step up to ` + - '`max_duration`', - ).toBeVisible({ timeout: 10_000 }); - // CDK places this menu's backdrop immediately before its overlay host. - const backdrop = await option.evaluateHandle( - (el) => - el.closest('.cdk-overlay-pane')!.parentElement! - .previousElementSibling, - ); - await option.click(); - if ( - (await this.durationTrigger.getAttribute('aria-expanded')) === - 'true' - ) { - await this.page.keyboard.press('Escape'); - } - await expect(option).toBeHidden(); - await expect - .poll(() => backdrop.evaluate((el) => el?.isConnected ?? false)) - .toBe(false); - await backdrop.dispose(); - } - - /** Every start time on offer, read from `data-time` rather than the label. */ - async startTimeOptions(): Promise { - await this.startTimeTrigger.click(); - const options = this.page.locator('button[data-time]'); - await expect(options.first()).toBeVisible({ timeout: 10_000 }); - const values = await options.evaluateAll((els) => - els.map((el) => el.getAttribute('data-time') ?? ''), - ); - await this.page.keyboard.press('Escape'); - return values.filter(Boolean); - } - - /** Every length on offer, in minutes. */ - async durationOptions(): Promise { - await this.durationTrigger.click(); - const options = this.page.locator('button[data-duration]'); - await expect(options.first()).toBeVisible({ timeout: 10_000 }); - const values = await options.evaluateAll((els) => - els.map((el) => Number(el.getAttribute('data-duration'))), - ); - await this.page.keyboard.press('Escape'); - return values.filter((v) => Number.isFinite(v)); - } - - /** - * Move to the confirm dialog and send. - * - * If the dialog never appears the form was silently invalid — the message - * says so rather than reporting a bare selector timeout, because that is - * what actually happens when "Require locker" is left on. - */ - async confirmAndSend(): Promise { - await this.confirmViewButton.click(); - await expect( - this.confirmButton, - 'the confirm dialog did not open — the form was silently invalid. Check ' + - '"Require locker" is cleared, a desk is selected, and the time is not ' + - 'in the past', - ).toBeVisible({ timeout: 20_000 }); - await this.confirmButton.click(); - } -} diff --git a/e2e/support/desk/desk-group.page.ts b/e2e/support/desk/desk-group.page.ts index 3b6f92e16b9..ec4f1d79897 100644 --- a/e2e/support/desk/desk-group.page.ts +++ b/e2e/support/desk/desk-group.page.ts @@ -1,14 +1,22 @@ -/** Group-specific page actions; ordinary desk tests keep using DeskForm. */ +/** Group-specific page actions; ordinary desk tests keep using DeskFlow. */ import { type Page, expect } from '@playwright/test'; -import { DeskForm } from './desk-form.page'; +import { DeskFlow } from './desk-flow.page'; import { groupDeskSlot } from './desk-group.env'; import { type GroupDeskSeed } from './desk-group.seed'; import { hhmm } from './desk.env'; export class DeskGroupPage { - readonly form: DeskForm; + readonly form: DeskFlow; constructor(readonly page: Page) { - this.form = new DeskForm(page); + this.form = new DeskFlow(page); + } + + /** + * The error notice of the app. The type of a notice is a class on its + * container (`notifications.ts`), so a success notice does not match. + */ + get errorNotice() { + return this.page.locator('mat-snack-bar-container.error:not([mat-exit])'); } async prepare( @@ -18,48 +26,39 @@ export class DeskGroupPage { slot: ReturnType, ) { await this.form.open(); - await this.form.groupModeButton.click(); + await this.form.bookForGroup(); await this.form.pickDate(slot.date_ms); - // Safeguard: this sets Group mode again. It does not toggle the mode. - await this.form.groupModeButton.click(); - await expect(this.form.root.locator('a-user-list-field')).toBeVisible(); await this.form.setChecked(this.form.requireLocker, false); - await this.form.setChecked(this.form.allDay, false); - await this.form.title.fill(title); - for (const member of members) await this.form.addGroupMember(member); - await this.form.addDeskButton.click(); - // Wait for the initial resource list before narrowing to the test level. - // Resource updates can rebuild the dropdown while it is open. - await expect( - this.page.locator('button[name="select-desk"]').filter({ - hasText: seed.desks[0].name, - }), - ).toBeVisible(); - const level = this.page.locator('mat-select[name="location"]').first(); - await expect(async () => { - if ((await level.innerText()).includes(seed.level_name)) return; - if ((await level.getAttribute('aria-expanded')) !== 'true') - await level.click(); - await this.page - .getByRole('option', { name: seed.level_name, exact: true }) - .click({ timeout: 2_000 }); - await expect(level).toContainText(seed.level_name); - }).toPass({ timeout: 15_000 }); - await this.page - .locator('button[name="select-desk"]') - .filter({ hasText: seed.desks[0].name }) - .click(); - await this.page.locator('button[name="toggle-desk"]').click(); + await this.form.setAllDay(false); await this.form.setStartTime(hhmm(slot.start)); await this.form.setDuration(slot.duration); + await this.form.title.fill(title); + for (const member of members) await this.form.addGroupMember(member); + // Narrow the list to the test level, then select its first desk. The + // flow gives the other members the desks nearest to it on the map. + await this.form.chooseLevel(seed.level_name); + await this.form.selectDesk(seed.desks[0].name); + // Read the form back. Confirm saves at once. + await expect(this.form.groupField).toBeVisible(); await expect(this.form.title).toHaveValue(title); await expect(this.form.groupMembers).toHaveCount(members.length); + await expect(this.form.allDayInput).not.toBeChecked(); } + /** Confirm. The flow saves at once: there is no confirm dialog. */ async submit() { await this.form.confirmAndSend(); } + async expectSuccess() { await expect(this.page).toHaveURL(/#\/book\/desk\/success/); + await expect(this.form.successContinue).toBeVisible(); + } + + /** The flow refused the booking: an error notice shows and the form stays. */ + async expectRefused() { + await expect(this.errorNotice).toBeVisible(); + await expect(this.page).toHaveURL(/#\/book\/desk\/form/); + await expect(this.form.confirm).toBeVisible(); } } diff --git a/e2e/support/desk/desk-group.seed.ts b/e2e/support/desk/desk-group.seed.ts index 792629f772e..5357b0d6729 100644 --- a/e2e/support/desk/desk-group.seed.ts +++ b/e2e/support/desk/desk-group.seed.ts @@ -50,12 +50,17 @@ export async function seedGroupDesks( data: { name: 'desks', description: 'Owned by this group desk E2E test', + // `features` is not optional: `desk-flow-select` reads it + // from every desk of the building, and a desk without the + // list stops that view from rendering a desk selection — + // in the pages of the other workers too. details: desks.map((desk) => ({ ...desk, map_id: desk.id, zone: level_id, bookable: true, groups: [], + features: [], })), }, }); diff --git a/e2e/support/desk/desk-schedule.page.ts b/e2e/support/desk/desk-schedule.page.ts index 29332cecd0e..431f6c60236 100644 --- a/e2e/support/desk/desk-schedule.page.ts +++ b/e2e/support/desk/desk-schedule.page.ts @@ -22,7 +22,7 @@ import { YourBookingsPage } from '../visitor/your-bookings.page'; export class DeskSchedulePage extends YourBookingsPage { protected override get editForm(): Locator { - return this.page.locator('desk-flow-form'); + return this.page.locator('desk-flow-new'); } protected override get editFormHint(): string { @@ -36,15 +36,6 @@ export class DeskSchedulePage extends YourBookingsPage { /** Make sure desk bookings are among the types being shown. */ async showDesks(): Promise { - const toggle = this.page - .locator('button[name="schedule-toggle-desk-filter"]:visible') - .first(); - const checkbox = toggle.locator('input[type="checkbox"]'); - if (!(await checkbox.count())) return; - // ENSURE, never blindly toggle: clicking a filter that is already on - // turns it off, and the list then looks like the booking is missing. - if (!(await checkbox.isChecked().catch(() => false))) { - await toggle.click({ timeout: 10_000 }); - } + await this.ensureShown('desk'); } } diff --git a/e2e/support/desk/desk.api.ts b/e2e/support/desk/desk.api.ts index f5b0b3c1147..33623722ebe 100644 --- a/e2e/support/desk/desk.api.ts +++ b/e2e/support/desk/desk.api.ts @@ -9,8 +9,9 @@ * The retry is the same one the visitor and Your Bookings areas carry, for the * same reason (REG-09 / PPT-2642: a burst of booking writes poisons a staff-api * connection and the request that lands on it fails for reasons unrelated to - * what was sent). Duplicated rather than shared, like the rest of the per-area - * support, so no area can break another. + * what was sent). The retry is duplicated, like the rest of the per-area + * support, so no area can break another. The check for REG-09 is not: it is + * `isReg09` in `../api.ts`, so that its removal is one deletion. */ import { APIRequestContext } from '@playwright/test'; import { @@ -18,19 +19,11 @@ import { ENGINE_API, STAFF_API, currentUser, + isReg09, readBack, zonesWithTag, } from '../api'; -function isConcurrentWriteFailure(status: number, body: string): boolean { - if (status === 400 && /Expected BeginObject but was EOF/.test(body)) return true; - if (status === 500 && /could not serialize access/.test(body)) return true; - // An empty `failures` list is the tell: a genuine validation error names the - // field it is unhappy about. - if (status === 422 && /"failures"\s*:\s*\[\s*\]/.test(body)) return true; - return false; -} - /** * Create a desk booking straight through the API. * @@ -88,7 +81,7 @@ export async function createDeskBookingViaApi( continue; } last = `HTTP ${res.status()} ${body.slice(0, 200)}`; - if (!isConcurrentWriteFailure(res.status(), body)) { + if (!isReg09(res.status(), body)) { throw new Error( `create desk booking failed: ${last}. A 409 means the desk is already ` + `held for that window — check the spec swept its own day first, and ` + diff --git a/e2e/support/desk/desk.settings.ts b/e2e/support/desk/desk.settings.ts index 1d61b0b749e..b362f45d7fb 100644 --- a/e2e/support/desk/desk.settings.ts +++ b/e2e/support/desk/desk.settings.ts @@ -42,7 +42,13 @@ export const LIMITED_HOURS = { 'app.bookings.bookable_hours': { start: 9, end: 17 }, }; -/** Allow all-day desk bookings — the form's All Day checkbox. */ +/** + * Allow all-day desk bookings. + * + * The setting gates the All Day checkbox of `desk-flow-details`, which shows + * on small screens only. The All Day toggle in the filters of + * `desk-flow-select` shows with or without it. + */ export const ALLOW_ALL_DAY = { 'app.bookings.allow_all_day': true, }; @@ -51,10 +57,10 @@ export const ALLOW_ALL_DAY = { * Book a desk on somebody else's behalf. * * ONE setting, unlike visitors. `can_book_for_anyone` is read only by the - * visitor forms (`visitor-invite-form.component.ts`); the desk form reads - * `can_book_for_others` alone (`desk-form-details.component.ts`) and renders its - * user field from that. Setting the visitor key here as well would look thorough - * and do nothing, so it is left out on purpose. + * visitor forms (`visitor-invite-form.component.ts`); the desk flow reads + * `can_book_for_others` alone (`desk-flow-details.component.ts`) and offers its + * "Book for other" booking type from that. Setting the visitor key here as well + * would look thorough and do nothing, so it is left out on purpose. */ export const BOOK_FOR_OTHERS = { 'app.bookings.can_book_for_others': true, @@ -77,7 +83,7 @@ export const BOOK_FOR_OTHERS = { 'app.basic_user_search': true, }; -/** Enable the desk form's group mode and its local user search backend. */ +/** Enable the Group booking type of the desk flow and its local user search backend. */ export const ALLOW_GROUP_BOOKING = { 'app.desks.allow_groups': true, 'app.basic_user_search': true, diff --git a/e2e/support/desk/desk.zones.ts b/e2e/support/desk/desk.zones.ts index d43e259002a..cf38472bb97 100644 --- a/e2e/support/desk/desk.zones.ts +++ b/e2e/support/desk/desk.zones.ts @@ -42,8 +42,14 @@ const LEVEL_NAME_PREFIX = 'E2E Extra Level'; * * Named per worker, so parallel workers do not share a zone: each one's spec * switches the selector to its own level and expects exactly its own desk. + * + * `features: false` writes the desk with no `features` list. Only the spec + * for that fault uses it: see the note at the metadata below. */ -export async function createSecondLevel(workerIndex: number): Promise { +export async function createSecondLevel( + workerIndex: number, + { features = true }: { features?: boolean } = {}, +): Promise { const admin = await apiFor('admin', 0); try { const [building] = await zonesWithTag(admin, 'building'); @@ -80,6 +86,10 @@ export async function createSecondLevel(workerIndex: number): Promise