Skip to content

Commit 9f5bdc1

Browse files
test(organizations): rewrite domainJoin E2E for Node D5 always-create flow (#4182)
* test(organizations): rewrite domainJoin E2E for Node D5 always-create flow Node #3680 dropped pendingJoin limbo — every signup now gets its own active workspace immediately, with a suggestedJoin hint when the domain matches. Rewrite the E2E suite to assert the new flow: member lands in organizationWelcome (not pending-join), suggestedJoin banner is pre-seeded via addInitScript and the Request access CTA creates a join request, access-control assertions updated for the member's own workspace (owner chevron) vs the joined domain org (no chevron). * test(organizations): make domainJoin E2E URL assertions env-agnostic CI runs with NODE_ENV=test which uses config.sign.route='/' (per src/config/defaults/test.config.js). Local dev uses '/tasks'. Subagent's hardcoded `/tasks` assertions failed in CI even though the flow worked correctly. Fix: assert we've LEFT the signup wizard / organization-required pages, not that we're on any specific destination URL. Matches both env routes. * test(organizations): address CR + Copilot review findings on domainJoin E2E - isApiAvailable: use API_URL (configured base path) instead of hardcoded '${API_ORIGIN}/api' — projects overriding config.api.base no longer false-negative as 'backend down'. (Copilot line 22) - Setup test catch: skip ONLY on connectivity errors (ECONNREFUSED/ ENOTFOUND/ETIMEDOUT/EAI_AGAIN/'fetch failed'/'socket hang up'/ 'network error'). Real backend regressions (4xx/5xx, schema drift) now surface as failures instead of silent skips. (CodeRabbit line 63) - errorMessage() helper: safely extract a string from unknown thrown values. Prevents `err.message` itself throwing on null/undefined. (Copilot line 61) - localStorage key: derived from new COOKIE_PREFIX export on lib/helpers/e2e/config.js (sourced from `config.cookie.prefix`). Projects overriding the prefix no longer silently break this test. (Copilot line 174) * test(organizations): address CR ASSERTIVE follow-up + drop local debris CodeRabbit follow-up review on previous fix commit: - isApiAvailable: return reachable=true on ANY HTTP response (was: res.ok() filtering 2xx). 4xx/5xx means the backend IS running and answered — the test should exercise the real code path, not skip. Only network/connect errors indicate the backend is genuinely unreachable. - isPostSignupRoute: extract the 4-times-duplicated inline waitForURL predicate into a named helper with JSDoc; also exclude '/signin' so the predicate guards against partial-auth races landing the user back on the auth surfaces. - seedSuggestedJoinLocalStorage: extract the inline addInitScript arrow into a named function with JSDoc. Also: remove playwright.local.config.js — local debris that leaked in on the env-agnostic-URL commit (dev override for port 5174). Add to .gitignore so it can't sneak back in.
1 parent dc2504a commit 9f5bdc1

3 files changed

Lines changed: 206 additions & 34 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,4 @@ mongod
8080
.claude/*local.*
8181
.github/*local.*
8282
.claude/worktrees/
83+
playwright.local.config.js

src/lib/helpers/e2e/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const rawApiPort = _config?.api?.port;
3333
const parsedApiPort = typeof rawApiPort === 'number' ? rawApiPort : Number.parseInt(rawApiPort, 10);
3434
const apiPort = Number.isNaN(parsedApiPort) ? 3000 : parsedApiPort;
3535
const apiBase = _config?.api?.base ?? 'api';
36+
const cookiePrefix = _config?.cookie?.prefix ?? 'devkit';
3637

3738
/** @type {string} Vue dev-server base URL, e.g. `http://localhost:8080` */
3839
export const BASE_URL = `http://localhost:${port}`;
@@ -43,5 +44,8 @@ export const API_URL = `${apiProtocol}://${apiHost}:${apiPort}/${apiBase}`;
4344
/** @type {string} API origin without path, e.g. `http://localhost:3000` */
4445
export const API_ORIGIN = `${apiProtocol}://${apiHost}:${apiPort}`;
4546

47+
/** @type {string} Cookie/localStorage key prefix (matches client `config.cookie.prefix`), e.g. `devkit` */
48+
export const COOKIE_PREFIX = cookiePrefix;
49+
4650
/** @type {number} Vue dev-server port */
4751
export { port };

src/modules/organizations/tests/organizations.domainJoin.e2e.tests.js

Lines changed: 201 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
import { test, expect } from '@playwright/test';
22
import { signin, signupViaAPI } from '../../../lib/helpers/e2e/auth.js';
33
import { authenticatedContext, createOrgViaAPI, API } from '../../../lib/helpers/e2e/api.js';
4+
import { API_URL, COOKIE_PREFIX } from '../../../lib/helpers/e2e/config.js';
5+
6+
/**
7+
* @desc Patterns that identify a backend-unreachable / transport-level failure.
8+
* Used to scope `test.skip` so real backend regressions (4xx/5xx, schema drift,
9+
* etc.) surface as failures instead of silent skips.
10+
* @type {RegExp}
11+
*/
12+
const CONNECTIVITY_ERROR_RE = /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|fetch failed|socket hang up|network error/i;
13+
14+
/**
15+
* @desc Safely extract a string message from an unknown thrown value.
16+
* Avoids crashing when something throws `null` / `undefined` / a non-Error.
17+
* @param {unknown} err
18+
* @returns {string}
19+
*/
20+
function errorMessage(err) {
21+
if (err instanceof Error) return err.message;
22+
if (err == null) return String(err);
23+
return typeof err === 'string' ? err : String(err);
24+
}
425

526
const timestamp = Date.now();
627
const domain = `domain${timestamp}.com`;
@@ -10,27 +31,92 @@ const password = 'E2eTestPass99xyz';
1031

1132
let orgId;
1233

34+
/**
35+
* @desc Check whether the Node API backend is reachable at the transport level.
36+
* Reachability = a response came back at all (any HTTP status). A 4xx/5xx means
37+
* the backend IS running and answered — so we should NOT skip the suite; we
38+
* should let the test exercise the real code path. Only network/connection
39+
* errors (request.get throws) indicate the backend is genuinely unreachable.
40+
* @param {import('@playwright/test').APIRequestContext} request
41+
* @returns {Promise<boolean>}
42+
*/
43+
async function isApiAvailable(request) {
44+
try {
45+
// Use the fully-configured API URL (honors api.protocol/host/port/base) so
46+
// we don't false-negative on projects that customize `config.api.base`.
47+
await request.get(API_URL);
48+
return true;
49+
} catch {
50+
return false;
51+
}
52+
}
53+
54+
/**
55+
* @desc Predicate for `page.waitForURL` — true once the SPA has left the auth /
56+
* onboarding limbo (signup wizard, signin, or organization-required). Used by
57+
* every post-auth navigation wait in this suite; the post-auth destination
58+
* varies by env (test=`/`, dev=`/tasks`) so we assert by EXCLUSION instead.
59+
* @param {URL} url
60+
* @returns {boolean}
61+
*/
62+
function isPostSignupRoute(url) {
63+
const p = url.pathname;
64+
return !p.includes('/signup') && !p.includes('/signin') && !p.includes('/organization-required');
65+
}
66+
67+
/**
68+
* @desc Seed the `suggestedJoin` localStorage entry on every page-load in a
69+
* Playwright context. Used to replicate what the signup flow stores so the
70+
* banner renders deterministically on subsequent signin tests.
71+
* @param {[string, string, string]} args - [storageKey, orgId, orgName]
72+
* @returns {void}
73+
*/
74+
function seedSuggestedJoinLocalStorage([key, id, name]) {
75+
window.localStorage.setItem(key, JSON.stringify({ orgId: id, orgName: name }));
76+
}
77+
1378
/**
1479
* End-to-end suite for organization domain-based join flow.
80+
*
81+
* Node #3680 (D5 always-create): domain-match signup no longer creates a pending
82+
* join request. Every user always gets their own active workspace. A `suggestedJoin`
83+
* hint ({ orgId, orgName }) is returned alongside the new workspace when the email
84+
* domain matches an existing org. The Vue app surfaces it as a dismissible snackbar
85+
* banner ("There may already be a workspace for X. Request access?").
1586
* @returns {void}
1687
*/
1788
test.describe('Organization Domain Join E2E', () => {
1889
test.describe.configure({ mode: 'serial' });
1990

20-
// ── Phase 1: Signup & pending join ────────────────────────────────
91+
// ── Phase 1: Signup & always-create workspace ─────────────────────
2192

2293
/**
2394
* Creates owner account and organization with domain matching.
2495
* @param {{ playwright: import('@playwright/test').Playwright, request: import('@playwright/test').APIRequestContext }} fixtures - Playwright fixtures
2596
* @returns {Promise<void>}
2697
*/
2798
test('owner signs up via API and creates org', async ({ playwright, request }) => {
28-
const res = await signupViaAPI(request, {
29-
email: ownerEmail,
30-
password,
31-
firstName: 'DJOwner',
32-
lastName: 'Test',
33-
});
99+
const apiUp = await isApiAvailable(request);
100+
test.skip(!apiUp, 'Node API backend not running');
101+
102+
let res;
103+
try {
104+
res = await signupViaAPI(request, {
105+
email: ownerEmail,
106+
password,
107+
firstName: 'DJOwner',
108+
lastName: 'Test',
109+
});
110+
} catch (err) {
111+
// Skip ONLY on infra-level connectivity failures so real backend
112+
// regressions (4xx/5xx, schema drift) surface as failures, not silent skips.
113+
const msg = errorMessage(err);
114+
if (CONNECTIVITY_ERROR_RE.test(msg)) {
115+
test.skip(true, `API connection failed during signup: ${msg}`);
116+
return;
117+
}
118+
throw err;
119+
}
34120
expect(res.user).toBeTruthy();
35121

36122
// Create org via authenticated API (domain matching uses the email domain)
@@ -43,69 +129,142 @@ test.describe('Organization Domain Join E2E', () => {
43129
});
44130

45131
/**
46-
* Signs up a member with matching domain and verifies pending join message.
132+
* Spec D5: member signs up with a matching domain.
133+
* Always-create: member lands in the organizationWelcome step ("Welcome! Your
134+
* organization X has been created.") — NOT a "request to join" pending message.
135+
* Sidenav is still absent during the signup wizard.
47136
* @param {{ page: import('@playwright/test').Page }} fixtures - Playwright fixtures
48137
* @returns {Promise<void>}
49138
*/
50-
test('member signs up via UI — sees pending message, no sidenav', async ({ page }) => {
139+
test('member signs up via UI — lands in own workspace, sees welcome step (no "request to join")', async ({ page }) => {
140+
test.skip(!orgId, 'Setup was skipped — no org created');
51141
await page.goto('/signup');
52142
await page.getByPlaceholder('name@example.com').first().waitFor({ state: 'visible', timeout: 10000 });
53143

54144
await page.getByPlaceholder('name@example.com').first().fill(memberEmail);
55145
await page.getByPlaceholder('Create a password').first().fill(password);
56146
await page.getByRole('button', { name: 'Continue', exact: true }).click();
57147

58-
// Wait for the pending join message
148+
// D5: member gets their own workspace — "Welcome!" message appears, NOT "request to join"
149+
const welcomeText = page.locator('text=Welcome!').first();
150+
await expect(welcomeText).toBeVisible({ timeout: 15000 });
151+
152+
// The old "request to join" pending message must NOT appear
59153
const pendingText = page.locator('text=request to join').first();
60-
await expect(pendingText).toBeVisible({ timeout: 10000 });
154+
await expect(pendingText).not.toBeVisible({ timeout: 3000 });
155+
156+
// NOTE: Unlike the old pendingJoin flow, the sidenav IS present during the
157+
// organizationWelcome step because the user has an active workspace (always-create).
158+
// The old "no sidenav" invariant no longer applies.
61159

62-
// CRITICAL: Sidenav must NOT be visible during signup
63-
const sidenav = page.locator('nav.v-navigation-drawer, .v-navigation-drawer');
64-
await expect(sidenav).toHaveCount(0, { timeout: 3000 });
160+
// Proceed to the app to complete signup (required so member has a session for later tests)
161+
await page.getByRole('button', { name: 'Get Started', exact: true }).click();
162+
// Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
163+
// env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
164+
// by exclusion (left signup/signin/organization-required).
165+
await page.waitForURL(isPostSignupRoute, { timeout: 15000 });
65166
});
66167

67168
/**
68-
* Verifies pending members are redirected to organization-required on sign-in.
169+
* Spec D5: member signs in — lands on the main app (has currentOrganization).
170+
* Router no longer redirects to /organization-required.
69171
* @param {{ page: import('@playwright/test').Page }} fixtures - Playwright fixtures
70172
* @returns {Promise<void>}
71173
*/
72-
test('member signs in — lands on organization-required', async ({ page }) => {
174+
test('member signs in — lands on main app (not organization-required)', async ({ page }) => {
175+
test.skip(!orgId, 'Setup was skipped — no org created');
73176
await signin(page, memberEmail, password);
74-
await page.waitForURL((url) => url.pathname.includes('/organization-required'), { timeout: 15000 });
75-
76-
// Should land on organization-required page (pending member has no active org)
77-
expect(page.url()).toContain('/organization-required');
78177

79-
// Should see the pending request banner
80-
await expect(page.getByRole('alert').getByText('pending approval')).toBeVisible({ timeout: 10000 });
178+
// D5: member has currentOrganization → router sends to /tasks, NOT /organization-required
179+
// Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
180+
// env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
181+
// by exclusion (left signup/signin/organization-required).
182+
await page.waitForURL(isPostSignupRoute, { timeout: 15000 });
183+
// Post-auth route varies by env (test='/' vs dev='/tasks'); assert by exclusion.
184+
expect(page.url()).not.toContain('/signup');
185+
expect(page.url()).not.toContain('/signin');
186+
expect(page.url()).not.toContain('/organization-required');
81187
});
82188

83189
/**
84-
* Verifies logged-in member visiting signup is redirected to organization-required.
190+
* Spec D5: member who is already logged in visiting /signup is redirected to the app.
191+
* With always-create (currentOrganization is set), the router sends to /tasks.
85192
* @param {{ page: import('@playwright/test').Page }} fixtures - Playwright fixtures
86193
* @returns {Promise<void>}
87194
*/
88-
test('member refresh on signup — redirects to organization-required', async ({ page }) => {
195+
test('member refresh on signup — redirects to main app (not organization-required)', async ({ page }) => {
196+
test.skip(!orgId, 'Setup was skipped — no org created');
89197
await signin(page, memberEmail, password);
90198
await page.waitForLoadState('domcontentloaded');
91199

92200
// Navigate to signup (simulating refresh — user is already logged in)
93201
await page.goto('/signup');
94-
// Wait for Vue app to detect logged-in user and redirect to organization-required
95-
await page.waitForURL((url) => url.pathname.includes('/organization-required'), { timeout: 15000 });
202+
// D5: member has currentOrganization → redirected to /tasks, NOT /organization-required
203+
// Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
204+
// env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
205+
// by exclusion (left signup/signin/organization-required).
206+
await page.waitForURL(isPostSignupRoute, { timeout: 15000 });
96207

97-
// Should redirect to organization-required (not show signup form again)
98-
expect(page.url()).toContain('/organization-required');
208+
// Post-auth route varies by env (test='/' vs dev='/tasks'); assert by exclusion.
209+
expect(page.url()).not.toContain('/signup');
210+
expect(page.url()).not.toContain('/signin');
211+
expect(page.url()).not.toContain('/organization-required');
99212
});
100213

101-
// ── Phase 2: Owner approves, then member access control ───────────
214+
// ── Phase 2: suggestedJoin banner CTA → join request + access control ─
215+
216+
/**
217+
* Member clicks 'Request access' on the suggestedJoin banner.
218+
* Pre-populates localStorage with the suggestedJoin hint (simulating what the signup
219+
* flow stored) so the banner renders on the next page load.
220+
* A pending join request is created on the domain-matched owner org.
221+
* @param {{ page: import('@playwright/test').Page, playwright: import('@playwright/test').Playwright }} fixtures - Playwright fixtures
222+
* @returns {Promise<void>}
223+
*/
224+
test('member clicks Request access on suggestedJoin banner — join request created', async ({ page, playwright }) => {
225+
test.skip(!orgId, 'Setup was skipped — no org created');
226+
227+
// Inject suggestedJoin into localStorage before every page load via addInitScript.
228+
// This ensures initFromStorage() reads the key on the /signin page mount,
229+
// before the user credentials are submitted and the SPA navigates to the home.
230+
// Replicates what signup stores: key = `${config.cookie.prefix}SuggestedJoin`.
231+
// COOKIE_PREFIX is sourced from the same config the SPA reads, so the key matches
232+
// even if a project overrides `config.cookie.prefix`.
233+
const storageKey = `${COOKIE_PREFIX}SuggestedJoin`;
234+
await page.context().addInitScript(seedSuggestedJoinLocalStorage, [storageKey, orgId, `DomainOrg${timestamp}`]);
235+
236+
await signin(page, memberEmail, password);
237+
// Post-auth destination varies by env (test=`/`, dev=`/tasks`) — use the
238+
// env-agnostic `isPostSignupRoute` helper (defined at module top) to assert
239+
// by exclusion (left signup/signin/organization-required).
240+
await page.waitForURL(isPostSignupRoute, { timeout: 15000 });
241+
242+
// suggestedJoin snackbar banner must be visible (top-right v-snackbar)
243+
// Banner text: "There may already be a workspace for {orgName}. Request access?"
244+
const requestBtn = page.getByRole('button', { name: 'Request access' }).first();
245+
await expect(requestBtn).toBeVisible({ timeout: 10000 });
246+
await requestBtn.click();
247+
248+
// After sending, the banner shows a success feedback snackbar and dismisses
249+
const feedbackText = page.locator('text=Request sent').first();
250+
await expect(feedbackText).toBeVisible({ timeout: 10000 });
251+
252+
// Verify the join request was created on the backend
253+
const ctx = await authenticatedContext(playwright, ownerEmail, password);
254+
const reqRes = await ctx.get(`${API}/organizations/${orgId}/requests`);
255+
const reqBody = await reqRes.json();
256+
const requests = reqBody.data || [];
257+
expect(requests.length).toBeGreaterThan(0);
258+
await ctx.dispose();
259+
});
102260

103261
/**
104262
* Owner approves the pending join request via API.
105263
* @param {{ playwright: import('@playwright/test').Playwright }} fixtures - Playwright fixtures
106264
* @returns {Promise<void>}
107265
*/
108266
test('owner approves member join request', async ({ playwright }) => {
267+
test.skip(!orgId, 'Setup was skipped — no org created');
109268
// Approve via authenticated API context — the pending requests section in the
110269
// detail component relies on loadMembership middleware timing which is not
111270
// deterministic in CI. The banner proves the API works; use it directly.
@@ -129,23 +288,29 @@ test.describe('Organization Domain Join E2E', () => {
129288
});
130289

131290
/**
132-
* Verifies approved member cannot see the manage chevron on account page.
291+
* Verifies approved member cannot navigate into the domain-org (no manage chevron).
292+
* With always-create (Node #3680), the member owns their own auto-created workspace
293+
* (chevron IS present for it), but has role 'member' on DomainOrg (no chevron there).
133294
* @param {{ page: import('@playwright/test').Page }} fixtures - Playwright fixtures
134295
* @returns {Promise<void>}
135296
*/
136297
test('approved member — no Manage button on account page', async ({ page }) => {
298+
test.skip(!orgId, 'Setup was skipped — no org created');
137299
await signin(page, memberEmail, password);
138300
await page.goto('/users');
139301
await page.waitForLoadState('domcontentloaded');
140302

141303
// Click the Organizations tab
142304
const orgTab = page.getByRole('tab', { name: /organizations/i });
143305
await orgTab.click({ timeout: 10000 });
144-
await expect(page.getByRole('main').getByText(`DomainOrg${timestamp}`, { exact: true })).toBeVisible({ timeout: 10000 });
145306

146-
// Members should NOT see the chevron (manage) icon on their org item
147-
const chevron = page.locator('.v-list-item .fa-chevron-right');
148-
await expect(chevron).toHaveCount(0, { timeout: 5000 });
307+
// Wait for the domain org list item to appear
308+
const domainOrgItem = page.locator('.v-list-item', { hasText: `DomainOrg${timestamp}` });
309+
await expect(domainOrgItem).toBeVisible({ timeout: 10000 });
310+
311+
// The domain org list item should NOT have a chevron (member role, not owner/admin)
312+
const chevronInDomainOrg = domainOrgItem.locator('.fa-chevron-right');
313+
await expect(chevronInDomainOrg).toHaveCount(0, { timeout: 5000 });
149314
});
150315

151316
/**
@@ -154,6 +319,7 @@ test.describe('Organization Domain Join E2E', () => {
154319
* @returns {Promise<void>}
155320
*/
156321
test('approved member — no management controls on org page', async ({ page }) => {
322+
test.skip(!orgId, 'Setup was skipped — no org created');
157323
await signin(page, memberEmail, password);
158324
await page.goto(`/users/organizations/${orgId}`);
159325
await expect(page.getByRole('heading', { name: `DomainOrg${timestamp}` })).toBeVisible({ timeout: 10000 });
@@ -181,6 +347,7 @@ test.describe('Organization Domain Join E2E', () => {
181347
* @returns {Promise<void>}
182348
*/
183349
test('owner sees full management controls', async ({ page }) => {
350+
test.skip(!orgId, 'Setup was skipped — no org created');
184351
await signin(page, ownerEmail, password);
185352
await page.goto(`/users/organizations/${orgId}`);
186353
await page.waitForLoadState('domcontentloaded');

0 commit comments

Comments
 (0)