Skip to content

Commit c6a1dbc

Browse files
author
Aaron Hardy
committed
test: overhaul browser fixture architecture and test suite structure
- replace monolithic communication specs with a shared contract runner used by iframe, worker, port, and backward-compatibility tests - split connection-management specs into focused suites (origins, reconnect, lifecycle, channels, targets) - extract shared fixture method factories for page/worker/service/shared workers to remove duplication and reduce fixture coupling - add async test utilities for message-wait/timeouts and improve reconnect probes - add Vitest node unit suite with dedicated config and scripts - expand type tests with additional method/options coverage - scope service-worker cleanup to relevant tests instead of global teardown - fix injected-content worker fixtures for shared script imports - remove leftover screenshot artifacts and ignore test screenshot output - update lint/config/docs to match new test topology and scripts
1 parent b4f5523 commit c6a1dbc

39 files changed

+2078
-1561
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ node_modules
22
dist
33
lib
44
cjs
5-
.eslintcache
5+
.eslintcache
6+
test/__screenshots__/

CONTRIBUTING.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ Thanks for contributing to Penpal.
1414
Penpal uses Vitest Browser Mode with Playwright for browser-based tests.
1515

1616
- Browser suites live in `test/**/*.spec.ts`.
17+
- Unit suites live in `test/unit/**/*.spec.ts`.
1718
- File-protocol tests live in `test/fileProtocol` and are run via `scripts/testFileProtocol.js`.
1819
- Type tests live in `test/types` and run via `tsc --noEmit`.
1920

20-
By default, `npm test` runs Chromium coverage (`test:chromium`), which includes both:
21+
By default, `npm test` runs unit tests and Chromium coverage, which includes:
2122

23+
- the unit suite
2224
- the main browser suite
2325
- the file-protocol test
2426

@@ -45,7 +47,7 @@ All scripts below are defined in `package.json`.
4547
- `npm run format`
4648
- Runs Prettier on JSON, TS, JS, CJS, Markdown, and HTML files.
4749
- `npm test`
48-
- Alias for `npm run test:chromium`.
50+
- Runs unit tests, then Chromium browser + file-protocol tests.
4951
- `npm run test:watch`
5052
- Alias for `npm run test:watch:chromium`.
5153
- `npm run prepublishOnly`
@@ -82,6 +84,8 @@ All scripts below are defined in `package.json`.
8284
- Runs WebKit browser suite and WebKit file-protocol tests.
8385
- `npm run test:all-browsers`
8486
- Runs Chromium, Firefox, Edge, and WebKit full test commands.
87+
- `npm run test:unit`
88+
- Runs fast Node-based unit tests in `test/unit`.
8589
- `npm run test:types`
8690
- Runs TypeScript type tests with `tsconfig.typesTest.json`.
8791
- `npm run test:legacy:vendor -- <6.x.x-version>`

eslint.config.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ export default tseslint.config(
4444
},
4545
},
4646
{
47-
files: ['scripts/**/*', 'vitest.browser.config.ts'],
47+
files: [
48+
'scripts/**/*',
49+
'vitest.browser.config.ts',
50+
'vitest.unit.config.ts',
51+
],
4852
languageOptions: {
4953
ecmaVersion: ECMA_VERSION,
5054
globals: globals.node,
@@ -60,19 +64,31 @@ export default tseslint.config(
6064
...globals.worker,
6165
...globals.serviceworker,
6266
Penpal: 'readonly',
67+
PenpalGeneralFixtureMethods: 'readonly',
6368
},
6469
},
6570
rules: {
6671
'@typescript-eslint/no-empty-function': 'off',
6772
},
6873
},
74+
{
75+
files: ['test/childFixtures/shared/**/*.js'],
76+
languageOptions: {
77+
globals: {
78+
...globals.browser,
79+
...globals.worker,
80+
...globals.serviceworker,
81+
},
82+
},
83+
},
6984
{
7085
files: ['test/childFixtures/pages/**/*.js'],
7186
languageOptions: {
7287
globals: {
7388
...globals.browser,
7489
Penpal: 'readonly',
7590
PenpalFixture: 'readonly',
91+
PenpalGeneralFixtureMethods: 'readonly',
7692
PenpalLegacyFixture: 'readonly',
7793
},
7894
},

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"build:analysis": "node scripts/filesize.js dist/penpal.min.js",
4949
"lint": "eslint --cache --fix",
5050
"format": "prettier --write \"**/*.{json,ts,js,cjs,md,html}\"",
51-
"test": "npm run test:chromium",
51+
"test": "npm-run-all test:unit test:chromium",
5252
"test:watch": "npm run test:watch:chromium",
5353
"prepublishOnly": "npm-run-all format lint test test:types build",
5454
"prepare": "husky install",
@@ -67,6 +67,7 @@
6767
"test:edge": "npm-run-all test:edge:browser test:file:edge",
6868
"test:webkit": "npm-run-all test:webkit:browser test:file:webkit",
6969
"test:all-browsers": "npm-run-all test:chromium test:firefox test:edge test:webkit",
70+
"test:unit": "vitest run --config vitest.unit.config.ts",
7071
"test:types": "tsc --noEmit -p tsconfig.typesTest.json",
7172
"test:legacy:vendor": "node scripts/updateLegacyPenpal.js"
7273
},

