test(e2e): the permission walk waits for access to settle before pressing or asserting absence (BUG-3267) (#1627)

Every permission-walk leg navigated, waited for content, then pressed Cmd-N or
asserted chrome absent. Content is not the signal: the create decision needs
the caller's membership (GET /me) and the workspace's collection list, and both
can land after content. Before both, Cmd-N is consumed and ignored (BUG-3258's
ruled behaviour) and every absence holds for the owner too.

Measured at 8 workers: every failure caught pressed with membership known and
no sidebar collections listed, so a membership-only wait still failed 3/420.

- Sidebar publishes the store's own flag as data-membership (known|pending).
  Nothing in the app reads it.
- waitForAccessSettled(page) waits for data-membership="known" and a listed
  sidebar collection. All five walk specs call it after their one goto.
- Two owner control legs hold GET /me or GET /collections, and show a press
  made then is dropped (not deferred) and a press after settling opens
  quick-add.

Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz
This commit is contained in:
xarmian
2026-09-27 19:33:41 -04:00
committed by GitHub
parent 2d8aedc9a4
commit b4cca2c408
7 changed files with 130 additions and 4 deletions
+31 -1
View File
@@ -1,4 +1,4 @@
import { request, type APIRequestContext, type BrowserContext } from '@playwright/test';
import { expect, request, type APIRequestContext, type BrowserContext, type Page } from '@playwright/test';
import { suiteFixture, quietCrossActorToasts } from '../fixtures';
/**
@@ -246,3 +246,33 @@ export async function actAs(context: BrowserContext, account: WalkAccount): Prom
await context.setExtraHTTPHeaders({ Authorization: `Bearer ${account.token}` });
await quietCrossActorToasts(context);
}
/**
* Wait until the page can decide what this account may create (BUG-3267).
*
* Two things feed that decision and both arrive after content can be on screen:
*
* - Membership (GET /workspaces/{ws}/me). Until it settles every permission
* helper answers "no access", so create and edit chrome is absent for EVERY
* account, the owner included.
* - The workspace's collection list, which the sidebar and quick-add build
* their create targets from. With membership settled but the list still
* empty, there is nothing to create in either.
*
* Before both, a Cmd-N is consumed and ignored (BUG-3258's ruled behaviour),
* and an absence holds for any account and proves nothing. A collection page's
* items are not this signal: they load separately and can render first.
* Measured under 8 workers: the press landed with membership `known` and no
* sidebar collections in every failure caught.
*
* The sidebar publishes the store's own membership flag, so this waits on the
* state the product decides from, whether it settled from a request or from a
* cached answer. Every walk account sees at least one collection, so a listed
* collection is the list's readiness. Call it after every navigation, before
* asserting on chrome.
*/
export async function waitForAccessSettled(page: Page): Promise<void> {
const nav = page.locator('nav.collection-nav');
await expect(nav).toHaveAttribute('data-membership', 'known');
await expect(nav.locator('.nav-section a.nav-item').first()).toBeVisible();
}
+69 -1
View File
@@ -1,8 +1,9 @@
import { test, expect, type Page, type Response } from '@playwright/test';
import { test, expect, type Page, type Response, type Route } from '@playwright/test';
import {
ACCOUNT_KEYS,
actAs,
seedPermissionWalk,
waitForAccessSettled,
type AccountKey,
type PermissionWalk
} from './lib/permission-walk';
@@ -70,6 +71,8 @@ async function open(page: Page, key: AccountKey, path: string, view?: 'list' | '
}, view);
}
await page.goto(`${walk.workspacePath}${path}`);
// Chrome is absent for every account until access settles (BUG-3267).
await waitForAccessSettled(page);
}
function sidebarEntry(page: Page, coll: Coll) {
@@ -172,6 +175,71 @@ for (const key of ACCOUNT_KEYS) {
});
}
// Why every leg waits for access to settle (BUG-3267). The owner may create in
// every collection, yet a Cmd-N pressed while either input to that decision is
// still in flight is consumed and ignored: with GET /me held, membership reads
// `pending`; with the collection list held, membership is `known` and the
// sidebar lists nothing to create in. The second is the state every failure
// caught under 8 workers was in. Releasing the held request settles access,
// the early press still does nothing (it was dropped, not deferred), and the
// same press then opens the dialog.
const HOLDS = {
membership: (slug: string) => `**/api/v1/workspaces/${slug}/me`,
collections: (slug: string) => `**/api/v1/workspaces/${slug}/collections`
} as const;
for (const [held, pattern] of Object.entries(HOLDS)) {
test(`owner: a Cmd-N pressed while ${held} is loading is ignored, and one after access settles opens quick-add`, async ({
page
}) => {
await actAs(page.context(), walk.accounts.owner);
// Every matching request is held until release, in case the page asks twice.
const heldRoutes: Route[] = [];
let releasing = false;
const holding = new Promise<void>((resolve) => {
void page.route(pattern(walk.workspaceSlug), (route) => {
if (releasing || route.request().method() !== 'GET') return route.continue();
heldRoutes.push(route);
resolve();
});
});
const release = async () => {
releasing = true;
await Promise.all(heldRoutes.map((r) => r.continue()));
};
await page.goto(`${walk.workspacePath}/ideas`);
await holding;
const nav = page.locator('nav.collection-nav');
const listed = nav.locator('.nav-section a.nav-item');
if (held === 'membership') {
// Content and collections are on screen: only membership is missing.
await expect(page.getByText(anchorTitle('ideas'), { exact: true }).first()).toBeVisible();
await expect(sidebarEntry(page, 'ideas')).toBeVisible();
await expect(nav).toHaveAttribute('data-membership', 'pending');
} else {
// Membership has an answer: only the collection list is missing.
await expect(nav).toHaveAttribute('data-membership', 'known');
await expect(listed).toHaveCount(0);
}
const modal = page.locator('.quick-add-modal');
await page.keyboard.press('ControlOrMeta+n');
await page.waitForTimeout(500);
await expect(modal).toHaveCount(0);
// Still unsettled, so the press met an early page, not a late one.
if (held === 'membership') await expect(nav).toHaveAttribute('data-membership', 'pending');
else await expect(listed).toHaveCount(0);
await release();
await waitForAccessSettled(page);
await page.waitForTimeout(500);
await expect(modal, 'the early press was consumed, not held until access settled').toHaveCount(0);
await page.keyboard.press('ControlOrMeta+n');
await expect(modal).toBeVisible();
await expect(modal.locator('.quick-add-label')).toHaveText('New Idea');
});
}
// Empty state: Docs is seeded by the template and never given an item. Only
// the accounts with collection_access=all see it.
for (const key of ['owner', 'editor', 'viewer', 'viewerTasksEdit'] as const) {
+3
View File
@@ -3,6 +3,7 @@ import {
ACCOUNT_KEYS,
actAs,
seedPermissionWalk,
waitForAccessSettled,
type AccountKey,
type PermissionWalk
} from './lib/permission-walk';
@@ -79,6 +80,8 @@ function taskFor(which: Which) {
async function openItem(page: Page, key: AccountKey, which: Which) {
await actAs(page.context(), walk.accounts[key]);
await page.goto(`${walk.workspacePath}/tasks/${taskFor(which).slug}`);
// Chrome is absent for every account until access settles (BUG-3267).
await waitForAccessSettled(page);
// Presence half: the item itself rendered, editable or not.
await expect(
page.locator('.title-row').getByText(taskFor(which).title, { exact: true })
+3
View File
@@ -3,6 +3,7 @@ import {
ACCOUNT_KEYS,
actAs,
seedPermissionWalk,
waitForAccessSettled,
type AccountKey,
type PermissionWalk
} from './lib/permission-walk';
@@ -40,6 +41,8 @@ async function sidebarCollections(page: Page): Promise<string[]> {
async function openWorkspace(page: Page, key: AccountKey) {
await actAs(page.context(), walk.accounts[key]);
await page.goto(walk.workspacePath);
// Chrome is absent for every account until access settles (BUG-3267).
await waitForAccessSettled(page);
// Every account in the walk sees at least Tasks. Waiting on it is also
// the presence half of each leg's absence assertions.
await expect(
+10 -1
View File
@@ -1,5 +1,12 @@
import { test, expect, request, type Page } from '@playwright/test';
import { ACCOUNT_KEYS, actAs, seedPermissionWalk, type AccountKey, type PermissionWalk } from './lib/permission-walk';
import {
ACCOUNT_KEYS,
actAs,
seedPermissionWalk,
waitForAccessSettled,
type AccountKey,
type PermissionWalk
} from './lib/permission-walk';
/**
* Permission walk, the system-collection pages (BUG-3264): Conventions and
@@ -53,6 +60,8 @@ test.beforeAll(async () => {
async function open(page: Page, key: AccountKey, path: string) {
await actAs(page.context(), walk.accounts[key]);
await page.goto(`${walk.workspacePath}${path}`);
// Chrome is absent for every account until access settles (BUG-3267).
await waitForAccessSettled(page);
}
for (const key of ACCOUNT_KEYS) {
@@ -3,6 +3,7 @@ import {
ACCOUNT_KEYS,
actAs,
seedPermissionWalk,
waitForAccessSettled,
type AccountKey,
type PermissionWalk
} from './lib/permission-walk';
@@ -109,6 +110,8 @@ async function serverAccepts(
async function open(page: Page, key: AccountKey, path: string) {
await actAs(page.context(), walk.accounts[key]);
await page.goto(`${walk.workspacePath}${path}`);
// Chrome is absent for every account until access settles (BUG-3267).
await waitForAccessSettled(page);
}
async function openSettingsTab(page: Page, key: AccountKey, tab: string) {
+11 -1
View File
@@ -525,7 +525,17 @@
>
<div class="sidebar-inner">
{#if wsSlug}
<nav class="collection-nav">
<!--
`data-membership` says whether the caller's membership has settled
(BUG-3267). Every create/edit affordance reads it through the
store's helpers, which answer "no access" while it is unknown, so an
absence observed before `known` says nothing about the account.
The e2e permission walk waits on it; nothing in the app reads it.
-->
<nav
class="collection-nav"
data-membership={workspaceStore.membershipKnown ? 'known' : 'pending'}
>
{#if !isGuest}
<a
href="{wsPrefix}"