@livestore/utils-dev 0.4.0-dev.9 → 0.5.0-dev.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 (82) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/node/DockerCompose/DockerCompose.d.ts +52 -0
  3. package/dist/node/DockerCompose/DockerCompose.d.ts.map +1 -0
  4. package/dist/node/DockerCompose/DockerCompose.js +158 -0
  5. package/dist/node/DockerCompose/DockerCompose.js.map +1 -0
  6. package/dist/node/DockerCompose/DockerCompose.test.d.ts +2 -0
  7. package/dist/node/DockerCompose/DockerCompose.test.d.ts.map +1 -0
  8. package/dist/node/{DockerComposeService/DockerComposeService.test.js → DockerCompose/DockerCompose.test.js} +18 -18
  9. package/dist/node/DockerCompose/DockerCompose.test.js.map +1 -0
  10. package/dist/node/DockerCompose/mod.d.ts +2 -0
  11. package/dist/node/DockerCompose/mod.d.ts.map +1 -0
  12. package/dist/node/DockerCompose/mod.js +2 -0
  13. package/dist/node/DockerCompose/mod.js.map +1 -0
  14. package/dist/node/FileLogger.d.ts +15 -8
  15. package/dist/node/FileLogger.d.ts.map +1 -1
  16. package/dist/node/FileLogger.js +64 -80
  17. package/dist/node/FileLogger.js.map +1 -1
  18. package/dist/node/cmd-log.d.ts +21 -0
  19. package/dist/node/cmd-log.d.ts.map +1 -0
  20. package/dist/node/cmd-log.js +58 -0
  21. package/dist/node/cmd-log.js.map +1 -0
  22. package/dist/node/cmd.d.ts +20 -14
  23. package/dist/node/cmd.d.ts.map +1 -1
  24. package/dist/node/cmd.js +248 -33
  25. package/dist/node/cmd.js.map +1 -1
  26. package/dist/node/cmd.test.d.ts +2 -0
  27. package/dist/node/cmd.test.d.ts.map +1 -0
  28. package/dist/node/cmd.test.js +103 -0
  29. package/dist/node/cmd.test.js.map +1 -0
  30. package/dist/node/mod.d.ts +5 -5
  31. package/dist/node/mod.d.ts.map +1 -1
  32. package/dist/node/mod.js +114 -66
  33. package/dist/node/mod.js.map +1 -1
  34. package/dist/node/workspace.d.ts +22 -0
  35. package/dist/node/workspace.d.ts.map +1 -0
  36. package/dist/node/workspace.js +26 -0
  37. package/dist/node/workspace.js.map +1 -0
  38. package/dist/node-vitest/Vitest.d.ts +17 -8
  39. package/dist/node-vitest/Vitest.d.ts.map +1 -1
  40. package/dist/node-vitest/Vitest.js +16 -16
  41. package/dist/node-vitest/Vitest.js.map +1 -1
  42. package/dist/node-vitest/Vitest.test.d.ts +6 -0
  43. package/dist/node-vitest/Vitest.test.d.ts.map +1 -1
  44. package/dist/node-vitest/Vitest.test.js +25 -10
  45. package/dist/node-vitest/Vitest.test.js.map +1 -1
  46. package/dist/wrangler/WranglerDevServer.d.ts +32 -20
  47. package/dist/wrangler/WranglerDevServer.d.ts.map +1 -1
  48. package/dist/wrangler/WranglerDevServer.js +79 -60
  49. package/dist/wrangler/WranglerDevServer.js.map +1 -1
  50. package/dist/wrangler/WranglerDevServer.test.js +31 -31
  51. package/dist/wrangler/WranglerDevServer.test.js.map +1 -1
  52. package/dist/wrangler/mod.d.ts +1 -1
  53. package/dist/wrangler/mod.d.ts.map +1 -1
  54. package/dist/wrangler/mod.js +1 -1
  55. package/dist/wrangler/mod.js.map +1 -1
  56. package/package.json +56 -21
  57. package/src/node/{DockerComposeService/DockerComposeService.test.ts → DockerCompose/DockerCompose.test.ts} +23 -20
  58. package/src/node/DockerCompose/DockerCompose.ts +328 -0
  59. package/src/node/DockerCompose/mod.ts +1 -0
  60. package/src/node/DockerCompose/test-fixtures/docker-compose.yml +4 -0
  61. package/src/node/FileLogger.ts +91 -115
  62. package/src/node/cmd-log.ts +95 -0
  63. package/src/node/cmd.test.ts +136 -0
  64. package/src/node/cmd.ts +431 -82
  65. package/src/node/mod.ts +152 -96
  66. package/src/node/workspace.ts +45 -0
  67. package/src/node-vitest/Vitest.test.ts +34 -10
  68. package/src/node-vitest/Vitest.ts +60 -66
  69. package/src/wrangler/WranglerDevServer.test.ts +38 -40
  70. package/src/wrangler/WranglerDevServer.ts +165 -108
  71. package/src/wrangler/fixtures/wrangler.toml +1 -1
  72. package/src/wrangler/mod.ts +1 -6
  73. package/dist/.tsbuildinfo.json +0 -1
  74. package/dist/node/DockerComposeService/DockerComposeService.d.ts +0 -58
  75. package/dist/node/DockerComposeService/DockerComposeService.d.ts.map +0 -1
  76. package/dist/node/DockerComposeService/DockerComposeService.js +0 -144
  77. package/dist/node/DockerComposeService/DockerComposeService.js.map +0 -1
  78. package/dist/node/DockerComposeService/DockerComposeService.test.d.ts +0 -2
  79. package/dist/node/DockerComposeService/DockerComposeService.test.d.ts.map +0 -1
  80. package/dist/node/DockerComposeService/DockerComposeService.test.js.map +0 -1
  81. package/src/node/DockerComposeService/DockerComposeService.ts +0 -328
  82. package/src/node/DockerComposeService/test-fixtures/docker-compose.yml +0 -4