test/asyncUtils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
type WaitForMessageOptions<TData = unknown> = {
2+
source: Window | MessagePort;
3+
predicate: (event: MessageEvent<TData>) => boolean;
4+
timeoutMs?: number;
5+
timeoutMessage?: string;
6+
};
7+
8+
export const withTimeout = async <T>(
9+
promise: Promise<T>,
10+
timeoutMs: number,
11+
timeoutMessage: string
12+
) => {
13+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
14+
15+
const timeoutPromise = new Promise<T>((_, reject) => {
16+
timeoutId = setTimeout(() => {
17+
reject(new Error(timeoutMessage));
18+
}, timeoutMs);
19+
});
20+
21+
try {
22+
return await Promise.race([promise, timeoutPromise]);
23+
} finally {
24+
clearTimeout(timeoutId);
25+
}
26+
};
27+
28+
export const waitForMessageFromSource = async <TData = unknown>({
29+
source,
30+
predicate,
31+
timeoutMs = 5000,
32+
timeoutMessage = 'Timed out waiting for message',
33+
}: WaitForMessageOptions<TData>) => {
34+
let handleMessage: ((event: MessageEvent<TData>) => void) | undefined;
35+
36+
try {
37+
return await withTimeout(
38+
new Promise<MessageEvent<TData>>((resolve) => {
39+
handleMessage = (event: MessageEvent<TData>) => {
40+
if (event.source !== source) {
41+
return;
42+
}
43+
44+
if (!predicate(event)) {
45+
return;
46+
}
47+
48+
window.removeEventListener('message', handleMessage as EventListener);
49+
resolve(event);
50+
};
51+
52+
window.addEventListener('message', handleMessage as EventListener);
53+
}),
54+
timeoutMs,
55+
timeoutMessage
56+
);
57+
} finally {
58+
if (handleMessage) {
59+
window.removeEventListener('message', handleMessage as EventListener);
60+
}
61+
}
62+
};
Lines changed: 8 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -1,179 +1,17 @@
1-
import { CallOptions, PenpalError } from '../../src/index.js';
21
import type { Methods } from '../../src/index.js';
32
import FixtureMethods from '../childFixtures/types/FixtureMethods.js';
3+
import { runCommunicationContract } from '../contracts/communicationContract.js';
44
import { createBackwardCompatibilityIframeAndConnection } from './utils.js';
55

