@stacksjs/defaults 0.75.61 → 0.75.63

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.
@@ -1,166 +0,0 @@
1
- /**
2
- * Env file support (src/env.ts, src/env-file.ts). Values are encrypted with
3
- * `@stacksjs/env`'s own `encryptValue`, so the round trip is the real one.
4
- */
5
- import { describe, expect, it } from 'bun:test'
6
- import { encryptValue, generateKeypair } from '../../../../core/env/src/crypto'
7
- import { createEnvSupport, projectDecryptor } from '../src/env'
8
- import { decryptEnvValue, envEntryAt, findPrivateKey, isEnvFile, missingKeys } from '../src/env-file'
9
- import { loadProjectEnv } from '../src/project'
10
- import { document, fakeVscode, flush, Position } from './fake-vscode'
11
-
12
- const ROOT = '/work/app'
13
- const { publicKey, privateKey } = generateKeypair()
14
- const other = generateKeypair()
15
- const SECRET = 's3cret value with spaces'
16
- const ENCRYPTED = encryptValue(SECRET, publicKey)
17
-
18
- describe('env files', () => {
19
- it('knows env files from the private key file', () => {
20
- expect(['.env', '.env.local', '.env.production', '.env.example'].every(name => isEnvFile(`${ROOT}/${name}`))).toBeTrue()
21
- expect(isEnvFile(`${ROOT}/.env.keys`)).toBeFalse()
22
- expect(isEnvFile(`${ROOT}/env.ts`)).toBeFalse()
23
- })
24
-
25
- it('finds the value on a line, quotes excluded', () => {
26
- expect(envEntryAt('export API_KEY="abc"')).toEqual({ key: 'API_KEY', value: 'abc', start: 16, end: 19 })
27
- expect(envEntryAt('PORT=3000 # dev')).toEqual({ key: 'PORT', value: '3000', start: 5, end: 9 })
28
- expect(envEntryAt('# just a comment')).toBeUndefined()
29
- })
30
-
31
- it('lists the keys .env.example has and .env does not', () => {
32
- expect(missingKeys('APP_NAME=\nAPP_KEY=\n# note\nDB_HOST=', 'APP_NAME=Stacks\nexport DB_HOST=x')).toEqual(['APP_KEY'])
33
- })
34
- })
35
-
36
- describe('private keys', () => {
37
- it('looks them up the way ./buddy env:get does: the file\'s key, then the plain one, .env.keys before the environment', () => {
38
- const keys = 'DOTENV_PRIVATE_KEY=plain\nDOTENV_PRIVATE_KEY_PRODUCTION=prod'
39
- expect(findPrivateKey(`${ROOT}/.env.production`, keys, {})).toEqual({ name: 'DOTENV_PRIVATE_KEY_PRODUCTION', key: 'prod', from: '.env.keys' })
40
- expect(findPrivateKey(`${ROOT}/.env.staging`, keys, {})).toEqual({ name: 'DOTENV_PRIVATE_KEY', key: 'plain', from: '.env.keys' })
41
- expect(findPrivateKey(`${ROOT}/.env.production`, undefined, { DOTENV_PRIVATE_KEY_PRODUCTION: 'ci' })).toEqual({ name: 'DOTENV_PRIVATE_KEY_PRODUCTION', key: 'ci', from: 'environment' })
42
- expect(findPrivateKey(`${ROOT}/.env`, undefined, {})).toBeUndefined()
43
- })
44
-
45
- it('decrypts with @stacksjs/env, and says why when it cannot', () => {
46
- expect(decryptEnvValue(`${ROOT}/.env`, ENCRYPTED, `DOTENV_PRIVATE_KEY=${privateKey}`, {})).toMatchObject({ ok: true, value: SECRET })
47
- expect(decryptEnvValue(`${ROOT}/.env`, ENCRYPTED, undefined, {})).toEqual({ ok: false, reason: 'no-key', looked: ['DOTENV_PRIVATE_KEY'] })
48
- expect(decryptEnvValue(`${ROOT}/.env`, ENCRYPTED, `DOTENV_PRIVATE_KEY=${other.privateKey}`, {})).toMatchObject({ ok: false, reason: 'failed' })
49
- })
50
-
51
- it('lets the preview read an encrypted APP_URL, and still skips one it cannot decrypt', () => {
52
- const files: Record<string, string> = {
53
- [`${ROOT}/.env`]: `APP_URL=${ENCRYPTED}\nPORT=${encryptValue('3100', other.publicKey)}`,
54
- [`${ROOT}/.env.keys`]: `DOTENV_PRIVATE_KEY=${privateKey}`,
55
- }
56
- const host = { exists: (path: string) => path in files, read: (path: string) => files[path], exec: async () => '', environment: {} }
57
- const env = loadProjectEnv(file => files[`${ROOT}/${file}`], ['.env'], projectDecryptor(host, ROOT))
58
- expect(env).toEqual({ APP_URL: SECRET })
59
- })
60
- })
61
-
62
- function setup(files: Record<string, string>, settings: Record<string, unknown> = {}) {
63
- const host = {
64
- exists: (path: string) => path in files,
65
- read: (path: string) => files[path],
66
- execs: [] as string[][],
67
- async exec(file: string, args: string[], cwd: string) {
68
- host.execs.push([file, ...args, `(in ${cwd})`])
69
- return 'the value\n'
70
- },
71
- environment: {},
72
- }
73
- const fake = fakeVscode({ folders: [ROOT], settings })
74
- const support = createEnvSupport(fake.api as any, host)
75
- support.activate(fake.context)
76
- return { ...fake, host }
77
- }
78
-
79
- describe('hover', () => {
80
- const line = `API_KEY="${ENCRYPTED}"`
81
- const files = { [`${ROOT}/buddy`]: '', [`${ROOT}/.env.keys`]: `DOTENV_PRIVATE_KEY=${privateKey}` }
82
-
83
- it('shows an encrypted value decrypted, and names the key it used', () => {
84
- const { hovers } = setup(files)
85
- const hover = hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 20))
86
-
87
- expect(hover.contents.value).toContain(SECRET)
88
- expect(hover.contents.value).toContain('`DOTENV_PRIVATE_KEY` from `.env.keys`')
89
- expect(hover.range.start.character).toBe(9)
90
- })
91
-
92
- it('explains a value it has no key for', () => {
93
- const { hovers } = setup({ [`${ROOT}/buddy`]: '' })
94
- const hover = hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 20))
95
- expect(hover.contents.value).toContain('No private key')
96
- expect(hover.contents.value).not.toContain(SECRET)
97
- })
98
-
99
- it('stays quiet on plaintext, on the key name, in other files and when turned off', () => {
100
- expect(setup(files).hovers[0].provideHover(document(`${ROOT}/.env`, 'PORT=3000', 'dotenv'), new Position(0, 6))).toBeUndefined()
101
- expect(setup(files).hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 2))).toBeUndefined()
102
- expect(setup(files).hovers[0].provideHover(document(`${ROOT}/app/x.ts`, line), new Position(0, 20))).toBeUndefined()
103
- expect(setup(files, { 'env.decryptOnHover': false }).hovers[0].provideHover(document(`${ROOT}/.env`, line, 'dotenv'), new Position(0, 20))).toBeUndefined()
104
- })
105
- })
106
-
107
- describe('missing keys', () => {
108
- it('flags keys .env.example declares that .env does not set', async () => {
109
- const { diagnostics, events } = setup({ [`${ROOT}/.env.example`]: 'APP_NAME=\nAPP_KEY=\n' })
110
- events.opened.fire(document(`${ROOT}/.env`, 'APP_NAME=Stacks\n', 'dotenv') as any)
111
- await flush()
112
-
113
- const list = diagnostics.get(`file://${ROOT}/.env`)!
114
- expect(list.map((diagnostic: any) => diagnostic.message)).toEqual(['APP_KEY is in .env.example but not set here.'])
115
- expect(list[0].severity).toBe(2)
116
- })
117
-
118
- it('does not compare other env files, which legitimately differ', async () => {
119
- const { diagnostics, events } = setup({ [`${ROOT}/.env.example`]: 'APP_KEY=\n' })
120
- events.opened.fire(document(`${ROOT}/.env.production`, '', 'dotenv') as any)
121
- await flush()
122
- expect(diagnostics.get(`file://${ROOT}/.env.production`)).toBeUndefined()
123
- })
124
- })
125
-
126
- describe('buddy env commands', () => {
127
- const files = { [`${ROOT}/buddy`]: '', [`${ROOT}/.env`]: 'APP_NAME=Stacks\nAPI_KEY=x\n' }
128
-
129
- it('sets a value through ./buddy env:set, without a shell, from a password box', async () => {
130
- const fake = setup(files)
131
- fake.answers.push('API_KEY', 'hunter2')
132
- await fake.handlers.get('stacks.env.set')!()
133
-
134
- expect(fake.host.execs).toEqual([[`${ROOT}/buddy`, 'env:set', 'API_KEY', 'hunter2', '--file', '.env', `(in ${ROOT})`]])
135
- expect(fake.inputs[1].password).toBeTrue()
136
- })
137
-
138
- it('gets a value through ./buddy env:get and copies it only when asked', async () => {
139
- const fake = setup(files)
140
- fake.answers.push('API_KEY', 'Copy Value')
141
- await fake.handlers.get('stacks.env.get')!()
142
-
143
- expect(fake.host.execs[0]!.slice(1, 5)).toEqual(['env:get', 'API_KEY', '--file', '.env'])
144
- expect(fake.messages[0]).not.toContain('the value')
145
- expect(fake.clipboard).toEqual(['the value'])
146
- })
147
-
148
- it('asks before writing plaintext to disk', async () => {
149
- const fake = setup(files)
150
- fake.answers.push(undefined)
151
- await fake.handlers.get('stacks.env.decrypt')!()
152
- expect(fake.host.execs).toEqual([])
153
- expect(fake.messages[0]).toContain('plaintext')
154
-
155
- fake.answers.push('Continue', undefined)
156
- await fake.handlers.get('stacks.env.decrypt')!()
157
- expect(fake.host.execs[0]!.slice(1, 4)).toEqual(['env:decrypt', '--file', '.env'])
158
- })
159
-
160
- it('acts on the env file that is open', async () => {
161
- const fake = setup({ ...files, [`${ROOT}/.env.production`]: 'APP_KEY=x\n' })
162
- fake.api.window.activeTextEditor = { document: document(`${ROOT}/.env.production`, '', 'dotenv') }
163
- await fake.handlers.get('stacks.env.encrypt')!()
164
- expect(fake.host.execs[0]!.slice(1, 4)).toEqual(['env:encrypt', '--file', '.env.production'])
165
- })
166
- })
@@ -1,428 +0,0 @@
1
- /**
2
- * The controller in src/stacks.ts, driven through a fake `vscode` API and a
3
- * fake host - no editor, processes or sockets. The real `vscode` module only
4
- * exists inside the editor, which is why the controller takes the API as an
5
- * argument instead of importing it.
6
- */
7
- import type { Host } from '../src/stacks'
8
- import { describe, expect, it } from 'bun:test'
9
- import { BUDDY_TERMINAL, COMMANDS, createStacksExtension, DEV_TERMINAL } from '../src/stacks'
10
-
11
- type Listener<T> = (event: T) => unknown
12
-
13
- function emitter<T>() {
14
- const listeners = new Set<Listener<T>>()
15
- const event = (listener: Listener<T>) => {
16
- listeners.add(listener)
17
- return { dispose: () => listeners.delete(listener) }
18
- }
19
- const fire = (value: T) => {
20
- for (const listener of [...listeners]) listener(value)
21
- }
22
- return { event, fire }
23
- }
24
-
25
- interface FakeTerminal {
26
- name: string
27
- exitStatus: { code: number | undefined } | undefined
28
- shellIntegration: any
29
- options: any
30
- sent: string[]
31
- executions: unknown[]
32
- shown: number
33
- show: () => void
34
- sendText: (text: string) => void
35
- dispose: () => void
36
- }
37
-
38
- interface Options {
39
- root?: string
40
- files?: Record<string, string>
41
- settings?: Record<string, unknown>
42
- open?: string[]
43
- listJson?: string
44
- listText?: string
45
- answers?: Array<string | undefined>
46
- shellIntegration?: boolean
47
- noBuddy?: boolean
48
- }
49
-
50
- function setup(options: Options = {}) {
51
- const root = options.root ?? '/work/app'
52
- const files: Record<string, string> = { ...(options.noBuddy ? {} : { [`${root}/buddy`]: '#!/bin/sh' }), ...options.files }
53
- const settings: Record<string, unknown> = options.settings ?? {}
54
- const answers = [...(options.answers ?? [])]
55
-
56
- const executed: Array<{ command: string, args: unknown[] }> = []
57
- const handlers = new Map<string, (...args: any[]) => any>()
58
- const messages: string[] = []
59
- const terminals: FakeTerminal[] = []
60
- const execs: string[][] = []
61
- const pickers: any[] = []
62
- const inputs: any[] = []
63
-
64
- const closeTerminal = emitter<FakeTerminal>()
65
- const shellIntegrationChanged = emitter<any>()
66
- const executionEnded = emitter<any>()
67
-
68
- const message = (text: string) => {
69
- messages.push(text)
70
- return Promise.resolve(answers.shift())
71
- }
72
-
73
- const api = {
74
- Uri: { parse: (value: string) => ({ value, toString: () => value }) },
75
- ViewColumn: { Active: -1, Beside: -2, One: 1 },
76
- StatusBarAlignment: { Left: 1, Right: 2 },
77
- commands: {
78
- registerCommand: (id: string, handler: (...args: any[]) => any) => {
79
- handlers.set(id, handler)
80
- return { dispose: () => handlers.delete(id) }
81
- },
82
- executeCommand: (command: string, ...args: unknown[]) => {
83
- executed.push({ command, args })
84
- return Promise.resolve(undefined)
85
- },
86
- },
87
- env: {
88
- asExternalUri: (uri: unknown) => Promise.resolve(uri),
89
- openExternal: () => Promise.resolve(true),
90
- },
91
- workspace: {
92
- workspaceFolders: [{ uri: { fsPath: root }, name: 'app', index: 0 }],
93
- getWorkspaceFolder: () => undefined,
94
- getConfiguration: () => ({ get: (key: string, fallback: unknown) => key in settings ? settings[key] : fallback }),
95
- onDidChangeConfiguration: emitter<any>().event,
96
- createFileSystemWatcher: () => ({
97
- onDidCreate: emitter<any>().event,
98
- onDidChange: emitter<any>().event,
99
- onDidDelete: emitter<any>().event,
100
- dispose: () => {},
101
- }),
102
- },
103
- window: {
104
- activeTextEditor: undefined,
105
- terminals,
106
- onDidCloseTerminal: closeTerminal.event,
107
- onDidChangeTerminalShellIntegration: shellIntegrationChanged.event,
108
- onDidEndTerminalShellExecution: executionEnded.event,
109
- createTerminal: (terminalOptions: any) => {
110
- const terminal: FakeTerminal = {
111
- name: terminalOptions.name,
112
- exitStatus: undefined,
113
- shellIntegration: undefined,
114
- options: terminalOptions,
115
- sent: [],
116
- executions: [],
117
- shown: 0,
118
- show: () => { terminal.shown++ },
119
- sendText: (text: string) => { terminal.sent.push(text) },
120
- dispose: () => {
121
- terminal.exitStatus = { code: undefined }
122
- terminals.splice(terminals.indexOf(terminal), 1)
123
- closeTerminal.fire(terminal)
124
- },
125
- }
126
- if (options.shellIntegration) {
127
- terminal.shellIntegration = {
128
- executeCommand: (commandLine: string) => {
129
- terminal.sent.push(commandLine)
130
- const execution = {
131
- async* read() {
132
- yield 'Starting...\n'
133
- yield ' ➜ App : https://stacks.localhost/dashboard\n'
134
- },
135
- }
136
- terminal.executions.push(execution)
137
- return execution
138
- },
139
- }
140
- }
141
- terminals.push(terminal)
142
- return terminal
143
- },
144
- createStatusBarItem: () => ({ text: '', tooltip: undefined, command: undefined, name: undefined, visible: false, show() { this.visible = true }, hide() { this.visible = false }, dispose() {} }),
145
- createQuickPick: () => {
146
- const changed = emitter<string>()
147
- const accepted = emitter<void>()
148
- const hidden = emitter<void>()
149
- const picker = {
150
- value: '',
151
- title: undefined,
152
- placeholder: undefined,
153
- items: [] as any[],
154
- selectedItems: [] as any[],
155
- busy: false,
156
- matchOnDescription: false,
157
- onDidChangeValue: changed.event,
158
- onDidAccept: accepted.event,
159
- onDidHide: hidden.event,
160
- show: () => {},
161
- hide: () => hidden.fire(),
162
- dispose: () => {},
163
- type(value: string) {
164
- picker.value = value
165
- changed.fire(value)
166
- },
167
- accept(item?: any) {
168
- picker.selectedItems = item ? [item] : []
169
- accepted.fire()
170
- },
171
- }
172
- pickers.push(picker)
173
- return picker
174
- },
175
- showInputBox: (inputOptions: any) => {
176
- inputs.push(inputOptions)
177
- return Promise.resolve(answers.shift())
178
- },
179
- showInformationMessage: message,
180
- showWarningMessage: message,
181
- showErrorMessage: message,
182
- },
183
- }
184
-
185
- const host: Host = {
186
- exists: path => path in files,
187
- read: path => files[path],
188
- exec: async (file, args) => {
189
- execs.push([file, ...args])
190
- if (args.includes('--json')) {
191
- if (options.listJson === undefined)
192
- throw new Error('unknown option --json')
193
- return options.listJson
194
- }
195
- return options.listText ?? ''
196
- },
197
- probe: async (host, port) => (options.open ?? []).includes(`${host}:${port}`),
198
- sleep: () => new Promise(resolve => setTimeout(resolve, 1)),
199
- }
200
-
201
- // Short waits, so a test never sits out the real 3s/120s timeouts.
202
- const extension = createStacksExtension(api as any, host, { shellIntegrationMs: 5, devReadyMs: 200, bannerGraceMs: 50 })
203
- const context = { subscriptions: [] as Array<{ dispose: () => unknown }> }
204
- extension.activate(context)
205
-
206
- const run = (id: string) => handlers.get(id)!()
207
- const previews = () => executed.filter(call => call.command.startsWith('simpleBrowser')).map(call => String(call.args[0]))
208
- const flush = () => new Promise(resolve => setTimeout(resolve, 20))
209
-
210
- return { api, context, executed, handlers, messages, terminals, execs, pickers, inputs, run, previews, flush, closeTerminal, executionEnded }
211
- }
212
-
213
- describe('activation', () => {
214
- it('registers the three commands and marks the workspace as a Stacks project', () => {
215
- const { handlers, executed } = setup()
216
- expect([...handlers.keys()].sort()).toEqual([COMMANDS.openPreview, COMMANDS.runBuddyCommand, COMMANDS.startDevServer].sort())
217
- expect(executed).toContainEqual({ command: 'setContext', args: ['stacks.isProject', true] })
218
- })
219
-
220
- it('stays out of the way in a workspace that is not a Stacks project', () => {
221
- // The extension also activates for .stx files anywhere, for its stx support.
222
- const { handlers, executed, context } = setup({ noBuddy: true })
223
- expect(executed).toContainEqual({ command: 'setContext', args: ['stacks.isProject', false] })
224
- expect(handlers.size).toBe(3)
225
- expect(context.subscriptions.some((item: any) => 'text' in item)).toBeFalse()
226
- })
227
- })
228
-
229
- describe('Stacks: Open Preview', () => {
230
- it('opens the pretty URL beside the editor when the server and proxy answer', async () => {
231
- const { run, executed } = setup({ open: ['localhost:3000', 'stacks.localhost:443'] })
232
- await run(COMMANDS.openPreview)
233
-
234
- const call = executed.find(entry => entry.command === 'simpleBrowser.api.open')!
235
- expect(String(call.args[0])).toBe('https://stacks.localhost')
236
- expect(call.args[1]).toEqual({ viewColumn: -2, preserveFocus: true })
237
- })
238
-
239
- it('reads PORT and APP_URL from the project env files', async () => {
240
- const { run, previews } = setup({
241
- files: { '/work/app/.env': 'APP_URL=http://localhost\nPORT=3140' },
242
- open: ['localhost:3140'],
243
- })
244
- await run(COMMANDS.openPreview)
245
- expect(previews()).toEqual(['http://localhost:3140'])
246
- })
247
-
248
- it('uses the stacks.preview.url override without probing', async () => {
249
- const { run, previews } = setup({ settings: { 'preview.url': 'http://localhost:4000/admin' } })
250
- await run(COMMANDS.openPreview)
251
- expect(previews()).toEqual(['http://localhost:4000/admin'])
252
- })
253
-
254
- it('offers to start the dev server when nothing is listening', async () => {
255
- const { run, messages, previews, terminals } = setup({ answers: ['Open Anyway'] })
256
- await run(COMMANDS.openPreview)
257
-
258
- expect(messages[0]).toContain('nothing is listening on localhost:3000')
259
- expect(previews()).toEqual(['https://stacks.localhost'])
260
- expect(terminals).toHaveLength(0)
261
- })
262
-
263
- it('falls back to simpleBrowser.show when the api command is unavailable', async () => {
264
- const env = setup({ open: ['localhost:3000'], settings: { 'preview.preferLocalhost': true } })
265
- env.api.commands.executeCommand = (command: string, ...args: unknown[]) => {
266
- env.executed.push({ command, args })
267
- return command === 'simpleBrowser.api.open' ? Promise.reject(new Error('not found')) : Promise.resolve(undefined)
268
- }
269
- await env.run(COMMANDS.openPreview)
270
- expect(env.executed.at(-1)).toEqual({ command: 'simpleBrowser.show', args: ['http://localhost:3000'] })
271
- })
272
- })
273
-
274
- describe('Stacks: Start Dev Server', () => {
275
- it('runs ./buddy dev in a named terminal at the project root, without the system browser', async () => {
276
- const { run, terminals } = setup({ settings: { 'devServer.openPreview': true } })
277
- await run(COMMANDS.startDevServer)
278
-
279
- expect(terminals).toHaveLength(1)
280
- expect(terminals[0]!.name).toBe(DEV_TERMINAL)
281
- expect(terminals[0]!.options).toMatchObject({ cwd: '/work/app', env: { STACKS_DEV_NO_OPEN: '1' } })
282
- // No shell integration, so the command is typed in.
283
- expect(terminals[0]!.sent).toEqual(['./buddy dev'])
284
- })
285
-
286
- it('opens the preview once the frontend port answers when output cannot be read', async () => {
287
- const { run, previews, flush } = setup({ open: ['localhost:3000'], settings: { 'preview.preferLocalhost': true } })
288
- await run(COMMANDS.startDevServer)
289
- await flush()
290
- expect(previews()).toEqual(['http://localhost:3000'])
291
- })
292
-
293
- it('passes stacks.devServer.args and lets buddy open the browser when previews are off', async () => {
294
- const { run, terminals } = setup({ shellIntegration: true, settings: { 'devServer.args': ['frontend'], 'devServer.openPreview': false } })
295
- await run(COMMANDS.startDevServer)
296
- expect(terminals[0]!.sent).toEqual(['./buddy dev frontend'])
297
- expect(terminals[0]!.options.env).toBeUndefined()
298
- })
299
-
300
- it('opens the exact URL from the dev banner once it is printed', async () => {
301
- const { run, previews, flush } = setup({ shellIntegration: true })
302
- await run(COMMANDS.startDevServer)
303
- await flush()
304
- expect(previews()).toEqual(['https://stacks.localhost/dashboard'])
305
- })
306
-
307
- it('does not type into a dev terminal that may still be serving', async () => {
308
- const { run, terminals, messages } = setup({ shellIntegration: true, settings: { 'devServer.openPreview': false } })
309
- await run(COMMANDS.startDevServer)
310
- await run(COMMANDS.startDevServer)
311
-
312
- expect(terminals).toHaveLength(1)
313
- expect(terminals[0]!.sent).toEqual(['./buddy dev'])
314
- expect(messages.at(-1)).toContain('already open')
315
- })
316
-
317
- it('restarts in a fresh terminal on request', async () => {
318
- const { run, terminals } = setup({ shellIntegration: true, settings: { 'devServer.openPreview': false }, answers: ['Restart'] })
319
- await run(COMMANDS.startDevServer)
320
- const first = terminals[0]
321
- await run(COMMANDS.startDevServer)
322
-
323
- expect(terminals).toHaveLength(1)
324
- expect(terminals[0]).not.toBe(first)
325
- expect(terminals[0]!.sent).toEqual(['./buddy dev'])
326
- })
327
-
328
- it('reuses the terminal once the dev command has ended', async () => {
329
- const { run, terminals, executionEnded } = setup({ shellIntegration: true, settings: { 'devServer.openPreview': false } })
330
- await run(COMMANDS.startDevServer)
331
- executionEnded.fire({ terminal: terminals[0], execution: terminals[0]!.executions[0], exitCode: 130 })
332
- await run(COMMANDS.startDevServer)
333
-
334
- expect(terminals).toHaveLength(1)
335
- expect(terminals[0]!.sent).toEqual(['./buddy dev', './buddy dev'])
336
- })
337
-
338
- it('refuses to run without a ./buddy at the root', async () => {
339
- // config/app.ts alone makes it a Stacks project, but there is no CLI to run.
340
- const { run, terminals, messages } = setup({ noBuddy: true, files: { '/work/app/config/app.ts': 'export default {}' } })
341
- await run(COMMANDS.startDevServer)
342
-
343
- expect(terminals).toHaveLength(0)
344
- expect(messages[0]).toContain('there is no ./buddy in /work/app')
345
- })
346
- })
347
-
348
- describe('Stacks: Run Buddy Command...', () => {
349
- const listJson = JSON.stringify({
350
- commands: [
351
- { name: 'migrate', description: 'Migrates your database', arguments: [] },
352
- { name: 'make:model', description: 'Create a new Model', usage: '$ buddy make:model <name>', arguments: [{ name: 'name', required: true, variadic: false }] },
353
- ],
354
- })
355
-
356
- it('lists commands from ./buddy list --json at the project root, never a buddy off the PATH', async () => {
357
- const { run, pickers, execs } = setup({ listJson })
358
- await run(COMMANDS.runBuddyCommand)
359
-
360
- expect(execs).toEqual([['/work/app/buddy', 'list', '--json']])
361
- expect(pickers[0].items.map((item: any) => item.label)).toEqual(['make:model', 'migrate'])
362
- expect(pickers[0].busy).toBe(false)
363
- })
364
-
365
- it('falls back to parsing the text listing', async () => {
366
- const { run, pickers, execs } = setup({ listText: 'Available Commands:\n\nGeneral:\n inspire Inspire yourself\n' })
367
- await run(COMMANDS.runBuddyCommand)
368
-
369
- expect(execs).toEqual([['/work/app/buddy', 'list', '--json'], ['/work/app/buddy', 'list']])
370
- expect(pickers[0].items.map((item: any) => item.label)).toEqual(['inspire'])
371
- })
372
-
373
- it('runs a picked command in the Stacks Buddy terminal', async () => {
374
- const { run, pickers, terminals, flush } = setup({ listJson })
375
- await run(COMMANDS.runBuddyCommand)
376
- pickers[0].accept(pickers[0].items.find((item: any) => item.label === 'migrate'))
377
- await flush()
378
-
379
- expect(terminals.map(terminal => [terminal.name, terminal.options.cwd, terminal.sent])).toEqual([[BUDDY_TERMINAL, '/work/app', ['./buddy migrate']]])
380
- })
381
-
382
- it('asks for the arguments a command declares', async () => {
383
- const { run, pickers, terminals, inputs, flush } = setup({ listJson, answers: ['Post'] })
384
- await run(COMMANDS.runBuddyCommand)
385
- pickers[0].accept(pickers[0].items.find((item: any) => item.label === 'make:model'))
386
- await flush()
387
-
388
- expect(inputs[0].placeHolder).toBe('<name>')
389
- expect(inputs[0].validateInput('')).toContain('<name>')
390
- expect(terminals[0]!.sent).toEqual(['./buddy make:model Post'])
391
- })
392
-
393
- it('runs typed text as a command, appended after the matches', async () => {
394
- const { run, pickers, terminals, flush } = setup({ listJson })
395
- await run(COMMANDS.runBuddyCommand)
396
- pickers[0].type('buddy migrate --diff')
397
-
398
- const last = pickers[0].items.at(-1)
399
- expect(last.label).toBe('./buddy migrate --diff')
400
- pickers[0].accept(last)
401
- await flush()
402
- expect(terminals[0]!.sent).toEqual(['./buddy migrate --diff'])
403
- })
404
-
405
- it('still accepts free text when ./buddy list fails', async () => {
406
- const { run, pickers, terminals, flush } = setup()
407
- await run(COMMANDS.runBuddyCommand)
408
- expect(pickers[0].placeholder).toContain('Could not read ./buddy list')
409
-
410
- pickers[0].type('test --unit')
411
- pickers[0].accept()
412
- await flush()
413
- expect(terminals[0]!.sent).toEqual(['./buddy test --unit'])
414
- })
415
-
416
- it('reuses the Stacks Buddy terminal', async () => {
417
- const { run, pickers, terminals, flush } = setup({ listJson })
418
- await run(COMMANDS.runBuddyCommand)
419
- pickers[0].accept(pickers[0].items[1])
420
- await flush()
421
- await run(COMMANDS.runBuddyCommand)
422
- pickers[1].accept(pickers[1].items[1])
423
- await flush()
424
-
425
- expect(terminals).toHaveLength(1)
426
- expect(terminals[0]!.sent).toEqual(['./buddy migrate', './buddy migrate'])
427
- })
428
- })