@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,251 @@
1
+ import type { TestContext } from './context.ts'
2
+
3
+ interface TestSuite {
4
+ name: string
5
+ tests: Test[]
6
+ only?: boolean
7
+ skip?: boolean
8
+ todo?: boolean
9
+ beforeEach?: () => void | Promise<void>
10
+ afterEach?: () => void | Promise<void>
11
+ beforeAll?: () => void | Promise<void>
12
+ afterAll?: () => void | Promise<void>
13
+ }
14
+
15
+ interface Test {
16
+ name: string
17
+ fn: (t?: any) => void | Promise<void>
18
+ suite: TestSuite
19
+ only?: boolean
20
+ skip?: boolean
21
+ todo?: boolean
22
+ }
23
+
24
+ // Holds lifecycle hooks registered at the top level (outside any describe).
25
+ // Top-level describes inherit these hooks just like nested describes inherit
26
+ // from their parent.
27
+ const rootHooks: Pick<TestSuite, 'beforeEach' | 'afterEach' | 'beforeAll' | 'afterAll'> = {}
28
+
29
+ let currentSuite: TestSuite | null = null
30
+ const rootSuites: TestSuite[] = []
31
+
32
+ // Lazily-created suite for top-level it() calls outside any describe().
33
+ // Name '' causes the reporter to display these tests under "Global".
34
+ // We check rootSuites.includes() so the suite is re-created after the executor
35
+ // clears rootSuites between files (suites.length = 0) or after captureRegistration splices it.
36
+ let implicitRootSuite: TestSuite | null = null
37
+ function getImplicitRootSuite(): TestSuite {
38
+ if (!implicitRootSuite || !rootSuites.includes(implicitRootSuite)) {
39
+ implicitRootSuite = { name: '', tests: [], ...rootHooks }
40
+ rootSuites.push(implicitRootSuite)
41
+ }
42
+ return implicitRootSuite
43
+ }
44
+
45
+ // Expose for executor.ts which reads this global
46
+ ;(globalThis as any).__testSuites = rootSuites
47
+
48
+ function registerDescribe(
49
+ name: string,
50
+ fn: () => void,
51
+ flags?: { only?: boolean; skip?: boolean },
52
+ ) {
53
+ // Nested describes are flattened: "Parent > Child"
54
+ let fullName = currentSuite ? `${currentSuite.name} > ${name}` : name
55
+ if (rootSuites.some((s) => s.name === fullName)) {
56
+ throw new Error(`Duplicate suite name: "${fullName}"`)
57
+ }
58
+ let suite: TestSuite = { name: fullName, tests: [], ...flags }
59
+
60
+ // Inherit lifecycle hooks from parent suite (or root hooks if at top level)
61
+ let parent = currentSuite ?? rootHooks
62
+ if (parent.beforeEach) suite.beforeEach = parent.beforeEach
63
+ if (parent.afterEach) suite.afterEach = parent.afterEach
64
+ if (parent.beforeAll) suite.beforeAll = parent.beforeAll
65
+ if (parent.afterAll) suite.afterAll = parent.afterAll
66
+
67
+ let insertedAt = rootSuites.length
68
+ rootSuites.push(suite)
69
+ let prevSuite = currentSuite
70
+ currentSuite = suite
71
+ try {
72
+ fn()
73
+ } catch (error) {
74
+ // Remove this suite and any suites registered during fn() so they don't
75
+ // end up in the executor after a failed registration call
76
+ rootSuites.splice(insertedAt)
77
+ throw error
78
+ } finally {
79
+ currentSuite = prevSuite
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Groups related tests into a named suite. Suites can be nested snd will be displayed
85
+ * as such or joined with ` > ` in reporter output. Lifecycle hooks registered inside
86
+ * a `describe` block apply only to tests within that block.
87
+ *
88
+ * @example
89
+ * describe('auth', () => {
90
+ * it('logs in', async () => { ... })
91
+ * })
92
+ *
93
+ * // Modifiers
94
+ * describe.skip('skipped suite', () => { ... })
95
+ * describe.only('focused suite', () => { ... })
96
+ * describe.todo('planned suite')
97
+ *
98
+ * @param name - The suite name shown in reporter output.
99
+ * @param fn - A function that registers the tests and lifecycle hooks in this suite.
100
+ */
101
+ export const describe = Object.assign(
102
+ (name: string, metaOrFn: SuiteMeta | (() => void), fn?: () => void) => {
103
+ let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
104
+ let suiteFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
105
+ registerDescribe(name, suiteFn, meta)
106
+ },
107
+ {
108
+ skip: (name: string, fn: () => void) => registerDescribe(name, fn, { skip: true }),
109
+ only: (name: string, fn: () => void) => registerDescribe(name, fn, { only: true }),
110
+ todo: (name: string) => {
111
+ let fullName = currentSuite ? `${currentSuite.name} > ${name}` : name
112
+ if (rootSuites.some((s) => s.name === fullName)) {
113
+ throw new Error(`Duplicate suite name: "${fullName}"`)
114
+ }
115
+ rootSuites.push({ name: fullName, tests: [], todo: true })
116
+ },
117
+ },
118
+ )
119
+
120
+ type SuiteMeta = { skip?: boolean; only?: boolean }
121
+ type TestMeta = { skip?: boolean; only?: boolean }
122
+ type TestFn = (t: TestContext) => void | Promise<void>
123
+
124
+ function registerIt(name: string, fn: TestFn, flags?: { only?: boolean; skip?: boolean }) {
125
+ let suite = currentSuite ?? getImplicitRootSuite()
126
+ if (suite.tests.some((t) => t.name === name)) {
127
+ throw new Error(`Duplicate test name: "${name}" in suite "${suite.name || 'Global'}"`)
128
+ }
129
+ suite.tests.push({ name, fn, suite, ...flags })
130
+ }
131
+
132
+ /**
133
+ * Defines a single test case. The optional `TestContext` argument `t` provides
134
+ * mock helpers and per-test cleanup registration.
135
+ *
136
+ * @example
137
+ * it('returns 200 for the home route', async () => {
138
+ * const res = await router.fetch('/')
139
+ * assert.equal(res.status, 200)
140
+ * })
141
+ *
142
+ * // Modifiers
143
+ * it.skip('not ready yet', () => { ... })
144
+ * it.only('focused test', () => { ... })
145
+ * it.todo('coming soon')
146
+ *
147
+ * @param name - The test name shown in reporter output.
148
+ * @param fn - The test body, receiving a {@link TestContext} as its first argument.
149
+ */
150
+ export const it = Object.assign(
151
+ (name: string, metaOrFn: TestMeta | TestFn, fn?: TestFn) => {
152
+ let meta = typeof metaOrFn === 'function' ? {} : metaOrFn
153
+ let testFn = typeof metaOrFn === 'function' ? metaOrFn : fn!
154
+ registerIt(name, testFn, meta)
155
+ },
156
+ {
157
+ skip: (name: string, fn?: TestFn) => registerIt(name, fn ?? (() => {}), { skip: true }),
158
+ only: (name: string, fn: TestFn) => registerIt(name, fn, { only: true }),
159
+ todo: (name: string) => {
160
+ let suite = currentSuite ?? getImplicitRootSuite()
161
+ if (suite.tests.some((t) => t.name === name)) {
162
+ throw new Error(`Duplicate test name: "${name}" in suite "${suite.name || 'Global'}"`)
163
+ }
164
+ suite.tests.push({ name, fn: () => {}, suite, todo: true })
165
+ },
166
+ },
167
+ )
168
+
169
+ /** Alias for {@link describe}. */
170
+ export const suite = describe
171
+ /** Alias for {@link it}. */
172
+ export const test = it
173
+
174
+ function chainBefore(
175
+ existing: (() => void | Promise<void>) | undefined,
176
+ fn: () => void | Promise<void>,
177
+ ) {
178
+ return existing
179
+ ? async () => {
180
+ await existing()
181
+ await fn()
182
+ }
183
+ : fn
184
+ }
185
+
186
+ function chainAfter(
187
+ existing: (() => void | Promise<void>) | undefined,
188
+ fn: () => void | Promise<void>,
189
+ ) {
190
+ // Child/later runs first, then earlier (reverse order)
191
+ return existing
192
+ ? async () => {
193
+ await fn()
194
+ await existing()
195
+ }
196
+ : fn
197
+ }
198
+
199
+ /**
200
+ * Registers a hook that runs before **each** test in the current suite (or
201
+ * globally if called outside a `describe`). Multiple calls are chained in
202
+ * registration order.
203
+ *
204
+ * @param fn - The setup function to run before each test.
205
+ */
206
+ export function beforeEach(fn: () => void | Promise<void>) {
207
+ let target = currentSuite ?? rootHooks
208
+ target.beforeEach = chainBefore(target.beforeEach, fn)
209
+ }
210
+
211
+ /**
212
+ * Registers a hook that runs after **each** test in the current suite (or
213
+ * globally if called outside a `describe`). Multiple calls are chained in
214
+ * reverse registration order. To run logic after a singular test, use
215
+ * `t.after()` from the {@link TestContext}
216
+ *
217
+ * @param fn - The teardown function to run after each test.
218
+ */
219
+ export function afterEach(fn: () => void | Promise<void>) {
220
+ let target = currentSuite ?? rootHooks
221
+ target.afterEach = chainAfter(target.afterEach, fn)
222
+ }
223
+
224
+ /**
225
+ * Registers a hook that runs once before **all** tests in the current suite
226
+ * (or globally if called outside a `describe`). Multiple calls are chained in
227
+ * registration order.
228
+ *
229
+ * @param fn - The setup function to run once before all tests in the suite.
230
+ */
231
+ export function beforeAll(fn: () => void | Promise<void>) {
232
+ let target = currentSuite ?? rootHooks
233
+ target.beforeAll = chainBefore(target.beforeAll, fn)
234
+ }
235
+
236
+ /**
237
+ * Registers a hook that runs once after **all** tests in the current suite (or
238
+ * globally if called outside a `describe`). Multiple calls are chained in
239
+ * reverse registration order.
240
+ *
241
+ * @param fn - The teardown function to run once after all tests in the suite.
242
+ */
243
+ export function afterAll(fn: () => void | Promise<void>) {
244
+ let target = currentSuite ?? rootHooks
245
+ target.afterAll = chainAfter(target.afterAll, fn)
246
+ }
247
+
248
+ /** Alias for {@link beforeAll} — matches the `node:test` API. */
249
+ export const before = beforeAll
250
+ /** Alias for {@link afterAll} — matches the `node:test` API. */
251
+ export const after = afterAll
@@ -0,0 +1,89 @@
1
+ /** Records the arguments, return value, and any thrown error for a single call. */
2
+ export interface MockCall<Args extends unknown[] = unknown[], Result = unknown> {
3
+ arguments: Args
4
+ result?: Result
5
+ error?: unknown
6
+ }
7
+
8
+ /**
9
+ * Metadata attached to every mock/spy function via its `.mock` property.
10
+ * `restore` is present on spies and reverts the original method when called.
11
+ */
12
+ export interface MockContext<Args extends unknown[] = unknown[], Result = unknown> {
13
+ calls: MockCall<Args, Result>[]
14
+ restore?: () => void
15
+ }
16
+
17
+ /** A function augmented with a `.mock` property for inspecting recorded calls. */
18
+ export type MockFunction<T extends (...args: any[]) => any = (...args: any[]) => any> = T & {
19
+ mock: MockContext<Parameters<T>, ReturnType<T>>
20
+ }
21
+
22
+ function createMockFn<T extends (...args: any[]) => any>(impl?: T): MockFunction<T> {
23
+ let calls: MockCall<Parameters<T>, ReturnType<T>>[] = []
24
+
25
+ let fn = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
26
+ let call: MockCall<Parameters<T>, ReturnType<T>> = { arguments: args }
27
+ calls.push(call)
28
+ if (impl) {
29
+ try {
30
+ let result = impl.apply(this, args)
31
+ call.result = result
32
+ return result
33
+ } catch (error) {
34
+ call.error = error
35
+ throw error
36
+ }
37
+ }
38
+ return undefined as ReturnType<T>
39
+ } as MockFunction<T>
40
+
41
+ fn.mock = { calls }
42
+ return fn
43
+ }
44
+
45
+ function createMethodMock<T extends object, K extends keyof T>(
46
+ obj: T,
47
+ method: K,
48
+ impl?: T[K] extends (...args: any[]) => any ? (...args: Parameters<T[K]>) => any : never,
49
+ ): MockFunction {
50
+ let original = obj[method]
51
+ let effectiveImpl = (impl ?? original) as (...args: any[]) => any
52
+ let mockFn = createMockFn(effectiveImpl)
53
+ obj[method] = mockFn as unknown as T[K]
54
+ mockFn.mock.restore = () => {
55
+ obj[method] = original
56
+ }
57
+ return mockFn
58
+ }
59
+
60
+ /**
61
+ * Utilities for creating mock functions and method spies. Mirrors the names
62
+ * on Node.js's built-in `MockTracker` from `node:test`.
63
+ *
64
+ * @example
65
+ * // Standalone mock
66
+ * const fn = mock.fn((x: number) => x * 2)
67
+ * fn(3)
68
+ * assert.equal(fn.mock.calls[0].result, 6)
69
+ *
70
+ * // Mock an existing method
71
+ * const spy = mock.method(console, 'log')
72
+ * console.log('hello')
73
+ * assert.equal(spy.mock.calls.length, 1)
74
+ * spy.mock.restore?.()
75
+ */
76
+ export const mock = {
77
+ /**
78
+ * Creates a mock function that records every call. If `impl` is provided it
79
+ * is used as the underlying implementation; otherwise the mock returns
80
+ * `undefined`.
81
+ */
82
+ fn: createMockFn,
83
+ /**
84
+ * Replaces `obj[methodName]` with a mock and records every call. The
85
+ * original method is used as the implementation unless `impl` is provided.
86
+ * Call `mockFn.mock.restore()` to revert.
87
+ */
88
+ method: createMethodMock,
89
+ }
@@ -0,0 +1,102 @@
1
+ import * as path from 'node:path'
2
+ import * as fs from 'node:fs/promises'
3
+ import { chromium, firefox, webkit } from 'playwright'
4
+ import type { BrowserContextOptions, LaunchOptions } from 'playwright'
5
+ import type { PlaywrightTestConfig } from 'playwright/test'
6
+ import { tsImport } from 'tsx/esm/api'
7
+
8
+ export type PlaywrightUseOpts = PlaywrightTestConfig['use']
9
+
10
+ export async function loadPlaywrightConfig(
11
+ input: string | undefined,
12
+ ): Promise<PlaywrightTestConfig | undefined> {
13
+ let candidates = input
14
+ ? [path.resolve(process.cwd(), input)]
15
+ : [
16
+ path.join(process.cwd(), 'playwright.config.ts'),
17
+ path.join(process.cwd(), 'playwright.config.js'),
18
+ ]
19
+
20
+ for (let configPath of candidates) {
21
+ try {
22
+ await fs.access(configPath)
23
+ let mod = await tsImport(configPath, { parentURL: import.meta.url })
24
+ return mod.default ?? mod
25
+ } catch {
26
+ // not found or failed to load — try next
27
+ }
28
+ }
29
+ }
30
+
31
+ const launchers = {
32
+ chromium,
33
+ firefox,
34
+ webkit,
35
+ }
36
+
37
+ export function getBrowserLauncher(playwrightUseOpts?: PlaywrightUseOpts) {
38
+ if (playwrightUseOpts?.browserName) {
39
+ let launcher = launchers[playwrightUseOpts.browserName as keyof typeof launchers]
40
+ if (!launcher) {
41
+ let supportedBrowsers = Object.keys(launchers).join(', ')
42
+ throw new Error(
43
+ `Unsupported browser "${playwrightUseOpts.browserName}". ` +
44
+ `Supported browsers are: ${supportedBrowsers}`,
45
+ )
46
+ }
47
+ return launcher
48
+ }
49
+ return chromium
50
+ }
51
+
52
+ export function resolveProjects(
53
+ config?: PlaywrightTestConfig,
54
+ ): Array<{ name?: string; playwrightUseOpts: PlaywrightUseOpts }> {
55
+ if (config?.projects?.length) {
56
+ return config.projects.map((p) => ({
57
+ name: p.name,
58
+ playwrightUseOpts: { ...config.use, ...p.use },
59
+ }))
60
+ }
61
+ return [
62
+ {
63
+ name: 'chromium',
64
+ playwrightUseOpts: config?.use,
65
+ },
66
+ ]
67
+ }
68
+
69
+ export function getPlaywrightLaunchOptions(playwrightUseOpts?: PlaywrightUseOpts): LaunchOptions {
70
+ return {
71
+ headless: playwrightUseOpts?.headless,
72
+ channel: playwrightUseOpts?.channel,
73
+ }
74
+ }
75
+
76
+ export function getPlaywrightPageOptions(
77
+ playwrightUseOpts?: PlaywrightUseOpts,
78
+ ): BrowserContextOptions & { navigationTimeout?: number; actionTimeout?: number } {
79
+ return {
80
+ // Context options passed to browser.newPage()
81
+ bypassCSP: playwrightUseOpts?.bypassCSP,
82
+ colorScheme: playwrightUseOpts?.colorScheme,
83
+ deviceScaleFactor: playwrightUseOpts?.deviceScaleFactor,
84
+ extraHTTPHeaders: playwrightUseOpts?.extraHTTPHeaders,
85
+ geolocation: playwrightUseOpts?.geolocation,
86
+ hasTouch: playwrightUseOpts?.hasTouch,
87
+ httpCredentials: playwrightUseOpts?.httpCredentials,
88
+ ignoreHTTPSErrors: playwrightUseOpts?.ignoreHTTPSErrors,
89
+ isMobile: playwrightUseOpts?.isMobile,
90
+ javaScriptEnabled: playwrightUseOpts?.javaScriptEnabled,
91
+ locale: playwrightUseOpts?.locale,
92
+ offline: playwrightUseOpts?.offline,
93
+ permissions: playwrightUseOpts?.permissions,
94
+ storageState: playwrightUseOpts?.storageState,
95
+ timezoneId: playwrightUseOpts?.timezoneId,
96
+ userAgent: playwrightUseOpts?.userAgent,
97
+ viewport: playwrightUseOpts?.viewport,
98
+ // Additional options set on the page instance
99
+ navigationTimeout: playwrightUseOpts?.navigationTimeout,
100
+ actionTimeout: playwrightUseOpts?.actionTimeout,
101
+ }
102
+ }
@@ -0,0 +1,57 @@
1
+ import { colors, normalizeLine, type Counts } from '../utils.ts'
2
+ import type { TestResult, TestResults } from '../executor.ts'
3
+ import type { Reporter } from './index.ts'
4
+
5
+ export class DotReporter implements Reporter {
6
+ #failures: { name: string; error: TestResult['error'] }[] = []
7
+ #dotCount = 0
8
+
9
+ onSectionStart(_label: string) {}
10
+
11
+ onResult(results: TestResults, _env?: string) {
12
+ for (let test of results.tests) {
13
+ if (test.status === 'passed') {
14
+ process.stdout.write(colors.green('.'))
15
+ } else if (test.status === 'skipped') {
16
+ process.stdout.write(colors.dim('S'))
17
+ } else if (test.status === 'todo') {
18
+ process.stdout.write(colors.dim('T'))
19
+ } else {
20
+ process.stdout.write(colors.red('F'))
21
+ this.#failures.push({ name: `${test.suiteName} > ${test.name}`, error: test.error })
22
+ }
23
+ this.#dotCount++
24
+ }
25
+ }
26
+
27
+ onSummary(counts: Counts, durationMs: number) {
28
+ if (this.#dotCount > 0) console.log()
29
+
30
+ for (let i = 0; i < this.#failures.length; i++) {
31
+ let { name, error } = this.#failures[i]
32
+ console.log(`\n ${colors.red(`${i + 1})`)} ${name}`)
33
+ if (error) {
34
+ console.log(` ${colors.red(error.message)}`)
35
+ if (error.stack) {
36
+ let frames = error.stack
37
+ .split('\n')
38
+ .slice(1, 4)
39
+ .map((l) => ` ${normalizeLine(l).trim()}`)
40
+ .join('\n')
41
+ console.log(frames)
42
+ }
43
+ }
44
+ }
45
+
46
+ let { passed, failed, skipped, todo } = counts
47
+ let info = colors.cyan('ℹ')
48
+ console.log()
49
+ console.log(`${info} tests ${passed + failed + skipped + todo}`)
50
+ console.log(`${info} pass ${passed}`)
51
+ console.log(`${info} fail ${failed}`)
52
+ if (skipped > 0) console.log(`${info} skipped ${skipped}`)
53
+ if (todo > 0) console.log(`${info} todo ${todo}`)
54
+ console.log(`${info} duration_ms ${durationMs.toFixed(5)}`)
55
+ console.log()
56
+ }
57
+ }
@@ -0,0 +1,76 @@
1
+ import * as path from 'node:path'
2
+ import { colors, normalizeLine, type Counts } from '../utils.ts'
3
+ import type { TestResult, TestResults } from '../executor.ts'
4
+ import type { Reporter } from './index.ts'
5
+
6
+ export class FilesReporter implements Reporter {
7
+ #failures: { suiteName: string; name: string; error: TestResult['error'] }[] = []
8
+
9
+ onSectionStart(_label: string) {}
10
+
11
+ onResult(results: TestResults, env?: string) {
12
+ let filePath = results.tests[0]?.filePath
13
+ let fileName = filePath ? path.relative(process.cwd(), filePath) : '(unknown)'
14
+ let envLabel = env ? ` ${colors.dim(`[${env}]`)}` : ''
15
+ let totalDuration = results.tests.reduce((sum, t) => sum + t.duration, 0)
16
+ let hasFailed = results.tests.some((t) => t.status === 'failed')
17
+
18
+ let fileColor = hasFailed ? colors.red : colors.green
19
+ let duration = hasFailed ? '' : ` (${totalDuration.toFixed(2)}ms)`
20
+ console.log(`${colors.dim('▶')} ${fileColor(fileName)}${duration}${envLabel}`)
21
+
22
+ if (hasFailed) {
23
+ // Print failing tests with suite/test nesting using > separators
24
+ for (let test of results.tests) {
25
+ if (test.status !== 'failed') continue
26
+ let fullName = test.name ? `${test.suiteName} > ${test.name}` : test.suiteName
27
+ console.log(` ${colors.red('✗')} ${fullName}`)
28
+ if (test.error) {
29
+ console.log(` ${colors.red(`Error: ${test.error.message}`)}`)
30
+ if (test.error.stack) {
31
+ let stack = test.error.stack
32
+ .split('\n')
33
+ .map((line) => normalizeLine(line))
34
+ .join('\n')
35
+ console.log(` ${stack.split('\n').slice(1, 5).join(`\n `)}`)
36
+ }
37
+ }
38
+ this.#failures.push({ suiteName: test.suiteName, name: test.name, error: test.error })
39
+ }
40
+ }
41
+ }
42
+
43
+ onSummary(counts: Counts, durationMs: number) {
44
+ if (this.#failures.length > 0) {
45
+ console.log()
46
+ console.log(colors.red('Failed tests:'))
47
+ for (let i = 0; i < this.#failures.length; i++) {
48
+ let { suiteName, name, error } = this.#failures[i]
49
+ let fullName = name ? `${suiteName} > ${name}` : suiteName
50
+ console.log(`\n ${colors.red(`${i + 1})`)} ${fullName}`)
51
+ if (error) {
52
+ console.log(` ${colors.red(error.message)}`)
53
+ if (error.stack) {
54
+ let frames = error.stack
55
+ .split('\n')
56
+ .slice(1, 4)
57
+ .map((l) => ` ${normalizeLine(l).trim()}`)
58
+ .join('\n')
59
+ console.log(frames)
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ let { passed, failed, skipped, todo } = counts
66
+ let info = colors.cyan('ℹ')
67
+ console.log()
68
+ console.log(`${info} tests ${passed + failed + skipped + todo}`)
69
+ console.log(`${info} pass ${passed}`)
70
+ console.log(`${info} fail ${failed}`)
71
+ if (skipped > 0) console.log(`${info} skipped ${skipped}`)
72
+ if (todo > 0) console.log(`${info} todo ${todo}`)
73
+ console.log(`${info} duration_ms ${durationMs.toFixed(5)}`)
74
+ console.log()
75
+ }
76
+ }
@@ -0,0 +1,28 @@
1
+ import type { Counts } from '../utils.ts'
2
+ import type { TestResults } from '../executor.ts'
3
+ import { SpecReporter } from './spec.ts'
4
+ import { TapReporter } from './tap.ts'
5
+ import { DotReporter } from './dot.ts'
6
+ import { FilesReporter } from './files.ts'
7
+
8
+ export interface Reporter {
9
+ onResult(results: TestResults, env?: string): void
10
+ onSummary(counts: Counts, durationMs: number): void
11
+ onSectionStart(label: string): void
12
+ }
13
+
14
+ export { SpecReporter, TapReporter, DotReporter, FilesReporter }
15
+
16
+ export function createReporter(type: string): Reporter {
17
+ switch (type) {
18
+ case 'tap':
19
+ return new TapReporter()
20
+ case 'dot':
21
+ return new DotReporter()
22
+ case 'files':
23
+ return new FilesReporter()
24
+ case 'spec':
25
+ default:
26
+ return new SpecReporter()
27
+ }
28
+ }