@@ -1,47 +1,8 @@
1
- import * as fs from 'node:fs'
2
- import path from 'node:path'
3
1
  import util from 'node:util'
4
- import {
5
- Cause,
6
- Effect,
7
- FiberId,
8
- HashMap,
9
- Inspectable,
10
- Layer,
11
- List,
12
- Logger,
13
- type LogLevel,
14
- LogSpan,
15
- ReadonlyArray,
16
- } from '@livestore/utils/effect'
17
-
18
- export const makeFileLogger = (
19
- logFilePath: string,
20
- options?: {
21
- readonly threadName: string
22
- readonly colors?: boolean
23
- },
24
- ) =>
25
- Layer.unwrapScoped(
26
- Effect.gen(function* () {
27
- yield* Effect.sync(() => fs.mkdirSync(path.dirname(logFilePath), { recursive: true }))
28
-
29
- const logFile = yield* Effect.acquireRelease(
30
- Effect.sync(() => fs.openSync(logFilePath, 'a', 0o666)),
31
- (fd) => Effect.sync(() => fs.closeSync(fd)),
32
- )
33
-
34
- return Logger.replace(
35
- Logger.defaultLogger,
36
- prettyLoggerTty({
37
- colors: options?.colors ?? false,
38
- stderr: false,
39
- formatDate: (date) => `${defaultDateFormat(date)} ${options?.threadName ?? ''}`,
40
- onLog: (str) => fs.writeSync(logFile, str),
41
- }),
42
- )
43
- }),
44
- )
2
+
3
+ import { Redactable } from 'effect'
4
+
5
+ import { Cause, Inspectable, Logger, type LogLevel, References, ReadonlyArray } from '@livestore/utils/effect'
45
6
 
