Skip to content

Commit f428704

Browse files
committed
E2E: drop proper-lockfile, put the login back in the fixtures
The guard that serializes changes to the lock directory is now built the same way as the lock itself, out of mkdir and an owner directory whose mtime a heartbeat keeps current. One less dependency for a few lines that already existed next to it. Taking over a stale lock now tolerates losing the mkdir race rather than throwing. The account fixtures authenticate again on first use, through getAccount. Handing back an unauthenticated TestAccount pushed the call into every spec that took a fixture and left the ones that didn't make it silently logged out.
1 parent 4d760d9 commit f428704

7 files changed

Lines changed: 133 additions & 43 deletions

File tree

packages/calypso-e2e/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
"jest-environment-node": "^29.7.0",
3636
"mailosaur": "^8.4.0",
3737
"playwright": "1.57.0",
38-
"proper-lockfile": "^4.1.2",
3938
"totp-generator": "^0.0.14",
4039
"tslib": "^2.8.1"
4140
},
@@ -46,7 +45,6 @@
4645
"@automattic/zendesk-client": "workspace:^",
4746
"@jest/globals": "^29.7.0",
4847
"@types/node": "^24.12.2",
49-
"@types/proper-lockfile": "^4.1.4",
5048
"nock": "^14.0.15",
5149
"typescript": "^6.0.3"
5250
},

