mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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,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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user