6-
const createConnection = (methods?: Methods) => {
6+
const createConnection = (options?: { methods?: Methods }) => {
77
return createBackwardCompatibilityIframeAndConnection<FixtureMethods>({
8-
methods,
8+
methods: options?.methods,
99
}).connection;
1010
};
1111

12-
describe(`BACKWARD COMPATIBILITY: communication between parent and child iframe`, () => {
13-
it('calls a function on the child', async () => {
14-
const connection = createConnection();
15-
const child = await connection.promise;
16-
const value = await child.multiply(2, 5);
17-
expect(value).toEqual(10);
18-
connection.destroy();
19-
});
20-
21-
it('calls nested functions on the child', async () => {
22-
const connection = createConnection();
23-
const child = await connection.promise;
24-
const oneLevel = await child.nested.oneLevel('pen');
25-
expect(oneLevel).toEqual('pen');
26-
const twoLevels = await child.nested.by.twoLevels('pal');
27-
expect(twoLevels).toEqual('pal');
28-
connection.destroy();
29-
});
30-
31-
it('calls an asynchronous function on the child', async () => {
32-
const connection = createConnection();
33-
const child = await connection.promise;
34-
const value = await child.multiplyAsync(2, 5);
35-
expect(value).toEqual(10);
36-
connection.destroy();
37-
});
38-
39-
it('calls a function on the parent', async () => {
40-
const connection = createConnection({
41-
add: (num1: number, num2: number) => {
42-
return num1 + num2;
43-
},
44-
});
45-
const child = await connection.promise;
46-
await child.addUsingParent();
47-
const value = await child.getParentReturnValue();
48-
expect(value).toEqual(9);
49-
connection.destroy();
50-
});
51-
52-
it('handles promises rejected with strings', async () => {
53-
const connection = createConnection();
54-
const child = await connection.promise;
55-
await expect(child.getPromiseRejectedWithString()).rejects.toBe(
56-
'test error string'
57-
);
58-
connection.destroy();
59-
});
60-
61-
it('handles promises rejected with objects', async () => {
62-
const connection = createConnection();
63-
const child = await connection.promise;
64-
let error;
65-
try {
66-
await child.getPromiseRejectedWithObject();
67-
} catch (e) {
68-
error = e;
69-
}
70-
expect(error).toEqual({ a: 'b' });
71-
connection.destroy();
72-
});
73-
74-
it('handles promises rejected with undefined', async () => {
75-
const connection = createConnection();
76-
const child = await connection.promise;
77-
let catchCalled = false;
78-
let error;
79-
try {
80-
await child.getPromiseRejectedWithUndefined();
81-
} catch (e) {
82-
catchCalled = true;
83-
error = e;
84-
}
85-
expect(catchCalled).toBe(true);
86-
expect(error).toBeUndefined();
87-
connection.destroy();
88-
});
89-
90-
it('handles promises rejected with error objects', async () => {
91-
const connection = createConnection();
92-
const child = await connection.promise;
93-
let error;
94-
try {
95-
await child.getPromiseRejectedWithError();
96-
} catch (e) {
97-
error = e;
98-
}
99-
expect(error).toEqual(expect.any(Error));
100-
expect((error as Error).name).toBe('TypeError');
101-
expect((error as Error).message).toBe('test error object');
102-
expect((error as Error).stack).toEqual(expect.any(String));
103-
connection.destroy();
104-
});
105-
106-
it('handles thrown errors', async () => {
107-
const connection = createConnection();
108-
const child = await connection.promise;
109-
let error;
110-
try {
111-
await child.throwError();
112-
} catch (e) {
113-
error = e;
114-
}
115-
expect(error).toEqual(expect.any(Error));
116-
expect((error as Error).message).toBe('Oh nos!');
117-
connection.destroy();
118-
});
119-
120-
it('handles unclonable values', async () => {
121-
const connection = createConnection();
122-
const child = await connection.promise;
123-
let error;
124-
try {
125-
await child.getUnclonableValue();
126-
} catch (e) {
127-
error = e;
128-
}
129-
expect(error).toEqual(expect.any(Error));
130-
expect((error as Error).name).toBe('DataCloneError');
131-
connection.destroy();
132-
});
133-
134-
it('rejects method call promise if method call timeout reached', async () => {
135-
const connection = createConnection();
136-
const child = await connection.promise;
137-
const promise = child.neverResolve(
138-
new CallOptions({
139-
timeout: 0,
140-
})
141-
);
142-
143-
let error;
144-
try {
145-
await promise;
146-
} catch (e) {
147-
error = e;
148-
}
149-
150-
expect(error).toEqual(expect.any(Error));
151-
expect((error as Error).message).toBe(
152-
'Method call neverResolve() timed out after 0ms'
153-
);
154-
expect((error as PenpalError).code).toBe('METHOD_CALL_TIMEOUT');
155-
connection.destroy();
156-
});
157-
158-
it('rejects method call promise if connection is destroyed before reply is received', async () => {
159-
const connection = createConnection();
160-
const child = await connection.promise;
161-
162-
let error: Error;
163-
164-
child.neverResolve().catch((e) => {
165-
error = e;
166-
});
167-
connection.destroy();
168-
169-
// Wait for microtask queue to drain
170-
await Promise.resolve();
171-
172-
expect(error!).toEqual(expect.any(Error));
173-
expect(error!.message).toBe(
174-
'Method call neverResolve() failed due to destroyed connection'
175-
);
176-
expect((error! as PenpalError).code).toBe('CONNECTION_DESTROYED');
177-
connection.destroy();
178-
});
12+
runCommunicationContract({
13+
suiteName:
14+
'BACKWARD COMPATIBILITY: communication between parent and child iframe',
15+
createConnection,
16+
includeAdvancedCases: false,
17917
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { CHILD_SERVER } from '../constants.js';
2+
import type { PenpalError } from '../../src/index.js';
3+
import FixtureMethods from '../childFixtures/types/FixtureMethods.js';
4+
import { createBackwardCompatibilityIframeAndConnection } from './utils.js';
5+
6+
describe('BACKWARD COMPATIBILITY: connection management lifecycle', () => {
7+
it('rejects promise if connection timeout passes', async () => {
8+
const { connection } = createBackwardCompatibilityIframeAndConnection<
9+
FixtureMethods
10+
>({
11+
url: `${CHILD_SERVER}/never-respond`,
12+
timeout: 0,
13+
});
14+
15+
let error;
16+
try {
17+
await connection.promise;
18+
} catch (e) {
19+
error = e;
20+
}
21+
expect(error).toEqual(expect.any(Error));
22+
expect((error as Error).message).toBe('Connection timed out after 0ms');
23+
expect((error as PenpalError).code).toBe('CONNECTION_TIMEOUT');
24+
connection.destroy();
25+
});
26+
27+
it("doesn't destroy connection if connection succeeds then timeout passes", async () => {
28+
vi.useFakeTimers();
29+
const {
30+
iframe,
31+
connection,
32+
} = createBackwardCompatibilityIframeAndConnection<FixtureMethods>({
33+
timeout: 100000,
34+
});
35+
36+
await connection.promise;
37+
vi.advanceTimersByTime(10000);
38+
39+
expect(iframe.parentNode).not.toBeNull();
40+
41+
connection.destroy();
42+
});
43+
});

0 commit comments

Comments
 (0)