@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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +325 -2
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +171 -0
  6. package/dist/index.d.ts +5 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +2 -0
  9. package/dist/lib/config.d.ts +60 -0
  10. package/dist/lib/config.d.ts.map +1 -0
  11. package/dist/lib/config.js +152 -0
  12. package/dist/lib/context.d.ts +69 -0
  13. package/dist/lib/context.d.ts.map +1 -0
  14. package/dist/lib/context.js +49 -0
  15. package/dist/lib/e2e-server.d.ts +11 -0
  16. package/dist/lib/e2e-server.d.ts.map +1 -0
  17. package/dist/lib/e2e-server.js +15 -0
  18. package/dist/lib/executor.d.ts +27 -0
  19. package/dist/lib/executor.d.ts.map +1 -0
  20. package/dist/lib/executor.js +123 -0
  21. package/dist/lib/framework.d.ts +107 -0
  22. package/dist/lib/framework.d.ts.map +1 -0
  23. package/dist/lib/framework.js +198 -0
  24. package/dist/lib/framework.test.d.ts +2 -0
  25. package/dist/lib/framework.test.d.ts.map +1 -0
  26. package/dist/lib/framework.test.e2e.d.ts +2 -0
  27. package/dist/lib/framework.test.e2e.d.ts.map +1 -0
  28. package/dist/lib/framework.test.e2e.js +29 -0
  29. package/dist/lib/framework.test.js +283 -0
  30. package/dist/lib/mock.d.ts +52 -0
  31. package/dist/lib/mock.d.ts.map +1 -0
  32. package/dist/lib/mock.js +61 -0
  33. package/dist/lib/playwright.d.ts +15 -0
  34. package/dist/lib/playwright.d.ts.map +1 -0
  35. package/dist/lib/playwright.js +84 -0
  36. package/dist/lib/reporters/dot.d.ts +10 -0
  37. package/dist/lib/reporters/dot.d.ts.map +1 -0
  38. package/dist/lib/reporters/dot.js +55 -0
  39. package/dist/lib/reporters/files.d.ts +10 -0
  40. package/dist/lib/reporters/files.d.ts.map +1 -0
  41. package/dist/lib/reporters/files.js +70 -0
  42. package/dist/lib/reporters/index.d.ts +14 -0
  43. package/dist/lib/reporters/index.d.ts.map +1 -0
  44. package/dist/lib/reporters/index.js +18 -0
  45. package/dist/lib/reporters/spec.d.ts +10 -0
  46. package/dist/lib/reporters/spec.d.ts.map +1 -0
  47. package/dist/lib/reporters/spec.js +152 -0
  48. package/dist/lib/reporters/tap.d.ts +10 -0
  49. package/dist/lib/reporters/tap.d.ts.map +1 -0
  50. package/dist/lib/reporters/tap.js +54 -0
  51. package/dist/lib/runner.d.ts +9 -0
  52. package/dist/lib/runner.d.ts.map +1 -0
  53. package/dist/lib/runner.js +89 -0
  54. package/dist/lib/utils.d.ts +16 -0
  55. package/dist/lib/utils.d.ts.map +1 -0
  56. package/dist/lib/utils.js +27 -0
  57. package/dist/lib/watcher.d.ts +5 -0
  58. package/dist/lib/watcher.d.ts.map +1 -0
  59. package/dist/lib/watcher.js +39 -0
  60. package/dist/lib/worker-e2e.d.ts +2 -0
  61. package/dist/lib/worker-e2e.d.ts.map +1 -0
  62. package/dist/lib/worker-e2e.js +48 -0
  63. package/dist/lib/worker.d.ts +2 -0
  64. package/dist/lib/worker.d.ts.map +1 -0
  65. package/dist/lib/worker.js +29 -0
  66. package/package.json +58 -5
  67. package/src/cli.ts +210 -0
  68. package/src/index.ts +15 -0
  69. package/src/lib/config.ts +231 -0
  70. package/src/lib/context.ts +126 -0
  71. package/src/lib/e2e-server.ts +28 -0
  72. package/src/lib/executor.ts +162 -0
  73. package/src/lib/framework.ts +251 -0
  74. package/src/lib/mock.ts +89 -0
  75. package/src/lib/playwright.ts +102 -0
  76. package/src/lib/reporters/dot.ts +57 -0
  77. package/src/lib/reporters/files.ts +76 -0
  78. package/src/lib/reporters/index.ts +28 -0
  79. package/src/lib/reporters/spec.ts +173 -0
  80. package/src/lib/reporters/tap.ts +58 -0
  81. package/src/lib/runner.ts +137 -0
  82. package/src/lib/utils.ts +40 -0
  83. package/src/lib/watcher.ts +46 -0
  84. package/src/lib/worker-e2e.ts +52 -0
  85. package/src/lib/worker.ts +30 -0
  86. 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
+ }