diff --git a/web/e2e/lib/permission-walk.ts b/web/e2e/lib/permission-walk.ts index c9730b387..c364edacf 100644 --- a/web/e2e/lib/permission-walk.ts +++ b/web/e2e/lib/permission-walk.ts @@ -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 { + 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(); +} diff --git a/web/e2e/permission-walk-collections.spec.ts b/web/e2e/permission-walk-collections.spec.ts index 074774619..2cf2d9828 100644 --- a/web/e2e/permission-walk-collections.spec.ts +++ b/web/e2e/permission-walk-collections.spec.ts @@ -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((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) { diff --git a/web/e2e/permission-walk-items.spec.ts b/web/e2e/permission-walk-items.spec.ts index 6d54ebbaa..b06bc6fbc 100644 --- a/web/e2e/permission-walk-items.spec.ts +++ b/web/e2e/permission-walk-items.spec.ts @@ -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 }) diff --git a/web/e2e/permission-walk-sidebar.spec.ts b/web/e2e/permission-walk-sidebar.spec.ts index 2cbec5517..eef3c8160 100644 --- a/web/e2e/permission-walk-sidebar.spec.ts +++ b/web/e2e/permission-walk-sidebar.spec.ts @@ -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 { 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( diff --git a/web/e2e/permission-walk-system.spec.ts b/web/e2e/permission-walk-system.spec.ts index 7dc531840..a45d8b72e 100644 --- a/web/e2e/permission-walk-system.spec.ts +++ b/web/e2e/permission-walk-system.spec.ts @@ -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) { diff --git a/web/e2e/permission-walk-workspace.spec.ts b/web/e2e/permission-walk-workspace.spec.ts index 6c90b89c5..fc67ce26e 100644 --- a/web/e2e/permission-walk-workspace.spec.ts +++ b/web/e2e/permission-walk-workspace.spec.ts @@ -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) { diff --git a/web/src/lib/components/layout/Sidebar.svelte b/web/src/lib/components/layout/Sidebar.svelte index 07e5309f6..08784e883 100644 --- a/web/src/lib/components/layout/Sidebar.svelte +++ b/web/src/lib/components/layout/Sidebar.svelte @@ -525,7 +525,17 @@ >