@stacksjs/defaults 0.75.38 → 0.75.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-ai/SKILL.md +24 -1
- package/ai/skills/stacks-commerce/SKILL.md +1 -1
- package/ai/skills/stacks-config/SKILL.md +1 -1
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-dependencies/SKILL.md +35 -3
- package/ai/skills/stacks-desktop/SKILL.md +12 -0
- package/ai/skills/stacks-env/SKILL.md +4 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
- package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
- package/app/Actions/Commerce/commerce-action.test.ts +30 -1
- package/app/Actions/Commerce/commerce-action.ts +44 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
- package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
- package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
- package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
- package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
- package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
- package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
- package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
- package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
- package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
- package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
- package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
- package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
- package/app/Actions/Storefront/AddToCartAction.ts +4 -1
- package/app/Actions/Storefront/GetCartAction.ts +25 -10
- package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
- package/app/Actions/Storefront/_shipping.ts +63 -37
- package/app/Broadcasts/OrderShipped.ts +1 -1
- package/app/Mail/OrderConfirmation.ts +14 -7
- package/app/Models/Campaign.ts +9 -4
- package/app/Models/commerce/Cart.ts +5 -4
- package/app/Models/commerce/CartItem.ts +6 -4
- package/app/Models/commerce/Coupon.ts +9 -3
- package/app/Models/commerce/Customer.ts +2 -1
- package/app/Models/commerce/GiftCard.ts +5 -2
- package/app/Models/commerce/Order.ts +6 -5
- package/app/Models/commerce/OrderItem.ts +2 -1
- package/app/Models/commerce/ProductVariant.ts +71 -4
- package/app/Models/commerce/Transaction.ts +3 -2
- package/app/Storefront/StorefrontMoney.ts +54 -0
- package/ide/vscode/.vscode/settings.json +1 -1
- package/ide/vscode/README.md +65 -20
- package/ide/vscode/package.json +102 -12
- package/ide/vscode/src/extension.ts +59 -0
- package/ide/vscode/src/project.ts +440 -0
- package/ide/vscode/src/stacks.ts +475 -0
- package/ide/vscode/src/vscode.d.ts +181 -0
- package/ide/vscode/tests/extension.test.ts +420 -0
- package/ide/vscode/tests/project.test.ts +268 -0
- package/ide/vscode/tsconfig.json +19 -0
- package/package.json +4 -4
- package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
- package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
- package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
- package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
- package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
- package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
- package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
- package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
- package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
- package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
- package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
- package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
- package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
- package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
- package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
- package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
- package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
- package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
- package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
- package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
- package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
- package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
- package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
- package/resources/components/Dashboard/Transaction/index.stx +6 -3
- package/resources/components/Storefront/CartDrawer.stx +11 -7
- package/resources/views/cart.stx +19 -23
- package/resources/views/checkout/contact.stx +12 -14
- package/resources/views/checkout/payment.stx +13 -13
- package/resources/views/checkout/shipping.stx +13 -13
- package/resources/views/cms/blocks/form.stx +50 -1
- package/resources/views/orders/[id].stx +13 -4
- package/vcs/github/workflows/buddy-bot.yml +5 -5
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
|
@@ -0,0 +1,420 @@
|
|
|
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
|
+
|
|
221
|
+
describe('Stacks: Open Preview', () => {
|
|
222
|
+
it('opens the pretty URL beside the editor when the server and proxy answer', async () => {
|
|
223
|
+
const { run, executed } = setup({ open: ['localhost:3000', 'stacks.localhost:443'] })
|
|
224
|
+
await run(COMMANDS.openPreview)
|
|
225
|
+
|
|
226
|
+
const call = executed.find(entry => entry.command === 'simpleBrowser.api.open')!
|
|
227
|
+
expect(String(call.args[0])).toBe('https://stacks.localhost')
|
|
228
|
+
expect(call.args[1]).toEqual({ viewColumn: -2, preserveFocus: true })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('reads PORT and APP_URL from the project env files', async () => {
|
|
232
|
+
const { run, previews } = setup({
|
|
233
|
+
files: { '/work/app/.env': 'APP_URL=http://localhost\nPORT=3140' },
|
|
234
|
+
open: ['localhost:3140'],
|
|
235
|
+
})
|
|
236
|
+
await run(COMMANDS.openPreview)
|
|
237
|
+
expect(previews()).toEqual(['http://localhost:3140'])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('uses the stacks.preview.url override without probing', async () => {
|
|
241
|
+
const { run, previews } = setup({ settings: { 'preview.url': 'http://localhost:4000/admin' } })
|
|
242
|
+
await run(COMMANDS.openPreview)
|
|
243
|
+
expect(previews()).toEqual(['http://localhost:4000/admin'])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('offers to start the dev server when nothing is listening', async () => {
|
|
247
|
+
const { run, messages, previews, terminals } = setup({ answers: ['Open Anyway'] })
|
|
248
|
+
await run(COMMANDS.openPreview)
|
|
249
|
+
|
|
250
|
+
expect(messages[0]).toContain('nothing is listening on localhost:3000')
|
|
251
|
+
expect(previews()).toEqual(['https://stacks.localhost'])
|
|
252
|
+
expect(terminals).toHaveLength(0)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('falls back to simpleBrowser.show when the api command is unavailable', async () => {
|
|
256
|
+
const env = setup({ open: ['localhost:3000'], settings: { 'preview.preferLocalhost': true } })
|
|
257
|
+
env.api.commands.executeCommand = (command: string, ...args: unknown[]) => {
|
|
258
|
+
env.executed.push({ command, args })
|
|
259
|
+
return command === 'simpleBrowser.api.open' ? Promise.reject(new Error('not found')) : Promise.resolve(undefined)
|
|
260
|
+
}
|
|
261
|
+
await env.run(COMMANDS.openPreview)
|
|
262
|
+
expect(env.executed.at(-1)).toEqual({ command: 'simpleBrowser.show', args: ['http://localhost:3000'] })
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('Stacks: Start Dev Server', () => {
|
|
267
|
+
it('runs ./buddy dev in a named terminal at the project root, without the system browser', async () => {
|
|
268
|
+
const { run, terminals } = setup({ settings: { 'devServer.openPreview': true } })
|
|
269
|
+
await run(COMMANDS.startDevServer)
|
|
270
|
+
|
|
271
|
+
expect(terminals).toHaveLength(1)
|
|
272
|
+
expect(terminals[0]!.name).toBe(DEV_TERMINAL)
|
|
273
|
+
expect(terminals[0]!.options).toMatchObject({ cwd: '/work/app', env: { STACKS_DEV_NO_OPEN: '1' } })
|
|
274
|
+
// No shell integration, so the command is typed in.
|
|
275
|
+
expect(terminals[0]!.sent).toEqual(['./buddy dev'])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('opens the preview once the frontend port answers when output cannot be read', async () => {
|
|
279
|
+
const { run, previews, flush } = setup({ open: ['localhost:3000'], settings: { 'preview.preferLocalhost': true } })
|
|
280
|
+
await run(COMMANDS.startDevServer)
|
|
281
|
+
await flush()
|
|
282
|
+
expect(previews()).toEqual(['http://localhost:3000'])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('passes stacks.devServer.args and lets buddy open the browser when previews are off', async () => {
|
|
286
|
+
const { run, terminals } = setup({ shellIntegration: true, settings: { 'devServer.args': ['frontend'], 'devServer.openPreview': false } })
|
|
287
|
+
await run(COMMANDS.startDevServer)
|
|
288
|
+
expect(terminals[0]!.sent).toEqual(['./buddy dev frontend'])
|
|
289
|
+
expect(terminals[0]!.options.env).toBeUndefined()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('opens the exact URL from the dev banner once it is printed', async () => {
|
|
293
|
+
const { run, previews, flush } = setup({ shellIntegration: true })
|
|
294
|
+
await run(COMMANDS.startDevServer)
|
|
295
|
+
await flush()
|
|
296
|
+
expect(previews()).toEqual(['https://stacks.localhost/dashboard'])
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('does not type into a dev terminal that may still be serving', async () => {
|
|
300
|
+
const { run, terminals, messages } = setup({ shellIntegration: true, settings: { 'devServer.openPreview': false } })
|
|
301
|
+
await run(COMMANDS.startDevServer)
|
|
302
|
+
await run(COMMANDS.startDevServer)
|
|
303
|
+
|
|
304
|
+
expect(terminals).toHaveLength(1)
|
|
305
|
+
expect(terminals[0]!.sent).toEqual(['./buddy dev'])
|
|
306
|
+
expect(messages.at(-1)).toContain('already open')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('restarts in a fresh terminal on request', async () => {
|
|
310
|
+
const { run, terminals } = setup({ shellIntegration: true, settings: { 'devServer.openPreview': false }, answers: ['Restart'] })
|
|
311
|
+
await run(COMMANDS.startDevServer)
|
|
312
|
+
const first = terminals[0]
|
|
313
|
+
await run(COMMANDS.startDevServer)
|
|
314
|
+
|
|
315
|
+
expect(terminals).toHaveLength(1)
|
|
316
|
+
expect(terminals[0]).not.toBe(first)
|
|
317
|
+
expect(terminals[0]!.sent).toEqual(['./buddy dev'])
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('reuses the terminal once the dev command has ended', async () => {
|
|
321
|
+
const { run, terminals, executionEnded } = setup({ shellIntegration: true, settings: { 'devServer.openPreview': false } })
|
|
322
|
+
await run(COMMANDS.startDevServer)
|
|
323
|
+
executionEnded.fire({ terminal: terminals[0], execution: terminals[0]!.executions[0], exitCode: 130 })
|
|
324
|
+
await run(COMMANDS.startDevServer)
|
|
325
|
+
|
|
326
|
+
expect(terminals).toHaveLength(1)
|
|
327
|
+
expect(terminals[0]!.sent).toEqual(['./buddy dev', './buddy dev'])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('refuses to run without a ./buddy at the root', async () => {
|
|
331
|
+
// config/app.ts alone makes it a Stacks project, but there is no CLI to run.
|
|
332
|
+
const { run, terminals, messages } = setup({ noBuddy: true, files: { '/work/app/config/app.ts': 'export default {}' } })
|
|
333
|
+
await run(COMMANDS.startDevServer)
|
|
334
|
+
|
|
335
|
+
expect(terminals).toHaveLength(0)
|
|
336
|
+
expect(messages[0]).toContain('there is no ./buddy in /work/app')
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('Stacks: Run Buddy Command...', () => {
|
|
341
|
+
const listJson = JSON.stringify({
|
|
342
|
+
commands: [
|
|
343
|
+
{ name: 'migrate', description: 'Migrates your database', arguments: [] },
|
|
344
|
+
{ name: 'make:model', description: 'Create a new Model', usage: '$ buddy make:model <name>', arguments: [{ name: 'name', required: true, variadic: false }] },
|
|
345
|
+
],
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('lists commands from ./buddy list --json at the project root, never a buddy off the PATH', async () => {
|
|
349
|
+
const { run, pickers, execs } = setup({ listJson })
|
|
350
|
+
await run(COMMANDS.runBuddyCommand)
|
|
351
|
+
|
|
352
|
+
expect(execs).toEqual([['/work/app/buddy', 'list', '--json']])
|
|
353
|
+
expect(pickers[0].items.map((item: any) => item.label)).toEqual(['make:model', 'migrate'])
|
|
354
|
+
expect(pickers[0].busy).toBe(false)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('falls back to parsing the text listing', async () => {
|
|
358
|
+
const { run, pickers, execs } = setup({ listText: 'Available Commands:\n\nGeneral:\n inspire Inspire yourself\n' })
|
|
359
|
+
await run(COMMANDS.runBuddyCommand)
|
|
360
|
+
|
|
361
|
+
expect(execs).toEqual([['/work/app/buddy', 'list', '--json'], ['/work/app/buddy', 'list']])
|
|
362
|
+
expect(pickers[0].items.map((item: any) => item.label)).toEqual(['inspire'])
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('runs a picked command in the Stacks Buddy terminal', async () => {
|
|
366
|
+
const { run, pickers, terminals, flush } = setup({ listJson })
|
|
367
|
+
await run(COMMANDS.runBuddyCommand)
|
|
368
|
+
pickers[0].accept(pickers[0].items.find((item: any) => item.label === 'migrate'))
|
|
369
|
+
await flush()
|
|
370
|
+
|
|
371
|
+
expect(terminals.map(terminal => [terminal.name, terminal.options.cwd, terminal.sent])).toEqual([[BUDDY_TERMINAL, '/work/app', ['./buddy migrate']]])
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('asks for the arguments a command declares', async () => {
|
|
375
|
+
const { run, pickers, terminals, inputs, flush } = setup({ listJson, answers: ['Post'] })
|
|
376
|
+
await run(COMMANDS.runBuddyCommand)
|
|
377
|
+
pickers[0].accept(pickers[0].items.find((item: any) => item.label === 'make:model'))
|
|
378
|
+
await flush()
|
|
379
|
+
|
|
380
|
+
expect(inputs[0].placeHolder).toBe('<name>')
|
|
381
|
+
expect(inputs[0].validateInput('')).toContain('<name>')
|
|
382
|
+
expect(terminals[0]!.sent).toEqual(['./buddy make:model Post'])
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('runs typed text as a command, appended after the matches', async () => {
|
|
386
|
+
const { run, pickers, terminals, flush } = setup({ listJson })
|
|
387
|
+
await run(COMMANDS.runBuddyCommand)
|
|
388
|
+
pickers[0].type('buddy migrate --diff')
|
|
389
|
+
|
|
390
|
+
const last = pickers[0].items.at(-1)
|
|
391
|
+
expect(last.label).toBe('./buddy migrate --diff')
|
|
392
|
+
pickers[0].accept(last)
|
|
393
|
+
await flush()
|
|
394
|
+
expect(terminals[0]!.sent).toEqual(['./buddy migrate --diff'])
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('still accepts free text when ./buddy list fails', async () => {
|
|
398
|
+
const { run, pickers, terminals, flush } = setup()
|
|
399
|
+
await run(COMMANDS.runBuddyCommand)
|
|
400
|
+
expect(pickers[0].placeholder).toContain('Could not read ./buddy list')
|
|
401
|
+
|
|
402
|
+
pickers[0].type('test --unit')
|
|
403
|
+
pickers[0].accept()
|
|
404
|
+
await flush()
|
|
405
|
+
expect(terminals[0]!.sent).toEqual(['./buddy test --unit'])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('reuses the Stacks Buddy terminal', async () => {
|
|
409
|
+
const { run, pickers, terminals, flush } = setup({ listJson })
|
|
410
|
+
await run(COMMANDS.runBuddyCommand)
|
|
411
|
+
pickers[0].accept(pickers[0].items[1])
|
|
412
|
+
await flush()
|
|
413
|
+
await run(COMMANDS.runBuddyCommand)
|
|
414
|
+
pickers[1].accept(pickers[1].items[1])
|
|
415
|
+
await flush()
|
|
416
|
+
|
|
417
|
+
expect(terminals).toHaveLength(1)
|
|
418
|
+
expect(terminals[0]!.sent).toEqual(['./buddy migrate', './buddy migrate'])
|
|
419
|
+
})
|
|
420
|
+
})
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
argumentHint,
|
|
4
|
+
buddyCommandLine,
|
|
5
|
+
choosePreviewUrl,
|
|
6
|
+
configuredAppPath,
|
|
7
|
+
freeTextCommandLine,
|
|
8
|
+
isLoopbackUrl,
|
|
9
|
+
loadProjectEnv,
|
|
10
|
+
parseBuddyList,
|
|
11
|
+
parseBuddyListJson,
|
|
12
|
+
parseBuddyListText,
|
|
13
|
+
parseDevBannerUrl,
|
|
14
|
+
parseEnvFile,
|
|
15
|
+
pickProjectRoot,
|
|
16
|
+
planPreview,
|
|
17
|
+
resolvePrettyDomain,
|
|
18
|
+
} from '../src/project'
|
|
19
|
+
|
|
20
|
+
describe('parseEnvFile', () => {
|
|
21
|
+
it('reads plain, exported, quoted and commented values', () => {
|
|
22
|
+
const env = parseEnvFile([
|
|
23
|
+
'# a comment',
|
|
24
|
+
'APP_NAME=Stacks',
|
|
25
|
+
'export PORT=3100',
|
|
26
|
+
'APP_URL="my-app.localhost"',
|
|
27
|
+
'QUOTED=\'single # not a comment\'',
|
|
28
|
+
'TRAILING=value # a comment',
|
|
29
|
+
'EMPTY=',
|
|
30
|
+
'not a pair',
|
|
31
|
+
'',
|
|
32
|
+
].join('\n'))
|
|
33
|
+
|
|
34
|
+
expect(env).toEqual({
|
|
35
|
+
APP_NAME: 'Stacks',
|
|
36
|
+
PORT: '3100',
|
|
37
|
+
APP_URL: 'my-app.localhost',
|
|
38
|
+
QUOTED: 'single # not a comment',
|
|
39
|
+
TRAILING: 'value',
|
|
40
|
+
EMPTY: '',
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('handles CRLF line endings', () => {
|
|
45
|
+
expect(parseEnvFile('PORT=3200\r\nAPP_URL=x.test\r\n')).toEqual({ PORT: '3200', APP_URL: 'x.test' })
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('loadProjectEnv', () => {
|
|
50
|
+
it('lets later dev env files override earlier ones, as @stacksjs/env loads them', () => {
|
|
51
|
+
const files: Record<string, string> = {
|
|
52
|
+
'.env': 'PORT=3000\nAPP_URL=stacks.localhost',
|
|
53
|
+
'.env.local': 'PORT=3100',
|
|
54
|
+
'.env.development.local': 'APP_URL=override.localhost',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
expect(loadProjectEnv(file => files[file])).toEqual({ PORT: '3100', APP_URL: 'override.localhost' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('skips dotenvx ciphertext instead of returning it', () => {
|
|
61
|
+
const env = loadProjectEnv(file => file === '.env' ? 'PORT=3000\nAPP_URL=encrypted:BCx9abc' : undefined)
|
|
62
|
+
expect(env).toEqual({ PORT: '3000' })
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('resolvePrettyDomain', () => {
|
|
67
|
+
it('mirrors buddy dev: a non-loopback APP_URL is a pretty domain', () => {
|
|
68
|
+
expect(resolvePrettyDomain('stacks.localhost')).toBe('stacks.localhost')
|
|
69
|
+
expect(resolvePrettyDomain('https://My-App.test/path')).toBe('my-app.test')
|
|
70
|
+
expect(resolvePrettyDomain('localhost:3000')).toBeNull()
|
|
71
|
+
expect(resolvePrettyDomain('http://127.0.0.1')).toBeNull()
|
|
72
|
+
expect(resolvePrettyDomain(undefined)).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('configuredAppPath', () => {
|
|
77
|
+
it('prefers APP_PATH, then appPath in config/app.ts, then /', () => {
|
|
78
|
+
const config = 'export default {\n name: \'x\',\n appPath: \'/dashboard\',\n}'
|
|
79
|
+
expect(configuredAppPath({ APP_PATH: 'admin' }, config)).toBe('/admin')
|
|
80
|
+
expect(configuredAppPath({}, config)).toBe('/dashboard')
|
|
81
|
+
expect(configuredAppPath({}, 'export default { name: \'x\' }')).toBe('/')
|
|
82
|
+
expect(configuredAppPath({})).toBe('/')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('planPreview', () => {
|
|
87
|
+
it('defaults to https://stacks.localhost with localhost:3000 behind it', () => {
|
|
88
|
+
expect(planPreview({ env: {} })).toEqual({
|
|
89
|
+
server: { host: 'localhost', port: 3000 },
|
|
90
|
+
candidates: [
|
|
91
|
+
{ url: 'https://stacks.localhost', host: 'stacks.localhost', port: 443 },
|
|
92
|
+
{ url: 'http://localhost:3000', host: 'localhost', port: 3000 },
|
|
93
|
+
],
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('uses PORT, APP_URL and the entry path', () => {
|
|
98
|
+
const plan = planPreview({ env: { PORT: '3140', APP_URL: 'https://shop.test', APP_PATH: '/admin' } })
|
|
99
|
+
expect(plan.server).toEqual({ host: 'localhost', port: 3140 })
|
|
100
|
+
expect(plan.candidates.map(candidate => candidate.url)).toEqual(['https://shop.test/admin', 'http://localhost:3140/admin'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('stays on localhost for STACKS_DEV_LOCALHOST=1, a loopback APP_URL, or the setting', () => {
|
|
104
|
+
const only = (plan: ReturnType<typeof planPreview>) => plan.candidates.map(candidate => candidate.url)
|
|
105
|
+
expect(only(planPreview({ env: { STACKS_DEV_LOCALHOST: '1' } }))).toEqual(['http://localhost:3000'])
|
|
106
|
+
expect(only(planPreview({ env: { APP_URL: 'http://localhost:3000' } }))).toEqual(['http://localhost:3000'])
|
|
107
|
+
expect(only(planPreview({ env: {}, preferLocalhost: true }))).toEqual(['http://localhost:3000'])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('ignores a PORT that is not a number', () => {
|
|
111
|
+
expect(planPreview({ env: { PORT: 'abc', STACKS_DEV_LOCALHOST: '1' } }).server.port).toBe(3000)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('choosePreviewUrl', () => {
|
|
116
|
+
const plan = planPreview({ env: {} })
|
|
117
|
+
const probeOf = (open: string[]) => async (host: string, port: number) => open.includes(`${host}:${port}`)
|
|
118
|
+
|
|
119
|
+
it('returns nothing when the frontend port is closed, even if the shared proxy answers', async () => {
|
|
120
|
+
expect(await choosePreviewUrl(plan, probeOf(['stacks.localhost:443']))).toBeUndefined()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('prefers the pretty URL when both the server and the proxy answer', async () => {
|
|
124
|
+
expect(await choosePreviewUrl(plan, probeOf(['localhost:3000', 'stacks.localhost:443']))).toBe('https://stacks.localhost')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('falls back to localhost when the proxy is down', async () => {
|
|
128
|
+
expect(await choosePreviewUrl(plan, probeOf(['localhost:3000']))).toBe('http://localhost:3000')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('parseDevBannerUrl', () => {
|
|
133
|
+
it('reads the App/Site line of the buddy dev banner, colors and all', () => {
|
|
134
|
+
const banner = [
|
|
135
|
+
'\u001B[32m➜\u001B[39m \u001B[1mApp \u001B[22m: \u001B[36mhttps://stacks.localhost/dashboard\u001B[39m',
|
|
136
|
+
' ➜ Site: https://stacks.localhost',
|
|
137
|
+
' ➜ API: https://stacks.localhost/api',
|
|
138
|
+
].join('\n')
|
|
139
|
+
|
|
140
|
+
expect(parseDevBannerUrl(banner)).toBe('https://stacks.localhost/dashboard')
|
|
141
|
+
expect(parseDevBannerUrl(' ➜ Site : http://localhost:3001')).toBe('http://localhost:3001')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('returns undefined before the banner is printed', () => {
|
|
145
|
+
expect(parseDevBannerUrl('Starting dev server...\n ➜ API: http://localhost:3008')).toBeUndefined()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const LIST_TEXT = `[env] loaded 33/49 variables from .env
|
|
150
|
+
|
|
151
|
+
Available Commands:
|
|
152
|
+
|
|
153
|
+
Development:
|
|
154
|
+
build Build any of your libraries (packages) for production use
|
|
155
|
+
dev Start development server
|
|
156
|
+
|
|
157
|
+
make:
|
|
158
|
+
make:model Create a new Model
|
|
159
|
+
make:action Create a new Action
|
|
160
|
+
|
|
161
|
+
upgrade:
|
|
162
|
+
upgrade:dependencies Upgrade your dependencies (pantry.yaml & package.json)
|
|
163
|
+
upgrade:bun Upgrade Bun to the latest version
|
|
164
|
+
|
|
165
|
+
Total: 6 commands
|
|
166
|
+
`
|
|
167
|
+
|
|
168
|
+
describe('parseBuddyListText', () => {
|
|
169
|
+
it('reads the indented command lines and skips headings and the total', () => {
|
|
170
|
+
expect(parseBuddyListText(LIST_TEXT).map(command => [command.name, command.description])).toEqual([
|
|
171
|
+
['build', 'Build any of your libraries (packages) for production use'],
|
|
172
|
+
['dev', 'Start development server'],
|
|
173
|
+
['make:action', 'Create a new Action'],
|
|
174
|
+
['make:model', 'Create a new Model'],
|
|
175
|
+
['upgrade:bun', 'Upgrade Bun to the latest version'],
|
|
176
|
+
['upgrade:dependencies', 'Upgrade your dependencies (pantry.yaml & package.json)'],
|
|
177
|
+
])
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const LIST_JSON = `[env] loaded 33/49 variables from .env
|
|
182
|
+
${JSON.stringify({
|
|
183
|
+
commands: [
|
|
184
|
+
{ name: 'make:model', description: 'Create a new Model', usage: '$ buddy make:model <name>', arguments: [{ name: 'name', required: true, variadic: false }], options: [] },
|
|
185
|
+
{ name: 'dev', description: 'Start development server', usage: '$ buddy dev [server]', arguments: [{ name: 'server', required: false, variadic: false }], options: [] },
|
|
186
|
+
{ name: 'rm -rf /; x', description: 'not a command name' },
|
|
187
|
+
],
|
|
188
|
+
total: 3,
|
|
189
|
+
}, null, 2)}`
|
|
190
|
+
|
|
191
|
+
describe('parseBuddyListJson', () => {
|
|
192
|
+
it('reads the inventory past any leading log line and drops malformed names', () => {
|
|
193
|
+
expect(parseBuddyListJson(LIST_JSON)).toEqual([
|
|
194
|
+
{ name: 'dev', description: 'Start development server', usage: 'buddy dev [server]', arguments: [{ name: 'server', required: false, variadic: false }] },
|
|
195
|
+
{ name: 'make:model', description: 'Create a new Model', usage: 'buddy make:model <name>', arguments: [{ name: 'name', required: true, variadic: false }] },
|
|
196
|
+
])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('returns undefined for output that is not the JSON inventory', () => {
|
|
200
|
+
expect(parseBuddyListJson(LIST_TEXT)).toBeUndefined()
|
|
201
|
+
expect(parseBuddyListJson('{ not json')).toBeUndefined()
|
|
202
|
+
expect(parseBuddyListJson('{"something":"else"}')).toBeUndefined()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('parseBuddyList falls back to the text listing', () => {
|
|
206
|
+
expect(parseBuddyList(LIST_TEXT)).toHaveLength(6)
|
|
207
|
+
expect(parseBuddyList(LIST_JSON)).toHaveLength(2)
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('command lines', () => {
|
|
212
|
+
it('always runs the project ./buddy', () => {
|
|
213
|
+
expect(buddyCommandLine('dev')).toBe('./buddy dev')
|
|
214
|
+
expect(buddyCommandLine('make:model', 'Post')).toBe('./buddy make:model Post')
|
|
215
|
+
expect(buddyCommandLine('dev', ['frontend', '--with-localhost'])).toBe('./buddy dev frontend --with-localhost')
|
|
216
|
+
expect(buddyCommandLine('migrate', ' ')).toBe('./buddy migrate')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('refuses a name that is not a buddy command', () => {
|
|
220
|
+
expect(() => buddyCommandLine('dev; rm -rf ~')).toThrow()
|
|
221
|
+
expect(() => buddyCommandLine('')).toThrow()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('turns free text into a ./buddy line, dropping a typed buddy/bud/stacks prefix', () => {
|
|
225
|
+
expect(freeTextCommandLine('migrate --diff')).toBe('./buddy migrate --diff')
|
|
226
|
+
expect(freeTextCommandLine('buddy migrate --diff')).toBe('./buddy migrate --diff')
|
|
227
|
+
expect(freeTextCommandLine('./buddy make:model Post')).toBe('./buddy make:model Post')
|
|
228
|
+
expect(freeTextCommandLine('bud test')).toBe('./buddy test')
|
|
229
|
+
expect(freeTextCommandLine('stacks list')).toBe('./buddy list')
|
|
230
|
+
expect(freeTextCommandLine('buddyfile')).toBe('./buddy buddyfile')
|
|
231
|
+
expect(freeTextCommandLine('buddy')).toBe('./buddy')
|
|
232
|
+
expect(freeTextCommandLine(' ')).toBeUndefined()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('hints positional arguments', () => {
|
|
236
|
+
expect(argumentHint({
|
|
237
|
+
name: 'x',
|
|
238
|
+
description: '',
|
|
239
|
+
arguments: [
|
|
240
|
+
{ name: 'name', required: true, variadic: false },
|
|
241
|
+
{ name: 'files', required: false, variadic: true },
|
|
242
|
+
],
|
|
243
|
+
})).toBe('<name> [files...]')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('pickProjectRoot', () => {
|
|
248
|
+
const projects = new Set(['/work/app', '/work/other-app'])
|
|
249
|
+
const isProject = (folder: string) => projects.has(folder)
|
|
250
|
+
|
|
251
|
+
it('prefers the active editor folder when it is a Stacks project', () => {
|
|
252
|
+
expect(pickProjectRoot(['/work/docs', '/work/app', '/work/other-app'], '/work/other-app', isProject)).toBe('/work/other-app')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('falls back to the first Stacks folder', () => {
|
|
256
|
+
expect(pickProjectRoot(['/work/docs', '/work/app'], '/work/docs', isProject)).toBe('/work/app')
|
|
257
|
+
expect(pickProjectRoot(['/work/docs'], undefined, isProject)).toBeUndefined()
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('isLoopbackUrl', () => {
|
|
262
|
+
it('detects URLs that need port forwarding in a remote window', () => {
|
|
263
|
+
expect(isLoopbackUrl('http://localhost:3000')).toBe(true)
|
|
264
|
+
expect(isLoopbackUrl('http://[::1]:3000')).toBe(true)
|
|
265
|
+
expect(isLoopbackUrl('https://stacks.localhost')).toBe(false)
|
|
266
|
+
expect(isLoopbackUrl('not a url')).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
})
|