@rvoh/psychic-spec-helpers 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
2
2
  import { createServer } from 'net';
3
3
  import sleep from '../../shared/sleep.js';
4
4
  const devServerProcesses = {};
5
- export default async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 5000, } = {}) {
5
+ export default async function launchDevServer(key, { port = 3000, cmd = 'pnpm client', timeout = 30000, } = {}) {
6
6
  if (devServerProcesses[key])
7
7
  return;
8
8
  if (process.env.DEBUG === '1')
@@ -17,9 +17,14 @@ export default async function launchDevServer(key, { port = 3000, cmd = 'pnpm cl
17
17
  },
18
18
  });
19
19
  devServerProcesses[key] = proc;
20
- await waitForPort(key, port, timeout);
20
+ let spawnError;
21
+ let exited = false;
22
+ let exitCode = null;
23
+ // attached before any await so that an immediate spawn failure (e.g. ENOENT)
24
+ // cannot emit 'error' with no listener and crash the process
21
25
  proc.on('error', err => {
22
- throw err;
26
+ spawnError = err;
27
+ console.error(`Dev server "${key}" (\`${cmd}\`) process error: ${err.message}`);
23
28
  });
24
29
  proc.stdout.on('data', data => {
25
30
  if (process.env.DEBUG === '1')
@@ -29,13 +34,37 @@ export default async function launchDevServer(key, { port = 3000, cmd = 'pnpm cl
29
34
  if (process.env.DEBUG === '1')
30
35
  console.error(`Server error: ${data}`);
31
36
  });
32
- proc.on('error', err => {
33
- console.error(`Server process error: ${err}`);
34
- });
35
37
  proc.on('close', code => {
36
- if (process.env.DEBUG === '1')
38
+ exited = true;
39
+ exitCode = code;
40
+ if (devServerProcesses[key] === proc) {
41
+ // the process died on its own (it was not stopped via stopDevServer); evict it
42
+ // so it is not treated as still running, and log unconditionally so specs that
43
+ // subsequently fail on a dead dev server are comprehensible
44
+ delete devServerProcesses[key];
45
+ if (!spawnError)
46
+ console.error(`Dev server "${key}" (\`${cmd}\`) exited unexpectedly with code ${code}`);
47
+ }
48
+ else if (process.env.DEBUG === '1') {
37
49
  console.log(`Server process exited with code ${code}`);
50
+ }
38
51
  });
52
+ try {
53
+ await waitForPort(key, cmd, port, timeout, () => {
54
+ if (spawnError)
55
+ return new Error(`Dev server "${key}" (\`${cmd}\`) failed to start: ${spawnError.message}`, {
56
+ cause: spawnError,
57
+ });
58
+ if (exited)
59
+ return new Error(`Dev server "${key}" (\`${cmd}\`) exited with code ${exitCode} before listening on port ${port}`);
60
+ return undefined;
61
+ });
62
+ }
63
+ catch (err) {
64
+ if (devServerProcesses[key] === proc)
65
+ delete devServerProcesses[key];
66
+ throw err;
67
+ }
39
68
  }
40
69
  export function stopDevServer(key) {
41
70
  const proc = devServerProcesses[key];
@@ -45,9 +74,11 @@ export function stopDevServer(key) {
45
74
  if (proc?.pid) {
46
75
  if (process.env.DEBUG === '1')
47
76
  console.log('Stopping server...');
77
+ // delete before killing so the 'close' listener can distinguish an intentional
78
+ // stop from the dev server dying on its own
79
+ delete devServerProcesses[key];
48
80
  // serverProcess.kill('SIGINT')
49
81
  process.kill(-proc.pid, 'SIGKILL');
50
- delete devServerProcesses[key];
51
82
  if (process.env.DEBUG === '1')
52
83
  console.log('server stopped');
53
84
  }
@@ -75,21 +106,19 @@ async function isPortAvailable(port) {
75
106
  .listen(port, '127.0.0.1');
76
107
  });
77
108
  }
78
- async function waitForPort(key, port, timeout = 5000) {
79
- if (await isPortAvailable(port)) {
80
- return true;
81
- }
109
+ async function waitForPort(key, cmd, port, timeout, abortError) {
82
110
  const startTime = Date.now();
83
- async function recursiveWaitForPort() {
84
- if (await isPortAvailable(port)) {
85
- return true;
86
- }
111
+ // the port being available means nothing is listening on it yet,
112
+ // so keep waiting until the dev server has actually bound it
113
+ while (await isPortAvailable(port)) {
114
+ const error = abortError();
115
+ if (error)
116
+ throw error;
87
117
  if (Date.now() > startTime + timeout) {
88
- stopDevServer(key);
89
- throw new Error('waited too long for port: ' + port);
118
+ if (devServerProcesses[key])
119
+ stopDevServer(key);
120
+ throw new Error(`Dev server "${key}" (\`${cmd}\`) did not listen on port ${port} within ${timeout}ms`);
90
121
  }
91
122
  await sleep(50);
92
- return await recursiveWaitForPort();
93
123
  }
94
- return await recursiveWaitForPort();
95
124
  }
@@ -17,7 +17,6 @@ export { default as providePuppeteerViteMatchers } from './feature/helpers/provi
17
17
  export { default as resetBrowserState } from './feature/helpers/resetBrowserState.js';
18
18
  export { default as visit } from './feature/helpers/visit.js';
19
19
  declare global {
20
- const context: (typeof import('vitest'))['describe'];
21
20
  const page: InstanceType<typeof Page>;
22
21
  const visit: (typeof import('./feature/helpers/visit.js'))['default'];
23
22
  const check: (typeof import('./feature/helpers/matcher-globals/check.js'))['default'];
@@ -30,9 +29,7 @@ declare global {
30
29
  const uncheck: (typeof import('./feature/helpers/matcher-globals/uncheck.js'))['default'];
31
30
  }
32
31
  declare module 'vitest' {
33
- interface ExpectStatic extends PuppeteerAssertions {
34
- }
35
- interface Assertion extends PuppeteerAssertions {
32
+ interface Matchers<T = any> extends PuppeteerAssertions {
36
33
  }
37
34
  }
38
35
  interface PuppeteerAssertions {
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@rvoh/psychic-spec-helpers",
4
- "version": "3.2.1",
4
+ "version": "3.3.0",
5
5
  "description": "psychic framework spec helpers",
6
6
  "author": "RVO Health",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
7
10
  "repository": {
8
11
  "type": "git",
9
12
  "url": "git+https://github.com/rvohealth/psychic-spec-helpers.git"
@@ -30,7 +33,7 @@
30
33
  "uspec": "vitest --config ./spec/unit/vite.config.ts",
31
34
  "fspec": "vitest --config ./spec/features/vite.config.ts",
32
35
  "build": "echo \"building psychic-spec-helpers...\" && rm -rf dist && tsc -p ./tsconfig.esm.build.json",
33
- "build:test-app": "rm -rf dist && echo \"building test app to esm...\" && tsc -p ./tsconfig.esm.build.test-app.json && echo \"building test app to cjs...\" && npx tsc -p ./tsconfig.cjs.build.test-app.json",
36
+ "build:test-app": "rm -rf dist && echo \"building test app to esm...\" && tsc -p ./tsconfig.esm.build.test-app.json",
34
37
  "lint": "pnpm eslint --no-warn-ignored \"src/**/*.ts\" \"spec/**/*.ts\" && pnpm prettier . --check",
35
38
  "format": "pnpm prettier . --write",
36
39
  "prepack": "pnpm build"
@@ -80,5 +83,5 @@
80
83
  "dotenv": "^17.2.3",
81
84
  "pluralize-esm": "^9.0.5"
82
85
  },
83
- "packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d"
86
+ "packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
84
87
  }
@@ -9,7 +9,7 @@ export default async function launchDevServer(
9
9
  {
10
10
  port = 3000,
11
11
  cmd = 'pnpm client',
12
- timeout = 5000,
12
+ timeout = 30000,
13
13
  }: { port?: number; cmd?: string; timeout?: number } = {}
14
14
  ) {
15
15
  if (devServerProcesses[key]) return
@@ -28,10 +28,15 @@ export default async function launchDevServer(
28
28
 
29
29
  devServerProcesses[key] = proc
30
30
 
31
- await waitForPort(key, port, timeout)
31
+ let spawnError: Error | undefined
32
+ let exited = false
33
+ let exitCode: number | null = null
32
34
 
35
+ // attached before any await so that an immediate spawn failure (e.g. ENOENT)
36
+ // cannot emit 'error' with no listener and crash the process
33
37
  proc.on('error', err => {
34
- throw err
38
+ spawnError = err
39
+ console.error(`Dev server "${key}" (\`${cmd}\`) process error: ${err.message}`)
35
40
  })
36
41
 
37
42
  proc.stdout.on('data', data => {
@@ -42,13 +47,43 @@ export default async function launchDevServer(
42
47
  if (process.env.DEBUG === '1') console.error(`Server error: ${data}`)
43
48
  })
44
49
 
45
- proc.on('error', err => {
46
- console.error(`Server process error: ${err as unknown as string}`)
47
- })
48
-
49
50
  proc.on('close', code => {
50
- if (process.env.DEBUG === '1') console.log(`Server process exited with code ${code}`)
51
+ exited = true
52
+ exitCode = code
53
+
54
+ if (devServerProcesses[key] === proc) {
55
+ // the process died on its own (it was not stopped via stopDevServer); evict it
56
+ // so it is not treated as still running, and log unconditionally so specs that
57
+ // subsequently fail on a dead dev server are comprehensible
58
+ delete devServerProcesses[key]
59
+ if (!spawnError)
60
+ console.error(`Dev server "${key}" (\`${cmd}\`) exited unexpectedly with code ${code}`)
61
+ } else if (process.env.DEBUG === '1') {
62
+ console.log(`Server process exited with code ${code}`)
63
+ }
51
64
  })
65
+
66
+ try {
67
+ await waitForPort(key, cmd, port, timeout, () => {
68
+ if (spawnError)
69
+ return new Error(
70
+ `Dev server "${key}" (\`${cmd}\`) failed to start: ${spawnError.message}`,
71
+ {
72
+ cause: spawnError,
73
+ }
74
+ )
75
+
76
+ if (exited)
77
+ return new Error(
78
+ `Dev server "${key}" (\`${cmd}\`) exited with code ${exitCode} before listening on port ${port}`
79
+ )
80
+
81
+ return undefined
82
+ })
83
+ } catch (err) {
84
+ if (devServerProcesses[key] === proc) delete devServerProcesses[key]
85
+ throw err
86
+ }
52
87
  }
53
88
 
54
89
  export function stopDevServer(key: string) {
@@ -59,9 +94,11 @@ export function stopDevServer(key: string) {
59
94
 
60
95
  if (proc?.pid) {
61
96
  if (process.env.DEBUG === '1') console.log('Stopping server...')
97
+ // delete before killing so the 'close' listener can distinguish an intentional
98
+ // stop from the dev server dying on its own
99
+ delete devServerProcesses[key]
62
100
  // serverProcess.kill('SIGINT')
63
101
  process.kill(-proc.pid, 'SIGKILL')
64
- delete devServerProcesses[key]
65
102
 
66
103
  if (process.env.DEBUG === '1') console.log('server stopped')
67
104
  }
@@ -91,26 +128,28 @@ async function isPortAvailable(port: number): Promise<boolean> {
91
128
  })
92
129
  }
93
130
 
94
- async function waitForPort(key: string, port: number, timeout: number = 5000) {
95
- if (await isPortAvailable(port)) {
96
- return true
97
- }
98
-
131
+ async function waitForPort(
132
+ key: string,
133
+ cmd: string,
134
+ port: number,
135
+ timeout: number,
136
+ abortError: () => Error | undefined
137
+ ) {
99
138
  const startTime = Date.now()
100
139
 
101
- async function recursiveWaitForPort() {
102
- if (await isPortAvailable(port)) {
103
- return true
104
- }
140
+ // the port being available means nothing is listening on it yet,
141
+ // so keep waiting until the dev server has actually bound it
142
+ while (await isPortAvailable(port)) {
143
+ const error = abortError()
144
+ if (error) throw error
105
145
 
106
146
  if (Date.now() > startTime + timeout) {
107
- stopDevServer(key)
108
- throw new Error('waited too long for port: ' + port)
147
+ if (devServerProcesses[key]) stopDevServer(key)
148
+ throw new Error(
149
+ `Dev server "${key}" (\`${cmd}\`) did not listen on port ${port} within ${timeout}ms`
150
+ )
109
151
  }
110
152
 
111
153
  await sleep(50)
112
- return await recursiveWaitForPort()
113
154
  }
114
-
115
- return await recursiveWaitForPort()
116
155
  }
package/src/index.ts CHANGED
@@ -36,7 +36,11 @@ export { default as resetBrowserState } from './feature/helpers/resetBrowserStat
36
36
  export { default as visit } from './feature/helpers/visit.js'
37
37
 
38
38
  declare global {
39
- const context: (typeof import('vitest'))['describe']
39
+ // `context` is deliberately NOT declared here: @rvoh/dream-spec-helpers (always
40
+ // present alongside this package in a psychic app) already declares it, and a
41
+ // second declaration is a TS2451 redeclare error whenever both declarations are
42
+ // checked in one program (e.g. this repo's own test-app build, where this file
43
+ // is source rather than a skipLibCheck-suppressed .d.ts).
40
44
 
41
45
  const page: InstanceType<typeof Page>
42
46
  const visit: (typeof import('./feature/helpers/visit.js'))['default']
@@ -51,10 +55,10 @@ declare global {
51
55
  }
52
56
 
53
57
  declare module 'vitest' {
58
+ // vitest >= 3.2 matcher augmentation point (covers expect(), expect.soft, asymmetric
59
+ // matchers); augmenting Assertion/ExpectStatic no longer merges under vitest 4
54
60
  // eslint-disable-next-line
55
- interface ExpectStatic extends PuppeteerAssertions {}
56
- // eslint-disable-next-line
57
- interface Assertion extends PuppeteerAssertions {}
61
+ interface Matchers<T = any> extends PuppeteerAssertions {}
58
62
  }
59
63
 
60
64
  interface PuppeteerAssertions {