46
7
  const withColor = (text: string, ...colors: ReadonlyArray<string>) => {
47
8
  let out = ''
@@ -65,18 +26,18 @@ const colors = {
65
26
  bgBrightRed: '101',
66
27
  } as const
67
28
 
68
- const logLevelColors: Record<LogLevel.LogLevel['_tag'], ReadonlyArray<string>> = {
29
+ const logLevelColors: Record<LogLevel.LogLevel, ReadonlyArray<string>> = {
69
30
  None: [],
70
31
  All: [],
71
32
  Trace: [colors.gray],
72
33
  Debug: [colors.blue],
73
34
  Info: [colors.green],
74
- Warning: [colors.yellow],
35
+ Warn: [colors.yellow],
75
36
  Error: [colors.red],
76
37
  Fatal: [colors.bgBrightRed, colors.black],
77
38
  }
78
39
 
79
- export const defaultDateFormat = (date: Date): string =>
40
+ export const formatLogTime = (date: Date): string =>
80
41
  `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date
81
42
  .getSeconds()
82
43
  .toString()
@@ -90,7 +51,7 @@ export const structuredMessage = (u: unknown): unknown => {
90
51
  return String(u)
91
52
  }
92
53
  default: {
93
- return Inspectable.toJSON(u)
54
+ return Inspectable.toJson(u)
94
55
  }
95
56
  }
96
57
  }
@@ -114,93 +75,108 @@ const consoleLogToString = (...s: any[]) => {
114
75
  .join(' ')
115
76
  }
116
77
 
117
- export const prettyLoggerTty = (options: {
78
+ type PrettyLoggerOptions = {
118
79
  readonly colors: boolean
119
80
  readonly stderr: boolean
120
81
  readonly formatDate: (date: Date) => string
121
82
  readonly onLog?: (str: string) => void
83
+ }
84
+
85
+ export const formatPrettyLog = ({
86
+ annotations,
87
+ cause,
88
+ date,
89
+ fiberId,
90
+ logLevel,
91
+ message: message_,
92
+ options,
93
+ spans,
94
+ }: {
95
+ readonly annotations: Readonly<Record<string, unknown>>
96
+ readonly cause: Cause.Cause<unknown>
97
+ readonly date: Date
98
+ readonly fiberId: number
99
+ readonly logLevel: LogLevel.LogLevel
100
+ readonly message: unknown
101
+ readonly options: PrettyLoggerOptions
102
+ readonly spans: ReadonlyArray<readonly [label: string, timestamp: number]>
122
103
  }) => {
123
- const color = options.colors ? withColor : withColorNoop
124
- return Logger.make<unknown, string>(({ annotations, cause, date, fiberId, logLevel, message: message_, spans }) => {
125
- let str = ''
104
+ const color = options.colors === true ? withColor : withColorNoop
105
+ let str = ''
126
106
 
127
- const log = (...s: any[]) => {
128
- str += `${consoleLogToString(...s)}\n`
129
- options.onLog?.(str)
130
- }
107
+ const log = (...s: any[]) => {
108
+ str += `${consoleLogToString(...s)}\n`
109
+ options.onLog?.(str)
110
+ }
131
111
 
132
- const logIndented = (...s: any[]) => {
133
- str += `${consoleLogToString(...s).replace(/^/gm, ' ')}\n`
134
- options.onLog?.(str)
135
- }
112
+ const logIndented = (...s: any[]) => {
113
+ str += `${consoleLogToString(...s).replace(/^/gm, ' ')}\n`
114
+ options.onLog?.(str)
115
+ }
136
116
 
137
- const message = ReadonlyArray.ensure(message_)
117
+ const message = ReadonlyArray.ensure(message_)
138
118
 
139
- let firstLine =
140
- color(`[${options.formatDate(date)}]`, colors.white) +
141
- ` ${color(logLevel.label, ...logLevelColors[logLevel._tag])}` +
142
- ` (${FiberId.threadName(fiberId)})`
119
+ let firstLine =
120
+ color(`[${options.formatDate(date)}]`, colors.white) +
121
+ ` ${color(logLevel.toUpperCase(), ...logLevelColors[logLevel])}` +
122
+ ` (#${fiberId})`
143
123
 
144
- if (List.isCons(spans)) {
145
- const now = date.getTime()
146
- const render = LogSpan.render(now)
147
- for (const span of spans) {
148
- firstLine += ` ${render(span)}`
149
- }
124
+ if (spans.length > 0) {
125
+ const now = date.getTime()
126
+ for (const [label, timestamp] of spans) {
127
+ firstLine += ` ${label}=${now - timestamp}ms`
150
128
  }
129
+ }
151
130
 
152
- firstLine += ':'
153
- let messageIndex = 0
154
- if (message.length > 0) {
155
- const firstMaybeString = structuredMessage(message[0])
156
- if (typeof firstMaybeString === 'string') {
157
- firstLine += ` ${color(firstMaybeString, colors.bold, colors.cyan)}`
158
- messageIndex++
159
- }
131
+ firstLine += ':'
132
+ let messageIndex = 0
133
+ if (message.length > 0) {
134
+ const firstMaybeString = structuredMessage(message[0])
135
+ if (typeof firstMaybeString === 'string') {
136
+ firstLine += ` ${color(firstMaybeString, colors.bold, colors.cyan)}`
137
+ messageIndex++
160
138
  }
139
+ }
161
140
 
162
- log(firstLine)
163
- // if (!processIsBun) console.group()
141
+ log(firstLine)
142
+ // if (!processIsBun) console.group()
164
143
 
165
- if (!Cause.isEmpty(cause)) {
166
- logIndented(Cause.pretty(cause, { renderErrorCause: true }))
167
- }
144
+ if (cause.reasons.length > 0) {
145
+ logIndented(Cause.pretty(cause))
146
+ }
168
147
 
169
- if (messageIndex < message.length) {
170
- for (; messageIndex < message.length; messageIndex++) {
171
- const msg = message[messageIndex]
172
- if (typeof msg === 'object' && msg !== null) {
173
- logIndented(
174
- util.inspect(structuredMessage(msg), {
175
- depth: 3,
176
- colors: false,
177
- compact: false,
178
- breakLength: 120,
179
- }),
180
- )
181
- } else {
182
- logIndented(Inspectable.redact(msg))
183
- }
148
+ if (messageIndex < message.length) {
149
+ for (; messageIndex < message.length; messageIndex++) {
150
+ const msg = message[messageIndex]
151
+ if (typeof msg === 'object' && msg !== null) {
152
+ logIndented(
153
+ util.inspect(structuredMessage(msg), {
154
+ depth: 3,
155
+ colors: false,
156
+ compact: false,
157
+ breakLength: 120,
158
+ }),
159
+ )
160
+ } else {
161
+ logIndented(Redactable.redact(msg))
184
162
  }
185
163
  }
164
+ }
186
165
 
187
- if (HashMap.size(annotations) > 0) {
188
- for (const [key, value] of annotations) {
189
- const formattedValue =
190
- typeof value === 'object' && value !== null
191
- ? util.inspect(structuredMessage(value), {
192
- depth: 3,
193
- colors: false,
194
- compact: false,
195
- breakLength: 120,
196
- })
197
- : Inspectable.redact(value)
198
- logIndented(color(`${key}:`, colors.bold, colors.white), formattedValue)
199
- }
200
- }
166
+ for (const [key, value] of Object.entries(annotations)) {
167
+ const formattedValue =
168
+ typeof value === 'object' && value !== null
169
+ ? util.inspect(structuredMessage(value), {
170
+ depth: 3,
171
+ colors: false,
172
+ compact: false,
173
+ breakLength: 120,
174
+ })
175
+ : Redactable.redact(value)
176
+ logIndented(color(`${key}:`, colors.bold, colors.white), formattedValue)
177
+ }
201
178
 
202
- // if (!processIsBun) console.groupEnd()
179
+ // if (!processIsBun) console.groupEnd()
203
180
 
204
- return str
205
- })
181
+ return str
206
182
  }
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { Cause, Effect, Predicate } from '@livestore/utils/effect'
5
+
6
+ export type TCmdLoggingOptions = {
7
+ readonly logDir?: string
8
+ readonly logFileName?: string
9
+ readonly logRetention?: number
10
+ }
11
+
12
+ /**
13
+ * Prepares logging directories, archives previous canonical log and prunes archives.
14
+ * Returns the canonical current log path if logging is enabled, otherwise undefined.
15
+ */
16
+ export const prepareCmdLogging: (options: TCmdLoggingOptions) => Effect.Effect<string | undefined> = Effect.fn(
17
+ 'cmd.logging.prepare',
18
+ )(function* ({ logDir, logFileName = 'dev.log', logRetention = 50 }: TCmdLoggingOptions) {
19
+ if (logDir == null || logDir === '') return undefined as string | undefined
20
+
21
+ const logsDir = logDir
22
+ const archiveDir = path.join(logsDir, 'archive')
23
+ const currentLogPath = path.join(logsDir, logFileName)
24
+
25
+ // Ensure directories exist
26
+ yield* Effect.sync(() => fs.mkdirSync(archiveDir, { recursive: true }))
27
+
28
+ // Archive previous log if present
29
+ if (fs.existsSync(currentLogPath) === true) {
30
+ const safeIso = new Date().toISOString().replaceAll(':', '-')
31
+ const archivedBase = `${path.parse(logFileName).name}-${safeIso}.log`
32
+ const archivedLog = path.join(archiveDir, archivedBase)
33
+ yield* Effect.try({
34
+ try: () => fs.renameSync(currentLogPath, archivedLog),
35
+ catch: (cause) => new Cause.UnknownError(cause),
36
+ }).pipe(
37
+ Effect.catch(() =>
38
+ Effect.try({
39
+ try: () => {
40
+ fs.copyFileSync(currentLogPath, archivedLog)
41
+ fs.truncateSync(currentLogPath, 0)
42
+ },
43
+ catch: (cause) => new Cause.UnknownError(cause),
44
+ }),
45
+ ),
46
+ Effect.ignore,
47
+ )
48
+
49
+ // Prune archives to retain only the newest N
50
+ yield* Effect.try({
51
+ try: () => fs.readdirSync(archiveDir),
52
+ catch: (cause) => new Cause.UnknownError(cause),
53
+ }).pipe(
54
+ Effect.map((names) => names.filter((n) => n.endsWith('.log'))),
55
+ Effect.map((names) =>
56
+ names
57
+ .map((name) => ({ name, mtimeMs: fs.statSync(path.join(archiveDir, name)).mtimeMs }))
58
+ .sort((a, b) => b.mtimeMs - a.mtimeMs),
59
+ ),
60
+ Effect.flatMap((entries) =>
61
+ Effect.forEach(entries.slice(logRetention), (e) =>
62
+ Effect.try({
63
+ try: () => fs.unlinkSync(path.join(archiveDir, e.name)),
64
+ catch: (cause) => new Cause.UnknownError(cause),
65
+ }).pipe(Effect.ignore),
66
+ ),
67
+ ),
68
+ Effect.ignore,
69
+ )
70
+ }
71
+
72
+ return currentLogPath
73
+ })
74
+
75
+ /**
76
+ * Given a command input, applies logging by piping output through `tee` to the
77
+ * canonical log file. Returns the transformed input and whether a shell is required.
78
+ */
79
+ export const applyLoggingToCommand: (
80
+ commandInput: string | (string | undefined)[],
81
+ options: TCmdLoggingOptions,
82
+ ) => Effect.Effect<{ input: string | string[]; subshell: boolean; logPath?: string }> = Effect.fn('cmd.logging.apply')(
83
+ function* (commandInput, options) {
84
+ const asArray = Array.isArray(commandInput)
85
+ const parts = asArray === true ? commandInput.filter(Predicate.isNotUndefined) : undefined
86
+
87
+ const logPath = yield* prepareCmdLogging(options)
88
+
89
+ return {
90
+ input: asArray === true ? ((parts as string[]) ?? []) : commandInput,
91
+ subshell: false,
92
+ ...(logPath !== undefined ? { logPath } : {}),
93
+ }
94
+ },
95
+ )
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { expect } from 'vitest'
5
+
6
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
7
+ import { ChildProcessSpawner, Duration, Effect, Layer } from '@livestore/utils/effect'
8
+ import { PlatformNode } from '@livestore/utils/node'
9
+
10
+ import { cmd } from './cmd.ts'
11
+ import { CurrentWorkingDirectory } from './workspace.ts'
12
+
13
+ const withNode = Vitest.makeWithTestCtx({
14
+ makeLayer: () => Layer.mergeAll(PlatformNode.NodeServices.layer, CurrentWorkingDirectory.live),
15
+ timeout: 20_000,
16
+ })
17
+
18
+ Vitest.describe('cmd helper', () => {
19
+ const ansiRegex = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g')
20
+
21
+ Vitest.live('runs tokenized string without shell', (test) =>
22
+ Effect.gen(function* () {
23
+ const exit = yield* cmd('printf ok')
24
+ expect(exit).toBe(ChildProcessSpawner.ExitCode(0))
25
+ }).pipe(withNode(test)),
26
+ )
27
+
28
+ Vitest.live('runs array input', (test) =>
29
+ Effect.gen(function* () {
30
+ const exit = yield* cmd(['printf', 'ok'])
31
+ expect(exit).toBe(ChildProcessSpawner.ExitCode(0))
32
+ }).pipe(withNode(test)),
33
+ )
34
+
35
+ /** TODO(#1020): Investigate CI timeout flakiness for cmd tests that spawn many child processes */
36
+ Vitest.live(
37
+ 'supports logging with archive + retention',
38
+ (test) =>
39
+ Effect.gen(function* () {
40
+ const workspace = process.env.WORKSPACE_ROOT!
41
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', String(Date.now()))
42
+
43
+ // first run
44
+ const exit1 = yield* cmd('printf first', { logDir: logsDir })
45
+ expect(exit1).toBe(ChildProcessSpawner.ExitCode(0))
46
+ const current = path.join(logsDir, 'dev.log')
47
+ expect(fs.existsSync(current)).toBe(true)
48
+ const firstLog = fs.readFileSync(current, 'utf8')
49
+ const firstStdoutLines = firstLog.split('\n').filter((line) => line.includes('[stdout]'))
50
+ expect(firstStdoutLines.length).toBeGreaterThan(0)
51
+ for (const line of firstStdoutLines) {
52
+ expect(line).toContain('[stdout] first')
53
+ expect(line).toMatch(/Info/i)
54
+ expect(line).toContain('printf first')
55
+ }
56
+
57
+ // second run — archives previous
58
+ const exit2 = yield* cmd('printf second', { logDir: logsDir })
59
+ expect(exit2).toBe(ChildProcessSpawner.ExitCode(0))
60
+ const archiveDir = path.join(logsDir, 'archive')
61
+ const archives = fs.readdirSync(archiveDir).filter((f) => f.endsWith('.log'))
62
+ expect(archives.length).toBe(1)
63
+ const archivedPath = path.join(archiveDir, archives[0]!)
64
+ const archivedLog = fs.readFileSync(archivedPath, 'utf8')
65
+ const archivedStdoutLines = archivedLog.split('\n').filter((line) => line.includes('[stdout]'))
66
+ expect(archivedStdoutLines.length).toBeGreaterThan(0)
67
+ for (const line of archivedStdoutLines) {
68
+ expect(line).toContain('[stdout] first')
69
+ }
70
+
71
+ const secondLog = fs.readFileSync(current, 'utf8')
72
+ const secondStdoutLines = secondLog.split('\n').filter((line) => line.includes('[stdout]'))
73
+ expect(secondStdoutLines.length).toBeGreaterThan(0)
74
+ for (const line of secondStdoutLines) {
75
+ expect(line).toContain('[stdout] second')
76
+ expect(line).toMatch(/Info/i)
77
+ }
78
+
79
+ // generate many archives to exercise retention (keep 50)
80
+ for (let i = 0; i < 60; i++) {
81
+ // Use small unique payloads
82
+ yield* cmd(['printf', String(i)], { logDir: logsDir })
83
+ }
84
+ const archivesAfter = fs.readdirSync(archiveDir).filter((f) => f.endsWith('.log'))
85
+ expect(archivesAfter.length).toBeLessThanOrEqual(50)
86
+ }).pipe(withNode(test)),
87
+ { timeout: 30_000, retry: 3 },
88
+ )
89
+
90
+ Vitest.live('streams stdout and stderr with logger formatting', (test) =>
91
+ Effect.gen(function* () {
92
+ const workspace = process.env.WORKSPACE_ROOT!
93
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', `format-${Date.now()}`)
94
+
95
+ const exit = yield* cmd(['node', '-e', "console.log('out'); console.error('err')"], {
96
+ logDir: logsDir,
97
+ })
98
+ expect(exit).toBe(ChildProcessSpawner.ExitCode(0))
99
+
100
+ const current = path.join(logsDir, 'dev.log')
101
+ const logContent = fs.readFileSync(current, 'utf8')
102
+ expect(logContent).toMatch(/\[stdout] out/)
103
+ expect(logContent).toMatch(/\[stderr] err/)
104
+
105
+ const relevantLines = logContent
106
+ .split('\n')
107
+ .map((line) => line.trim())
108
+ .filter((line) => line.includes('[stdout]') || line.includes('[stderr]'))
109
+
110
+ expect(relevantLines.length).toBeGreaterThanOrEqual(2)
111
+
112
+ for (const line of relevantLines) {
113
+ const stripped = line.replace(ansiRegex, '')
114
+ expect(stripped.startsWith('[')).toBe(true)
115
+ expect(stripped).toMatch(/(Info|Warn)/i)
116
+ expect(stripped).toMatch(/\[(stdout|stderr)]/)
117
+ }
118
+ }).pipe(withNode(test)),
119
+ )
120
+
121
+ Vitest.live('cleans up logged child process when interrupted', (test) =>
122
+ Effect.gen(function* () {
123
+ const workspace = process.env.WORKSPACE_ROOT!
124
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', `timeout-${Date.now()}`)
125
+
126
+ const result = yield* cmd(['node', '-e', 'setTimeout(() => {}, 5000)'], {
127
+ logDir: logsDir,
128
+ stdout: 'pipe',
129
+ stderr: 'pipe',
130
+ }).pipe(Effect.timeoutOption(Duration.millis(200)))
131
+
132
+ expect(result._tag).toBe('None')
133
+ expect(fs.existsSync(path.join(logsDir, 'dev.log'))).toBe(true)
134
+ }).pipe(withNode(test)),
135
+ )
136
+ })