@remix-run/test 0.0.0 → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +325 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +171 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/lib/config.d.ts +60 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +152 -0
- package/dist/lib/context.d.ts +69 -0
- package/dist/lib/context.d.ts.map +1 -0
- package/dist/lib/context.js +49 -0
- package/dist/lib/e2e-server.d.ts +11 -0
- package/dist/lib/e2e-server.d.ts.map +1 -0
- package/dist/lib/e2e-server.js +15 -0
- package/dist/lib/executor.d.ts +27 -0
- package/dist/lib/executor.d.ts.map +1 -0
- package/dist/lib/executor.js +123 -0
- package/dist/lib/framework.d.ts +107 -0
- package/dist/lib/framework.d.ts.map +1 -0
- package/dist/lib/framework.js +198 -0
- package/dist/lib/framework.test.d.ts +2 -0
- package/dist/lib/framework.test.d.ts.map +1 -0
- package/dist/lib/framework.test.e2e.d.ts +2 -0
- package/dist/lib/framework.test.e2e.d.ts.map +1 -0
- package/dist/lib/framework.test.e2e.js +29 -0
- package/dist/lib/framework.test.js +283 -0
- package/dist/lib/mock.d.ts +52 -0
- package/dist/lib/mock.d.ts.map +1 -0
- package/dist/lib/mock.js +61 -0
- package/dist/lib/playwright.d.ts +15 -0
- package/dist/lib/playwright.d.ts.map +1 -0
- package/dist/lib/playwright.js +84 -0
- package/dist/lib/reporters/dot.d.ts +10 -0
- package/dist/lib/reporters/dot.d.ts.map +1 -0
- package/dist/lib/reporters/dot.js +55 -0
- package/dist/lib/reporters/files.d.ts +10 -0
- package/dist/lib/reporters/files.d.ts.map +1 -0
- package/dist/lib/reporters/files.js +70 -0
- package/dist/lib/reporters/index.d.ts +14 -0
- package/dist/lib/reporters/index.d.ts.map +1 -0
- package/dist/lib/reporters/index.js +18 -0
- package/dist/lib/reporters/spec.d.ts +10 -0
- package/dist/lib/reporters/spec.d.ts.map +1 -0
- package/dist/lib/reporters/spec.js +152 -0
- package/dist/lib/reporters/tap.d.ts +10 -0
- package/dist/lib/reporters/tap.d.ts.map +1 -0
- package/dist/lib/reporters/tap.js +54 -0
- package/dist/lib/runner.d.ts +9 -0
- package/dist/lib/runner.d.ts.map +1 -0
- package/dist/lib/runner.js +89 -0
- package/dist/lib/utils.d.ts +16 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +27 -0
- package/dist/lib/watcher.d.ts +5 -0
- package/dist/lib/watcher.d.ts.map +1 -0
- package/dist/lib/watcher.js +39 -0
- package/dist/lib/worker-e2e.d.ts +2 -0
- package/dist/lib/worker-e2e.d.ts.map +1 -0
- package/dist/lib/worker-e2e.js +48 -0
- package/dist/lib/worker.d.ts +2 -0
- package/dist/lib/worker.d.ts.map +1 -0
- package/dist/lib/worker.js +29 -0
- package/package.json +58 -5
- package/src/cli.ts +210 -0
- package/src/index.ts +15 -0
- package/src/lib/config.ts +231 -0
- package/src/lib/context.ts +126 -0
- package/src/lib/e2e-server.ts +28 -0
- package/src/lib/executor.ts +162 -0
- package/src/lib/framework.ts +251 -0
- package/src/lib/mock.ts +89 -0
- package/src/lib/playwright.ts +102 -0
- package/src/lib/reporters/dot.ts +57 -0
- package/src/lib/reporters/files.ts +76 -0
- package/src/lib/reporters/index.ts +28 -0
- package/src/lib/reporters/spec.ts +173 -0
- package/src/lib/reporters/tap.ts +58 -0
- package/src/lib/runner.ts +137 -0
- package/src/lib/utils.ts +40 -0
- package/src/lib/watcher.ts +46 -0
- package/src/lib/worker-e2e.ts +52 -0
- package/src/lib/worker.ts +30 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import * as os from 'node:os'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import * as fsp from 'node:fs/promises'
|
|
4
|
+
import * as util from 'node:util'
|
|
5
|
+
import { tsImport } from 'tsx/esm/api'
|
|
6
|
+
import type { PlaywrightTestConfig } from 'playwright/test'
|
|
7
|
+
|
|
8
|
+
// prettier-ignore
|
|
9
|
+
// Note: `description` is not a field used by parseArgs(), it's an additional field
|
|
10
|
+
// we use for `--help`
|
|
11
|
+
const cliOptions = {
|
|
12
|
+
'browser.echo': {
|
|
13
|
+
type: 'boolean',
|
|
14
|
+
description: 'Echo browser console output to stdout',
|
|
15
|
+
},
|
|
16
|
+
'browser.open': {
|
|
17
|
+
type: 'boolean',
|
|
18
|
+
description: 'Open browser window and keep open after tests finish',
|
|
19
|
+
},
|
|
20
|
+
'glob.e2e': {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Glob pattern for E2E test files',
|
|
23
|
+
},
|
|
24
|
+
'glob.test': {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Glob pattern for all test files',
|
|
27
|
+
},
|
|
28
|
+
concurrency: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
short: 'c',
|
|
31
|
+
description: 'Max number of concurrent test workers (default: os.availableParallelism())',
|
|
32
|
+
},
|
|
33
|
+
config: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Path to config file (default: remix-test.config.ts)',
|
|
36
|
+
},
|
|
37
|
+
setup: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
short: 's',
|
|
40
|
+
description: 'Path to a setup module exporting globalSetup/globalTeardown',
|
|
41
|
+
},
|
|
42
|
+
playwrightConfig: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
description: 'Path to a Playwright config file',
|
|
45
|
+
},
|
|
46
|
+
project: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
short: 'p',
|
|
49
|
+
description: 'Filter to a specific Playwright project (comma-separated)',
|
|
50
|
+
},
|
|
51
|
+
reporter: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
short: 'r',
|
|
54
|
+
description: 'Test reporter: spec, files, tap, dot (default: spec)',
|
|
55
|
+
},
|
|
56
|
+
type: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
short: 't',
|
|
59
|
+
description: 'Comma-separated test types to run (default: server,e2e)',
|
|
60
|
+
},
|
|
61
|
+
watch: {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
short: 'w',
|
|
64
|
+
description: 'Re-run tests on file changes',
|
|
65
|
+
},
|
|
66
|
+
} as const
|
|
67
|
+
|
|
68
|
+
const defaultValues: ResolvedRemixTestConfig = {
|
|
69
|
+
browser: {
|
|
70
|
+
echo: false,
|
|
71
|
+
open: false,
|
|
72
|
+
},
|
|
73
|
+
concurrency: os.availableParallelism(),
|
|
74
|
+
glob: {
|
|
75
|
+
test: '**/*.test?(.e2e).{ts,tsx}',
|
|
76
|
+
e2e: '**/*.test.e2e.{ts,tsx}',
|
|
77
|
+
},
|
|
78
|
+
reporter: process.env.CI === 'true' ? 'dot' : 'spec',
|
|
79
|
+
type: 'server,e2e',
|
|
80
|
+
setup: undefined,
|
|
81
|
+
playwrightConfig: undefined,
|
|
82
|
+
project: undefined,
|
|
83
|
+
watch: false,
|
|
84
|
+
} as const
|
|
85
|
+
|
|
86
|
+
export interface RemixTestConfig {
|
|
87
|
+
/**
|
|
88
|
+
* Options for controlling the playwright browser
|
|
89
|
+
* - `browser.echo`: Echo browser console output to stdout (--browser.echo)
|
|
90
|
+
* - `browser.open`: Open browser window and keep open after test finish (--browser.open)
|
|
91
|
+
*/
|
|
92
|
+
browser?: {
|
|
93
|
+
echo?: boolean
|
|
94
|
+
open?: boolean
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Glob patterns to identify test files
|
|
98
|
+
* - `glob.test`: Glob pattern for all test files (--glob.test)
|
|
99
|
+
* - `glob.e2e`: Glob pattern for the subset of e2e test files (--glob.e2e)
|
|
100
|
+
*/
|
|
101
|
+
glob?: {
|
|
102
|
+
test?: string
|
|
103
|
+
e2e?: string
|
|
104
|
+
}
|
|
105
|
+
/** Max number of concurrent test workers (--concurrency) */
|
|
106
|
+
concurrency?: number | string
|
|
107
|
+
/**
|
|
108
|
+
* Path to a module that exports `globalSetup` and/or `globalTeardown` functions,
|
|
109
|
+
* called once before and after the test run respectively. (--setup)
|
|
110
|
+
*/
|
|
111
|
+
setup?: string
|
|
112
|
+
/**
|
|
113
|
+
* Playwright configuration — either a path to a playwright config file or an inline
|
|
114
|
+
* PlaywrightTestConfig object. CLI `--playwrightConfig` only accepts a file path.
|
|
115
|
+
*/
|
|
116
|
+
playwrightConfig?: string | PlaywrightTestConfig
|
|
117
|
+
/** Filter tests to a specific playwright project or comma-separated list of projects (--project) */
|
|
118
|
+
project?: string
|
|
119
|
+
/** Test reporter (--reporter) */
|
|
120
|
+
reporter?: string
|
|
121
|
+
/** Comma-separated list of test types to run (--type) */
|
|
122
|
+
type?: string
|
|
123
|
+
/** Watch mode — re-run tests on file changes (--watch) */
|
|
124
|
+
watch?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ResolvedRemixTestConfig {
|
|
128
|
+
browser: {
|
|
129
|
+
echo?: boolean
|
|
130
|
+
open?: boolean
|
|
131
|
+
}
|
|
132
|
+
concurrency: number
|
|
133
|
+
glob: {
|
|
134
|
+
test: string
|
|
135
|
+
e2e: string
|
|
136
|
+
}
|
|
137
|
+
playwrightConfig: string | PlaywrightTestConfig | undefined
|
|
138
|
+
project: string | undefined
|
|
139
|
+
reporter: string
|
|
140
|
+
setup: string | undefined
|
|
141
|
+
type: string
|
|
142
|
+
watch: boolean
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function loadConfig() {
|
|
146
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
147
|
+
console.log(generateHelp())
|
|
148
|
+
process.exit(0)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let parsed = parseCliArgs()
|
|
152
|
+
let fileConfig = await loadConfigFile(parsed.values.config)
|
|
153
|
+
let config = resolveConfig(fileConfig, parsed)
|
|
154
|
+
return config
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function generateHelp(): string {
|
|
158
|
+
let lines = [
|
|
159
|
+
'Usage: remix-test [glob] [options]',
|
|
160
|
+
'',
|
|
161
|
+
'Arguments:',
|
|
162
|
+
` glob Glob pattern for test files (default: "${defaultValues.glob.test}")`,
|
|
163
|
+
'',
|
|
164
|
+
'Options:',
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
for (let [long, opt] of Object.entries(cliOptions)) {
|
|
168
|
+
let short = 'short' in opt ? `/-${opt.short}` : ''
|
|
169
|
+
let label = opt.type === 'string' ? `--${long}${short} <value>` : `--${long}${short}`
|
|
170
|
+
lines.push(` ${label.padEnd(30)} ${opt.description}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
lines.push(` ${'-h, --help'.padEnd(30)} Show this help message`)
|
|
174
|
+
|
|
175
|
+
return lines.join('\n')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseCliArgs(args = process.argv.slice(2)) {
|
|
179
|
+
return util.parseArgs({ args, options: cliOptions, allowPositionals: true })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveConfig(
|
|
183
|
+
fileConfig: RemixTestConfig,
|
|
184
|
+
{ values: cliValues, positionals }: ReturnType<typeof parseCliArgs>,
|
|
185
|
+
): ResolvedRemixTestConfig {
|
|
186
|
+
return {
|
|
187
|
+
glob: {
|
|
188
|
+
test:
|
|
189
|
+
positionals[0] ??
|
|
190
|
+
cliValues['glob.test'] ??
|
|
191
|
+
fileConfig.glob?.test ??
|
|
192
|
+
defaultValues.glob.test,
|
|
193
|
+
e2e: cliValues['glob.e2e'] ?? fileConfig.glob?.e2e ?? defaultValues.glob.e2e,
|
|
194
|
+
},
|
|
195
|
+
browser: {
|
|
196
|
+
echo: cliValues['browser.echo'] ?? fileConfig.browser?.echo ?? defaultValues.browser.echo,
|
|
197
|
+
open: cliValues['browser.open'] ?? fileConfig.browser?.open ?? defaultValues.browser.open,
|
|
198
|
+
},
|
|
199
|
+
concurrency: Number(
|
|
200
|
+
cliValues.concurrency ?? fileConfig.concurrency ?? defaultValues.concurrency,
|
|
201
|
+
),
|
|
202
|
+
setup: cliValues.setup ?? fileConfig.setup ?? defaultValues.setup,
|
|
203
|
+
playwrightConfig:
|
|
204
|
+
cliValues.playwrightConfig ?? fileConfig.playwrightConfig ?? defaultValues.playwrightConfig,
|
|
205
|
+
project: cliValues.project ?? fileConfig.project ?? defaultValues.project,
|
|
206
|
+
reporter: cliValues.reporter ?? fileConfig.reporter ?? defaultValues.reporter,
|
|
207
|
+
type: cliValues.type ?? fileConfig.type ?? defaultValues.type,
|
|
208
|
+
watch: cliValues.watch ?? fileConfig.watch ?? defaultValues.watch,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function loadConfigFile(configPath?: string): Promise<RemixTestConfig> {
|
|
213
|
+
let candidates = configPath
|
|
214
|
+
? [path.resolve(process.cwd(), configPath)]
|
|
215
|
+
: [
|
|
216
|
+
path.join(process.cwd(), 'remix-test.config.ts'),
|
|
217
|
+
path.join(process.cwd(), 'remix-test.config.js'),
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
for (let candidate of candidates) {
|
|
221
|
+
try {
|
|
222
|
+
await fsp.access(candidate)
|
|
223
|
+
let mod = await tsImport(candidate, { parentURL: import.meta.url })
|
|
224
|
+
return mod.default ?? mod
|
|
225
|
+
} catch {
|
|
226
|
+
// not found or failed to load — try next
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {}
|
|
231
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { Browser, Page } from 'playwright'
|
|
2
|
+
import { mock, type MockFunction, type MockCall, type MockContext } from './mock.ts'
|
|
3
|
+
|
|
4
|
+
import type { CreateServerFunction } from './e2e-server.ts'
|
|
5
|
+
import type { getPlaywrightPageOptions } from './playwright.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Test Context providing utilities for testing via remix-test. The context is
|
|
9
|
+
* passed as the first argument to the {@link test}/{@link it} functions.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* describe('my test suite', () => {
|
|
13
|
+
* it('my test case', async (t) => {
|
|
14
|
+
* let mockFn = t.mock.fn(() => 'mocked value')
|
|
15
|
+
* // ...
|
|
16
|
+
* })
|
|
17
|
+
* })
|
|
18
|
+
*/
|
|
19
|
+
export interface TestContext {
|
|
20
|
+
/**
|
|
21
|
+
* Registers a cleanup function to be called after the test completes.
|
|
22
|
+
*
|
|
23
|
+
* @param {() => void} fn - The cleanup function to execute
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
after(fn: () => void): void
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mock tracker for the current test. Mirrors the shape of Node's
|
|
30
|
+
* `t.mock`. Method mocks created here are auto-restored on test completion.
|
|
31
|
+
*/
|
|
32
|
+
mock: {
|
|
33
|
+
/**
|
|
34
|
+
* Creates a mock function with an optional implementation.
|
|
35
|
+
*
|
|
36
|
+
* @template T - The function type to be mocked
|
|
37
|
+
* @param {T} [impl] - Optional custom implementation for the mock
|
|
38
|
+
* @returns {MockFunction<T>} A mock function instance
|
|
39
|
+
*/
|
|
40
|
+
fn<T extends (...args: any[]) => any>(impl?: T): MockFunction<T>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Replaces `obj[methodName]` with a mock and records every call. The
|
|
44
|
+
* original method is restored automatically after the test completes.
|
|
45
|
+
*
|
|
46
|
+
* @template T - The object type
|
|
47
|
+
* @template K - The method key of the object
|
|
48
|
+
* @param {T} obj - The object to mock
|
|
49
|
+
* @param {K} methodName - The method name to mock
|
|
50
|
+
* @param {Function} [impl] - Optional implementation override (must be a function)
|
|
51
|
+
* @returns {MockFunction} A mock function instance for the mocked method
|
|
52
|
+
*/
|
|
53
|
+
method<T extends object, K extends keyof T>(
|
|
54
|
+
obj: T,
|
|
55
|
+
methodName: K,
|
|
56
|
+
impl?: Function,
|
|
57
|
+
): MockFunction
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Starts a test server with the provided request handler.
|
|
62
|
+
*
|
|
63
|
+
* @param {(req: Request) => Promise<Response>} handler - Function handling incoming requests
|
|
64
|
+
* @returns {Promise<Page>} A promise resolving to a page instance for the server
|
|
65
|
+
*/
|
|
66
|
+
serve(handler: (req: Request) => Promise<Response>): Promise<Page>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createTestContext(options: {
|
|
70
|
+
createServer?: CreateServerFunction
|
|
71
|
+
browser?: Browser
|
|
72
|
+
open?: boolean
|
|
73
|
+
playwrightPageOptions?: ReturnType<typeof getPlaywrightPageOptions>
|
|
74
|
+
}): { testContext: TestContext; cleanup(): Promise<void> } {
|
|
75
|
+
let cleanups: Array<() => void | Promise<void>> = []
|
|
76
|
+
|
|
77
|
+
let testContext: TestContext = {
|
|
78
|
+
mock: {
|
|
79
|
+
fn: mock.fn,
|
|
80
|
+
method(obj, methodName, impl) {
|
|
81
|
+
let mockFn = mock.method(obj, methodName, impl as any)
|
|
82
|
+
if (mockFn.mock.restore) cleanups.push(mockFn.mock.restore)
|
|
83
|
+
return mockFn
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
after(fn) {
|
|
87
|
+
cleanups.push(fn)
|
|
88
|
+
},
|
|
89
|
+
async serve(handler) {
|
|
90
|
+
if (!options.createServer || !options.browser) {
|
|
91
|
+
throw new Error('t.serve() is only available in E2E test suites')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let server = await options.createServer(handler)
|
|
95
|
+
let page = await options.browser.newPage({
|
|
96
|
+
...options.playwrightPageOptions,
|
|
97
|
+
baseURL: server.baseUrl,
|
|
98
|
+
})
|
|
99
|
+
if (options.playwrightPageOptions?.navigationTimeout != null) {
|
|
100
|
+
page.setDefaultNavigationTimeout(options.playwrightPageOptions.navigationTimeout)
|
|
101
|
+
}
|
|
102
|
+
if (options.playwrightPageOptions?.actionTimeout != null) {
|
|
103
|
+
page.setDefaultTimeout(options.playwrightPageOptions.actionTimeout)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
cleanups.push(async () => {
|
|
107
|
+
if (!options.open) {
|
|
108
|
+
await page.close()
|
|
109
|
+
}
|
|
110
|
+
await server.close()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return page
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
testContext,
|
|
119
|
+
async cleanup() {
|
|
120
|
+
for (let fn of cleanups) await fn()
|
|
121
|
+
cleanups.length = 0
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type { MockFunction, MockCall, MockContext }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as http from 'node:http'
|
|
2
|
+
import { createRequestListener } from '@remix-run/node-fetch-server'
|
|
3
|
+
|
|
4
|
+
export interface CreateServerFunction {
|
|
5
|
+
(handler: (req: Request) => Promise<Response>): Promise<{
|
|
6
|
+
baseUrl: string
|
|
7
|
+
close(): Promise<void>
|
|
8
|
+
}>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createServer(handler: (req: Request) => Promise<Response>): Promise<{
|
|
12
|
+
baseUrl: string
|
|
13
|
+
close(): Promise<void>
|
|
14
|
+
}> {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let server = http.createServer(createRequestListener(handler))
|
|
17
|
+
|
|
18
|
+
server.listen(0, '127.0.0.1', () => {
|
|
19
|
+
let addr = server.address() as { port: number }
|
|
20
|
+
resolve({
|
|
21
|
+
baseUrl: `http://127.0.0.1:${addr.port}`,
|
|
22
|
+
close: () => new Promise((r, rj) => server.close((e) => (e ? rj(e) : r()))),
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
server.on('error', reject)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Browser, BrowserContextOptions } from 'playwright'
|
|
2
|
+
import { createTestContext } from './context.ts'
|
|
3
|
+
import type { CreateServerFunction } from './e2e-server.ts'
|
|
4
|
+
|
|
5
|
+
export interface TestResult {
|
|
6
|
+
name: string
|
|
7
|
+
suiteName: string
|
|
8
|
+
filePath?: string
|
|
9
|
+
status: 'passed' | 'failed' | 'skipped' | 'todo'
|
|
10
|
+
error?: {
|
|
11
|
+
message: string
|
|
12
|
+
stack?: string
|
|
13
|
+
}
|
|
14
|
+
duration: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TestResults {
|
|
18
|
+
passed: number
|
|
19
|
+
failed: number
|
|
20
|
+
skipped: number
|
|
21
|
+
todo: number
|
|
22
|
+
tests: TestResult[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runTests(options?: {
|
|
26
|
+
createServer?: CreateServerFunction
|
|
27
|
+
browser?: Browser
|
|
28
|
+
open?: boolean
|
|
29
|
+
playwrightPageOptions?: BrowserContextOptions
|
|
30
|
+
}): Promise<TestResults> {
|
|
31
|
+
let suites = (globalThis as any).__testSuites || []
|
|
32
|
+
let results: TestResults = {
|
|
33
|
+
passed: 0,
|
|
34
|
+
failed: 0,
|
|
35
|
+
skipped: 0,
|
|
36
|
+
todo: 0,
|
|
37
|
+
tests: [],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let hasOnlySuites = suites.some((s: any) => s.only)
|
|
41
|
+
|
|
42
|
+
for (let suite of suites) {
|
|
43
|
+
// If any suite uses .only, skip all non-only suites
|
|
44
|
+
if (hasOnlySuites && !suite.only) {
|
|
45
|
+
for (let test of suite.tests) {
|
|
46
|
+
results.tests.push({
|
|
47
|
+
name: test.name,
|
|
48
|
+
suiteName: suite.name,
|
|
49
|
+
status: 'skipped',
|
|
50
|
+
duration: 0,
|
|
51
|
+
})
|
|
52
|
+
results.skipped++
|
|
53
|
+
}
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (suite.skip || suite.todo) {
|
|
58
|
+
let status: 'skipped' | 'todo' = suite.todo ? 'todo' : 'skipped'
|
|
59
|
+
for (let test of suite.tests) {
|
|
60
|
+
results.tests.push({ name: test.name, suiteName: suite.name, status, duration: 0 })
|
|
61
|
+
results[status]++
|
|
62
|
+
}
|
|
63
|
+
// describe.todo('name') with no tests — add placeholder so suite appears in output
|
|
64
|
+
if (suite.tests.length === 0) {
|
|
65
|
+
results.tests.push({ name: '', suiteName: suite.name, status, duration: 0 })
|
|
66
|
+
results[status]++
|
|
67
|
+
}
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (suite.beforeAll) {
|
|
72
|
+
try {
|
|
73
|
+
await suite.beforeAll()
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error(`beforeAll failed in suite "${suite.name}":`, error)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let hasOnlyTests = suite.tests.some((t: any) => t.only)
|
|
81
|
+
|
|
82
|
+
for (let test of suite.tests) {
|
|
83
|
+
// If any test uses .only, skip all non-only tests in this suite
|
|
84
|
+
if (hasOnlyTests && !test.only) {
|
|
85
|
+
results.tests.push({
|
|
86
|
+
name: test.name,
|
|
87
|
+
suiteName: suite.name,
|
|
88
|
+
status: 'skipped',
|
|
89
|
+
duration: 0,
|
|
90
|
+
})
|
|
91
|
+
results.skipped++
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (test.skip || test.todo) {
|
|
96
|
+
let status: 'skipped' | 'todo' = test.todo ? 'todo' : 'skipped'
|
|
97
|
+
results.tests.push({ name: test.name, suiteName: suite.name, status, duration: 0 })
|
|
98
|
+
results[status]++
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let startTime = performance.now()
|
|
103
|
+
let result: TestResult = {
|
|
104
|
+
name: test.name,
|
|
105
|
+
suiteName: suite.name,
|
|
106
|
+
status: 'passed',
|
|
107
|
+
duration: 0,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let { testContext, cleanup } = createTestContext({
|
|
111
|
+
createServer: options?.createServer,
|
|
112
|
+
browser: options?.browser,
|
|
113
|
+
open: options?.open,
|
|
114
|
+
playwrightPageOptions: options?.playwrightPageOptions,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (suite.beforeEach) {
|
|
119
|
+
await suite.beforeEach()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await test.fn(testContext)
|
|
123
|
+
|
|
124
|
+
result.status = 'passed'
|
|
125
|
+
results.passed++
|
|
126
|
+
} catch (error: any) {
|
|
127
|
+
result.status = 'failed'
|
|
128
|
+
result.error = {
|
|
129
|
+
message: error.message || String(error),
|
|
130
|
+
stack: error.stack,
|
|
131
|
+
}
|
|
132
|
+
results.failed++
|
|
133
|
+
} finally {
|
|
134
|
+
await cleanup()
|
|
135
|
+
if (suite.afterEach) {
|
|
136
|
+
try {
|
|
137
|
+
await suite.afterEach()
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.error('afterEach failed:', error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
result.duration = performance.now() - startTime
|
|
144
|
+
results.tests.push(result)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (suite.afterAll) {
|
|
149
|
+
try {
|
|
150
|
+
await suite.afterAll()
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error(`afterAll failed in suite "${suite.name}":`, error)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Clear suites in-place so the shared framework module is reset
|
|
158
|
+
// for the next test file (which reuses the same cached module instance)
|
|
159
|
+
suites.length = 0
|
|
160
|
+
|
|
161
|
+
return results
|
|
162
|
+
}
|