packages/calypso-e2e/src/lib/test-account.ts

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import fs from 'fs/promises';
33
import path from 'path';
44
import chalk from 'chalk';
55
import { BrowserContext, Page } from 'playwright';
6-
import { lock } from 'proper-lockfile';
76
import { TestAccountName } from '..';
87
import { getAccountSiteURL, getCalypsoURL } from '../data-helper';
98
import { EmailClient } from '../email-client';
@@ -19,6 +18,7 @@ const LOCK_POLL_MS = 500;
1918
const LOCK_HEARTBEAT_MS = 10 * 1000;
2019
const LOCK_STALE_MS = 45 * 1000;
2120
const LOCK_WAIT_MS = 90 * 1000;
21+
const LOCK_GUARD_HEARTBEAT_MS = 2 * 1000;
2222
const LOCK_GUARD_STALE_MS = 10 * 1000;
2323
const LOCK_GUARD_WAIT_MS = 15 * 1000;
2424
const LOGIN_ATTEMPTS = 2;
@@ -219,7 +219,14 @@ export class TestAccount {
219219
throw error;
220220
}
221221
await fs.rm( stalePath, { force: true, recursive: true } );
222-
await fs.mkdir( lockPath );
222+
try {
223+
await fs.mkdir( lockPath );
224+
} catch ( error ) {
225+
if ( hasErrorCode( error, 'EEXIST' ) ) {
226+
return null;
227+
}
228+
throw error;
229+
}
223230
this.log( 'Took over a stale login lock' );
224231
return await this.createOwnedLoginLock();
225232
} finally {
@@ -347,19 +354,98 @@ export class TestAccount {
347354

348355
/** Tries to acquire the guard that serializes lock directory changes. */
349356
private async acquireLoginLockGuard(): Promise< ( () => Promise< void > ) | null > {
357+
const guardPath = `${ this.getLoginLockPath() }.guard`;
358+
const ownerPath = path.join( guardPath, `${ randomUUID() }.owner` );
359+
350360
try {
351-
return await lock( this.getLoginLockPath(), {
352-
lockfilePath: `${ this.getLoginLockPath() }.guard`,
353-
realpath: false,
354-
stale: LOCK_GUARD_STALE_MS,
355-
update: 2 * 1000,
356-
} );
361+
await fs.mkdir( guardPath );
357362
} catch ( error ) {
358-
if ( hasErrorCode( error, 'ELOCKED' ) ) {
363+
if ( ! hasErrorCode( error, 'EEXIST' ) ) {
364+
throw error;
365+
}
366+
367+
let newestMtime: number;
368+
let owners: string[];
369+
try {
370+
newestMtime = ( await fs.stat( guardPath ) ).mtimeMs;
371+
owners = await fs.readdir( guardPath );
372+
} catch ( inspectError ) {
373+
if ( hasErrorCode( inspectError, 'ENOENT' ) ) {
374+
return null;
375+
}
376+
throw inspectError;
377+
}
378+
379+
for ( const owner of owners ) {
380+
try {
381+
newestMtime = Math.max(
382+
newestMtime,
383+
( await fs.stat( path.join( guardPath, owner ) ) ).mtimeMs
384+
);
385+
} catch ( inspectError ) {
386+
if ( hasErrorCode( inspectError, 'ENOENT' ) ) {
387+
return null;
388+
}
389+
throw inspectError;
390+
}
391+
}
392+
393+
if ( Date.now() < newestMtime + LOCK_GUARD_STALE_MS ) {
359394
return null;
360395
}
396+
397+
const stalePath = `${ guardPath }.${ randomUUID() }.stale`;
398+
try {
399+
await fs.rename( guardPath, stalePath );
400+
} catch ( renameError ) {
401+
if ( hasErrorCode( renameError, 'ENOENT' ) ) {
402+
return null;
403+
}
404+
throw renameError;
405+
}
406+
await fs.rm( stalePath, { force: true, recursive: true } );
407+
return null;
408+
}
409+
410+
try {
411+
await fs.mkdir( ownerPath );
412+
} catch ( error ) {
413+
await fs.rmdir( guardPath ).catch( () => {} );
361414
throw error;
362415
}
416+
417+
let heartbeatError: unknown;
418+
let heartbeatPromise = Promise.resolve();
419+
const heartbeatTimer = setInterval( () => {
420+
heartbeatPromise = heartbeatPromise.then( async () => {
421+
try {
422+
const now = new Date();
423+
await fs.utimes( ownerPath, now, now );
424+
} catch ( error ) {
425+
heartbeatError ??= error;
426+
}
427+
} );
428+
}, LOCK_GUARD_HEARTBEAT_MS );
429+
heartbeatTimer.unref();
430+
431+
return async () => {
432+
clearInterval( heartbeatTimer );
433+
await heartbeatPromise;
434+
if ( heartbeatError ) {
435+
throw new Error( `Lost login lock guard for ${ this.accountName }`, {
436+
cause: heartbeatError,
437+
} );
438+
}
439+
try {
440+
await fs.rmdir( ownerPath );
441+
} catch ( error ) {
442+
if ( hasErrorCode( error, 'ENOENT' ) ) {
443+
throw new Error( `Lost login lock guard for ${ this.accountName }`, { cause: error } );
444+
}
445+
throw error;
446+
}
447+
await fs.rmdir( guardPath );
448+
};
363449
}
364450

365451
/** Returns this account's login lock directory. */

test/e2e/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ test( 'Test', async ( { pageLogin, componentSidebar } ) => {
141141

142142
### Available Fixtures
143143

144-
**Accounts**: one fixture per key of `fixtureAccounts` in [`lib/pw-base.ts`](lib/pw-base.ts), plus `accountGivenByEnvironment` and `accountSMS`. Adding a key there adds the fixture; calling `authenticate()` logs the account in when needed.
144+
**Accounts**: one fixture per key of `fixtureAccounts` in [`lib/pw-base.ts`](lib/pw-base.ts), plus `accountGivenByEnvironment` and `accountSMS`. Adding a key there adds the fixture; the account is logged in the first time a spec asks for it.
145145

146146
**Pages/Components**: Follow naming conventions:
147147

test/e2e/docs/tests_local.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ See the [list of groups](tests_ci.md#featuretest-groups).
7070

7171
### Save authentication cookies
7272

73-
The first call to `TestAccount.authenticate()` for an account logs in and saves its cookies under `COOKIES_PATH`, to be re-used until expiry (typically 2 days). Every later authentication reads that file instead of logging in again.
73+
The first test that needs an account logs in and saves its cookies under `COOKIES_PATH`, to be re-used until expiry (typically 2 days). Every other test and worker reads that file instead of logging in again.
7474

75-
Workers authenticating the same account at the same time race for a lock beside the cookies: the winner logs in, and the rest wait for the cookies it writes. So each account is logged in once per run without being listed anywhere in advance.
75+
Workers that need the same account at the same time race for a lock beside the cookies: the winner logs in, the rest wait for the cookies it writes. So each account is logged in once per run, whichever spec gets there first, and nothing has to be listed anywhere in advance.
7676

7777
Delete the `cookies` directory to force a fresh login.
7878

test/e2e/lib/get-account.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { TestAccount, TestAccountName } from '@automattic/calypso-e2e';
2+
import { Page } from 'playwright';
3+
4+
/**
5+
* Retrieves and authenticates a `TestAccount` instance for the specified account name.
6+
*
7+
* If the account does not have fresh authentication cookies, this function logs in via the login page.
8+
* Otherwise, it loads the saved cookies into the browser context.
9+
*
10+
* @param {Page} page - The Playwright `Page` instance to use for authentication actions.
11+
* @param {TestAccountName} accountName - The name of the test account to retrieve.
12+
* @returns {Promise< TestAccount >} A promise that resolves to a `TestAccount` instance with valid authentication cookies.
13+
*/
14+
export async function getAccount(
15+
page: Page,
16+
accountName: TestAccountName
17+
): Promise< TestAccount > {
18+
const testAccount = new TestAccount( accountName );
19+
await testAccount.authenticate( page );
20+
return testAccount;
21+
}

test/e2e/lib/pw-base.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,15 @@ import {
9090
UseADomainIOwnPage,
9191
SelectItemsComponent,
9292
} from '@automattic/calypso-e2e';
93-
import { test as base, expect } from '@playwright/test';
93+
import { test as base, expect, type Page } from '@playwright/test';
9494
import {
9595
apiCloseAccount,
9696
apiWaitForBearerTokenAcceptance,
9797
apiWaitForEmailVerification,
9898
} from '../specs/shared';
9999
import { useBlackboxTestKeyForCollect } from './blackbox-test-key';
100100
import { snoozeAccountRecoveryInterstitial } from './dashboard-helpers';
101+
import { getAccount } from './get-account';
101102

102103
export type CustomOptions = {
103104
/**
@@ -108,7 +109,7 @@ export type CustomOptions = {
108109
};
109110

110111
/**
111-
* Test accounts exposed as fixtures of the same name.
112+
* Test accounts exposed as a fixture of the same name, logged in on first use.
112113
*
113114
* Two accounts are fixtures without belonging here: `accountGivenByEnvironment`, which
114115
* resolves at run time, and `accountSMS`, whose 2FA code costs a Mailosaur email only a
@@ -125,7 +126,7 @@ export const fixtureAccounts = {
125126
} as const satisfies Record< string, TestAccountName >;
126127

127128
type AccountFixture = (
128-
args: object,
129+
args: { page: Page },
129130
use: ( account: TestAccount ) => Promise< void >
130131
) => Promise< void >;
131132

@@ -390,17 +391,20 @@ export const test = base.extend<
390391
...( Object.fromEntries(
391392
Object.entries( fixtureAccounts ).map( ( [ fixtureName, accountName ] ) => [
392393
fixtureName,
393-
async ( {}, use ) => {
394-
await use( new TestAccount( accountName ) );
394+
async ( { page }, use ) => {
395+
const testAccount = await getAccount( page, accountName );
396+
await use( testAccount );
395397
},
396398
] )
397399
) as Record< keyof typeof fixtureAccounts, AccountFixture > ),
398-
accountGivenByEnvironment: async ( {}, use ) => {
400+
accountGivenByEnvironment: async ( { page }, use ) => {
399401
const accountName = getTestAccountByFeature( envToFeatureKey( envVariables ) );
400-
await use( new TestAccount( accountName ) );
402+
const testAccount = await getAccount( page, accountName );
403+
await use( testAccount );
401404
},
402-
accountSMS: async ( {}, use ) => {
403-
await use( new TestAccount( 'smsUser' ) );
405+
accountSMS: async ( { page }, use ) => {
406+
const testAccount = await getAccount( page, 'smsUser' );
407+
await use( testAccount );
404408
},
405409
clientEmail: async ( {}, use ) => {
406410
const emailClient = new EmailClient();
@@ -677,8 +681,7 @@ export const test = base.extend<
677681
}
678682
},
679683
sitePublicShared: async ( { page, helperData }, use ) => {
680-
const account = new TestAccount( 'defaultUser' );
681-
await account.authenticate( page );
684+
const account = await getAccount( page, 'defaultUser' );
682685

683686
// createSite is the first line that creates a real resource. From here on
684687
// everything is wrapped so the site is deleted no matter what happens next:

yarn.lock

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,6 @@ __metadata:
614614
"@playwright/browser-chromium": "npm:1.57.0"
615615
"@playwright/browser-firefox": "npm:1.57.0"
616616
"@types/node": "npm:^24.12.2"
617-
"@types/proper-lockfile": "npm:^4.1.4"
618617
"@types/totp-generator": "npm:^0.0.8"
619618
"@wordpress/i18n": "npm:^6.21.0"
620619
asana-phrase: "npm:^0.0.8"
@@ -625,7 +624,6 @@ __metadata:
625624
mailosaur: "npm:^8.4.0"
626625
nock: "npm:^14.0.15"
627626
playwright: "npm:1.57.0"
628-
proper-lockfile: "npm:^4.1.2"
629627
totp-generator: "npm:^0.0.14"
630628
tslib: "npm:^2.8.1"
631629
typescript: "npm:^6.0.3"
@@ -9698,15 +9696,6 @@ __metadata:
96989696
languageName: node
96999697
linkType: hard
97009698

9701-
"@types/proper-lockfile@npm:^4.1.4":
9702-
version: 4.1.4
9703-
resolution: "@types/proper-lockfile@npm:4.1.4"
9704-
dependencies:
9705-
"@types/retry": "npm:*"
9706-
checksum: d597846d6f7860da2470623d3f11b6e5b39864719c3c18b5a5e55f783c5e432fe49dcdcf6341b3903428fdf103f2bdc68eba263ce1ce3cbe8070cbcb54bbf230
9707-
languageName: node
9708-
linkType: hard
9709-
97109699
"@types/qs@npm:*, @types/qs@npm:^6.9.7":
97119700
version: 6.9.7
97129701
resolution: "@types/qs@npm:6.9.7"
@@ -9800,13 +9789,6 @@ __metadata:
98009789
languageName: node
98019790
linkType: hard
98029791

9803-
"@types/retry@npm:*":
9804-
version: 0.12.5
9805-
resolution: "@types/retry@npm:0.12.5"
9806-
checksum: eaaca483cc62f2f02c0b8486847ee70986ca7f97afd7363037247dbe3e98df8bd56a5b50d58b1e96768a5a1be0307010d86e9991bd458d72e8df88be471bd720
9807-
languageName: node
9808-
linkType: hard
9809-
98109792
"@types/seed-random@npm:^2.2.4":
98119793
version: 2.2.4
98129794
resolution: "@types/seed-random@npm:2.2.4"

0 commit comments

Comments
 (0)