@stacksjs/defaults 0.75.40 → 0.75.42
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-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-chat/SKILL.md +43 -1
- package/ai/skills/stacks-cloud/SKILL.md +36 -0
- package/ai/skills/stacks-dashboard/SKILL.md +16 -2
- package/ai/skills/stacks-database/SKILL.md +24 -3
- package/ai/skills/stacks-deploy/SKILL.md +7 -1
- package/ai/skills/stacks-desktop/SKILL.md +22 -0
- package/ai/skills/stacks-development/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +38 -2
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/ai/skills/stacks-security/SKILL.md +28 -1
- package/app/Actions/Auth/DataExportAction.test.ts +84 -0
- package/app/Actions/Auth/DataExportAction.ts +40 -0
- package/app/Actions/Dashboard/Content/FileReprocessAction.ts +1 -1
- package/app/Actions/Dashboard/Content/file-manager.ts +120 -8
- package/app/Actions/Dashboard/Content/file-metadata.ts +108 -12
- package/app/Actions/Dashboard/Content/file-pipeline.test.ts +13 -9
- package/app/Actions/Dashboard/Content/file-preview.test.ts +487 -0
- package/app/Actions/Dashboard/Content/file-preview.ts +548 -0
- package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
- package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
- package/app/Jobs/GenerateStoragePreviewJob.ts +47 -0
- package/app/Jobs/OptimizeStorageImageJob.ts +7 -3
- package/app/Jobs/PruneRetainedDataJob.ts +46 -0
- package/app/Models/Activity.ts +2 -0
- package/app/Models/CampaignSend.ts +2 -0
- package/app/Models/Card.ts +1 -0
- package/app/Models/CardComment.ts +1 -0
- package/app/Models/Comment.ts +5 -0
- package/app/Models/CommunicationSuppression.ts +2 -1
- package/app/Models/ConsentEvent.ts +4 -3
- package/app/Models/Content/Author.ts +5 -0
- package/app/Models/EmailListSubscriber.ts +1 -0
- package/app/Models/EmailSuppression.ts +2 -0
- package/app/Models/Forms/FormSubmission.ts +5 -0
- package/app/Models/GdprRequest.ts +77 -0
- package/app/Models/MagicLinkToken.ts +2 -0
- package/app/Models/Notification.ts +2 -0
- package/app/Models/NotificationDelivery.ts +4 -0
- package/app/Models/PaymentMethod.ts +5 -0
- package/app/Models/PaymentTransaction.ts +1 -0
- package/app/Models/PersonalAccessToken.ts +3 -0
- package/app/Models/Referral.ts +4 -1
- package/app/Models/ReferralCode.ts +4 -1
- package/app/Models/Request.ts +3 -0
- package/app/Models/SmsOptOut.ts +2 -0
- package/app/Models/SocialAccount.ts +3 -0
- package/app/Models/SocialPost.ts +2 -0
- package/app/Models/StorageItemTask.ts +14 -8
- package/app/Models/Subscriber.ts +2 -0
- package/app/Models/SubscriberEmail.ts +2 -0
- package/app/Models/Subscription.ts +1 -0
- package/app/Models/TeamInvitation.ts +2 -0
- package/app/Models/TeamMember.ts +1 -0
- package/app/Models/User.ts +5 -0
- package/app/Models/commerce/Bid.ts +3 -0
- package/app/Models/commerce/Cart.ts +2 -0
- package/app/Models/commerce/Courier.ts +7 -0
- package/app/Models/commerce/CourierPing.ts +3 -0
- package/app/Models/commerce/Customer.ts +5 -0
- package/app/Models/commerce/DeliveryStop.ts +7 -0
- package/app/Models/commerce/GiftCard.ts +4 -0
- package/app/Models/commerce/Order.ts +5 -0
- package/app/Models/commerce/OrderItem.ts +2 -0
- package/app/Models/commerce/Payment.ts +5 -0
- package/app/Models/commerce/Pledge.ts +3 -0
- package/app/Models/commerce/Transaction.ts +2 -0
- package/app/Models/commerce/WaitlistProduct.ts +5 -0
- package/app/Models/commerce/WaitlistRestaurant.ts +4 -0
- package/ide/vscode/.vscode/extensions.json +1 -1
- package/ide/vscode/.vscode/settings.json +8 -149
- package/ide/vscode/README.md +92 -22
- package/ide/vscode/build.ts +43 -0
- package/ide/vscode/package.json +368 -29
- package/ide/vscode/scripts/stx-contributes.ts +66 -0
- package/ide/vscode/src/env-file.ts +103 -0
- package/ide/vscode/src/env.ts +241 -0
- package/ide/vscode/src/extension.ts +153 -0
- package/ide/vscode/src/pickier-protocol.ts +51 -0
- package/ide/vscode/src/pickier-worker.ts +92 -0
- package/ide/vscode/src/pickier.ts +433 -0
- package/ide/vscode/src/project.ts +450 -0
- package/ide/vscode/src/stacks.ts +482 -0
- package/ide/vscode/tests/build.test.ts +58 -0
- package/ide/vscode/tests/env.test.ts +166 -0
- package/ide/vscode/tests/extension.test.ts +428 -0
- package/ide/vscode/tests/fake-vscode.ts +228 -0
- package/ide/vscode/tests/manifest.test.ts +88 -0
- package/ide/vscode/tests/pickier.test.ts +202 -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/Content/FileManagerDashboard.stx +2 -0
- package/resources/components/Dashboard/FileViewer/MediaCard.stx +14 -3
- package/routes/auth.ts +4 -0
- package/vcs/github/workflows/buddy-bot.yml +2 -2
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough of the `vscode` API for ./pickier.test.ts and ./env.test.ts:
|
|
3
|
+
* documents, diagnostics, code actions, hovers and the provider registries,
|
|
4
|
+
* recorded so tests can call the providers the way the editor would.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type Listener<T> = (event: T) => unknown
|
|
8
|
+
|
|
9
|
+
export function emitter<T>() {
|
|
10
|
+
const listeners = new Set<Listener<T>>()
|
|
11
|
+
return {
|
|
12
|
+
event: (listener: Listener<T>) => {
|
|
13
|
+
listeners.add(listener)
|
|
14
|
+
return { dispose: () => listeners.delete(listener) }
|
|
15
|
+
},
|
|
16
|
+
fire: (value: T) => {
|
|
17
|
+
for (const listener of [...listeners]) listener(value)
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Position {
|
|
23
|
+
constructor(readonly line: number, readonly character: number) {}
|
|
24
|
+
translate(lines = 0, characters = 0) {
|
|
25
|
+
return new Position(this.line + lines, this.character + characters)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Range {
|
|
30
|
+
readonly start: Position
|
|
31
|
+
readonly end: Position
|
|
32
|
+
constructor(a: Position | number, b: Position | number, c?: number, d?: number) {
|
|
33
|
+
this.start = typeof a === 'number' ? new Position(a, b as number) : a
|
|
34
|
+
this.end = typeof a === 'number' ? new Position(c as number, d as number) : b as Position
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CodeActionKind {
|
|
39
|
+
static readonly QuickFix = new CodeActionKind('quickfix')
|
|
40
|
+
static readonly SourceFixAll = new CodeActionKind('source.fixAll')
|
|
41
|
+
constructor(readonly value: string) {}
|
|
42
|
+
append(part: string) {
|
|
43
|
+
return new CodeActionKind(`${this.value}.${part}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
contains(other: CodeActionKind) {
|
|
47
|
+
return other.value === this.value || other.value.startsWith(`${this.value}.`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class WorkspaceEdit {
|
|
52
|
+
readonly edits: Array<{ uri: unknown, range?: Range, position?: Position, text: string }> = []
|
|
53
|
+
replace(uri: unknown, range: Range, text: string) {
|
|
54
|
+
this.edits.push({ uri, range, text })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
insert(uri: unknown, position: Position, text: string) {
|
|
58
|
+
this.edits.push({ uri, position, text })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class MarkdownString {
|
|
63
|
+
value = ''
|
|
64
|
+
appendMarkdown(text: string) {
|
|
65
|
+
this.value += text
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
appendCodeblock(code: string, language = '') {
|
|
70
|
+
this.value += `\n\`\`\`${language}\n${code}\n\`\`\`\n`
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FakeDocument {
|
|
76
|
+
uri: { fsPath: string, scheme: string, toString: () => string }
|
|
77
|
+
languageId: string
|
|
78
|
+
version: number
|
|
79
|
+
isClosed: boolean
|
|
80
|
+
lineCount: number
|
|
81
|
+
getText: () => string
|
|
82
|
+
lineAt: (line: number) => { text: string, lineNumber: number, range: Range }
|
|
83
|
+
getWordRangeAtPosition: (position: Position) => Range | undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function document(path: string, text: string, languageId = 'typescript'): FakeDocument {
|
|
87
|
+
const lines = text.split('\n')
|
|
88
|
+
return {
|
|
89
|
+
uri: { fsPath: path, scheme: 'file', toString: () => `file://${path}` },
|
|
90
|
+
languageId,
|
|
91
|
+
version: 1,
|
|
92
|
+
isClosed: false,
|
|
93
|
+
lineCount: lines.length,
|
|
94
|
+
getText: () => text,
|
|
95
|
+
lineAt: (line: number) => ({ text: lines[line] ?? '', lineNumber: line, range: new Range(line, 0, line, (lines[line] ?? '').length) }),
|
|
96
|
+
getWordRangeAtPosition: (position: Position) => {
|
|
97
|
+
const line = lines[position.line] ?? ''
|
|
98
|
+
const match = /\w+/.exec(line.slice(position.character))
|
|
99
|
+
return match && match.index === 0 ? new Range(position.line, position.character, position.line, position.character + match[0].length) : undefined
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function fakeVscode(options: { folders: string[], settings?: Record<string, unknown>, documents?: FakeDocument[] }) {
|
|
105
|
+
const settings = options.settings ?? {}
|
|
106
|
+
const diagnostics = new Map<string, any[]>()
|
|
107
|
+
const handlers = new Map<string, (...args: any[]) => any>()
|
|
108
|
+
const codeActionProviders: any[] = []
|
|
109
|
+
const formatters: any[] = []
|
|
110
|
+
const hovers: any[] = []
|
|
111
|
+
const messages: string[] = []
|
|
112
|
+
const answers: Array<string | undefined> = []
|
|
113
|
+
const inputs: any[] = []
|
|
114
|
+
const output: string[] = []
|
|
115
|
+
const applied: WorkspaceEdit[] = []
|
|
116
|
+
const clipboard: string[] = []
|
|
117
|
+
const opened = emitter<FakeDocument>()
|
|
118
|
+
const changed = emitter<{ document: FakeDocument }>()
|
|
119
|
+
const saved = emitter<FakeDocument>()
|
|
120
|
+
const closed = emitter<FakeDocument>()
|
|
121
|
+
|
|
122
|
+
const message = (text: string) => {
|
|
123
|
+
messages.push(text)
|
|
124
|
+
return Promise.resolve(answers.shift())
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const api = {
|
|
128
|
+
Position,
|
|
129
|
+
Range,
|
|
130
|
+
CodeActionKind,
|
|
131
|
+
WorkspaceEdit,
|
|
132
|
+
MarkdownString,
|
|
133
|
+
DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 },
|
|
134
|
+
Diagnostic: class {
|
|
135
|
+
source?: string
|
|
136
|
+
code?: string
|
|
137
|
+
constructor(readonly range: Range, readonly message: string, readonly severity: number) {}
|
|
138
|
+
},
|
|
139
|
+
CodeAction: class {
|
|
140
|
+
edit?: WorkspaceEdit
|
|
141
|
+
diagnostics?: unknown[]
|
|
142
|
+
constructor(readonly title: string, readonly kind: CodeActionKind) {}
|
|
143
|
+
},
|
|
144
|
+
Hover: class {
|
|
145
|
+
constructor(readonly contents: MarkdownString, readonly range?: Range) {}
|
|
146
|
+
},
|
|
147
|
+
TextEdit: { replace: (range: Range, newText: string) => ({ range, newText }) },
|
|
148
|
+
commands: {
|
|
149
|
+
registerCommand: (id: string, handler: (...args: any[]) => any) => {
|
|
150
|
+
handlers.set(id, handler)
|
|
151
|
+
return { dispose: () => handlers.delete(id) }
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
env: { clipboard: { writeText: async (text: string) => { clipboard.push(text) } } },
|
|
155
|
+
languages: {
|
|
156
|
+
createDiagnosticCollection: () => ({
|
|
157
|
+
set: (uri: { toString: () => string }, list: any[]) => diagnostics.set(uri.toString(), list),
|
|
158
|
+
delete: (uri: { toString: () => string }) => diagnostics.delete(uri.toString()),
|
|
159
|
+
clear: () => diagnostics.clear(),
|
|
160
|
+
dispose: () => {},
|
|
161
|
+
}),
|
|
162
|
+
registerCodeActionsProvider: (_selector: unknown, provider: unknown) => {
|
|
163
|
+
codeActionProviders.push(provider)
|
|
164
|
+
return { dispose: () => {} }
|
|
165
|
+
},
|
|
166
|
+
registerDocumentFormattingEditProvider: (_selector: unknown, provider: unknown) => {
|
|
167
|
+
formatters.push(provider)
|
|
168
|
+
return { dispose: () => {} }
|
|
169
|
+
},
|
|
170
|
+
registerHoverProvider: (_selector: unknown, provider: unknown) => {
|
|
171
|
+
hovers.push(provider)
|
|
172
|
+
return { dispose: () => {} }
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
window: {
|
|
176
|
+
activeTextEditor: undefined as undefined | { document: FakeDocument },
|
|
177
|
+
createOutputChannel: () => ({ appendLine: (line: string) => output.push(line), dispose: () => {} }),
|
|
178
|
+
showInformationMessage: message,
|
|
179
|
+
showWarningMessage: message,
|
|
180
|
+
showErrorMessage: message,
|
|
181
|
+
showInputBox: (inputOptions: unknown) => {
|
|
182
|
+
inputs.push(inputOptions)
|
|
183
|
+
return Promise.resolve(answers.shift())
|
|
184
|
+
},
|
|
185
|
+
showQuickPick: (items: string[], pickOptions: unknown) => {
|
|
186
|
+
inputs.push({ items, ...pickOptions as object })
|
|
187
|
+
return Promise.resolve(answers.shift())
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
workspace: {
|
|
191
|
+
workspaceFolders: options.folders.map((folder, index) => ({ uri: { fsPath: folder }, name: folder, index })),
|
|
192
|
+
textDocuments: options.documents ?? [],
|
|
193
|
+
getConfiguration: () => ({ get: (key: string, fallback: unknown) => key in settings ? settings[key] : fallback }),
|
|
194
|
+
getWorkspaceFolder: (uri: { fsPath: string }) => {
|
|
195
|
+
const folder = options.folders.find(candidate => uri.fsPath.startsWith(`${candidate}/`))
|
|
196
|
+
return folder ? { uri: { fsPath: folder } } : undefined
|
|
197
|
+
},
|
|
198
|
+
applyEdit: async (edit: WorkspaceEdit) => {
|
|
199
|
+
applied.push(edit)
|
|
200
|
+
return true
|
|
201
|
+
},
|
|
202
|
+
onDidOpenTextDocument: opened.event,
|
|
203
|
+
onDidChangeTextDocument: changed.event,
|
|
204
|
+
onDidSaveTextDocument: saved.event,
|
|
205
|
+
onDidCloseTextDocument: closed.event,
|
|
206
|
+
onDidChangeConfiguration: emitter<any>().event,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
api,
|
|
212
|
+
diagnostics,
|
|
213
|
+
handlers,
|
|
214
|
+
codeActionProviders,
|
|
215
|
+
formatters,
|
|
216
|
+
hovers,
|
|
217
|
+
messages,
|
|
218
|
+
answers,
|
|
219
|
+
inputs,
|
|
220
|
+
output,
|
|
221
|
+
applied,
|
|
222
|
+
clipboard,
|
|
223
|
+
events: { opened, changed, saved, closed },
|
|
224
|
+
context: { subscriptions: [] as Array<{ dispose: () => unknown }> },
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const flush = () => new Promise(resolve => setTimeout(resolve, 10))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extension manifest.
|
|
3
|
+
*
|
|
4
|
+
* The Stacks extension is self-contained: stx support, pickier and env files
|
|
5
|
+
* are built in, so it installs no extension pack and depends on no other
|
|
6
|
+
* extension. stx support comes from `@stacksjs/stx-vscode`, the library build
|
|
7
|
+
* of the stx extension (Stacks.vscode-stx); with both installed, the stx
|
|
8
|
+
* extension stands down (stacksjs/stx#2020).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from 'bun:test'
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { libraryContributes, withStxContributes } from '../scripts/stx-contributes'
|
|
14
|
+
|
|
15
|
+
const root = join(import.meta.dir, '..')
|
|
16
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
17
|
+
const STX_EXTENSION_ID = 'Stacks.vscode-stx'
|
|
18
|
+
const projectSettings = Bun.JSONC.parse(readFileSync(join(root, '.vscode/settings.json'), 'utf8')) as Record<string, any>
|
|
19
|
+
const recommendations = (Bun.JSONC.parse(readFileSync(join(root, '.vscode/extensions.json'), 'utf8')) as { recommendations: string[] }).recommendations
|
|
20
|
+
|
|
21
|
+
describe('marketplace identity', () => {
|
|
22
|
+
it('is Stacks.vscode-stacks, not the stx extension', () => {
|
|
23
|
+
const id = `${manifest.publisher}.${manifest.name}`
|
|
24
|
+
expect(id).toBe('Stacks.vscode-stacks')
|
|
25
|
+
expect(id.toLowerCase()).not.toBe(STX_EXTENSION_ID.toLowerCase())
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('self-contained', () => {
|
|
30
|
+
it('installs no extension pack and depends on no extension', () => {
|
|
31
|
+
expect(manifest.extensionPack).toBeUndefined()
|
|
32
|
+
expect(manifest.extensionDependencies).toBeUndefined()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('has no runtime dependencies to ship', () => {
|
|
36
|
+
expect(manifest.dependencies).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('recommends only itself to new projects', () => {
|
|
40
|
+
expect(recommendations.map(id => id.toLowerCase())).toEqual(['stacks.vscode-stacks'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('configures new projects for no other extension', () => {
|
|
44
|
+
// Settings that only mean something to an extension the project no longer
|
|
45
|
+
// asks for: ESLint, Prettier, Biome, markdownlint, shell-format, cSpell,
|
|
46
|
+
// Grammarly, vscode-icons, Todo Tree.
|
|
47
|
+
const foreign = Object.keys(projectSettings).filter(key =>
|
|
48
|
+
/^(?:eslint|prettier|biome|markdownlint|cSpell|grammarly|vsicons|todo-tree)\./.test(key))
|
|
49
|
+
expect(foreign).toEqual([])
|
|
50
|
+
expect(JSON.stringify(projectSettings)).not.toMatch(/dbaeumer|foxundermoon|DavidAnson|esbenp/i)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('formats and fixes with pickier through this extension', () => {
|
|
54
|
+
expect(projectSettings['editor.defaultFormatter']).toBe('Stacks.vscode-stacks')
|
|
55
|
+
expect(projectSettings['editor.codeActionsOnSave']['source.fixAll.pickier']).toBe('explicit')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('stx language support', () => {
|
|
60
|
+
it('declares what @stacksjs/stx-vscode ships (run `bun run sync:stx` after updating it)', () => {
|
|
61
|
+
expect(withStxContributes(manifest, libraryContributes(root))).toEqual(manifest)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('declares the stx language and its grammar from the bundled assets', () => {
|
|
65
|
+
expect(manifest.contributes.languages.map((language: { id: string }) => language.id)).toContain('stx')
|
|
66
|
+
expect(manifest.contributes.grammars[0].path).toStartWith('./dist/stx/')
|
|
67
|
+
expect(manifest.files).toContain('dist/stx/**')
|
|
68
|
+
expect(manifest.activationEvents).toContain('onLanguage:stx')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('leaves snippets to runtime, so they never show twice next to the stx extension', () => {
|
|
72
|
+
expect(manifest.contributes.snippets).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not map .stx files to another language', () => {
|
|
76
|
+
const extensionDefaults = manifest.contributes.configurationDefaults['files.associations'] ?? {}
|
|
77
|
+
for (const associations of [extensionDefaults, projectSettings['files.associations'] ?? {}])
|
|
78
|
+
expect(Object.keys(associations).filter(pattern => pattern.endsWith('.stx'))).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('packaging', () => {
|
|
83
|
+
it('ships the files the extension loads at runtime', () => {
|
|
84
|
+
for (const file of ['dist/extension.js', 'dist/pickier-worker.js', 'dist/pickier-worker.bunfig.toml'])
|
|
85
|
+
expect(manifest.files).toContain(file)
|
|
86
|
+
expect(manifest.main).toBe('./dist/extension.js')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pickier support (src/pickier.ts), driven through a fake `vscode` API and a
|
|
3
|
+
* fake worker process: no editor, no Bun, no pickier.
|
|
4
|
+
*/
|
|
5
|
+
import type { PickierProcess } from '../src/pickier'
|
|
6
|
+
import type { PickierRequest, PickierResult } from '../src/pickier-protocol'
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
import { chooseBun, createPickierSupport, disableNextLineComment, isLintedFile, isPickierConfigPath } from '../src/pickier'
|
|
9
|
+
import { CodeActionKind, document, fakeVscode, flush } from './fake-vscode'
|
|
10
|
+
|
|
11
|
+
const ROOT = '/work/app'
|
|
12
|
+
|
|
13
|
+
interface Worker {
|
|
14
|
+
answer?: (request: PickierRequest) => PickierResult
|
|
15
|
+
ready?: false
|
|
16
|
+
fixText?: boolean
|
|
17
|
+
exitOnStart?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setup(options: { worker?: Worker, files?: string[], settings?: Record<string, unknown>, documents?: ReturnType<typeof document>[] } = {}) {
|
|
21
|
+
const files = new Set(options.files ?? [`${ROOT}/buddy`])
|
|
22
|
+
const fake = fakeVscode({ folders: [ROOT], settings: options.settings, documents: options.documents })
|
|
23
|
+
const spawned: Array<{ bun: string, script: string, cwd: string, requests: PickierRequest[] }> = []
|
|
24
|
+
|
|
25
|
+
const host = {
|
|
26
|
+
exists: (path: string) => files.has(path),
|
|
27
|
+
spawnBun(bun: string, script: string, cwd: string): PickierProcess {
|
|
28
|
+
const record = { bun, script, cwd, requests: [] as PickierRequest[] }
|
|
29
|
+
spawned.push(record)
|
|
30
|
+
const lineListeners: Array<(line: string) => void> = []
|
|
31
|
+
const exitListeners: Array<() => void> = []
|
|
32
|
+
const emit = (message: unknown) => queueMicrotask(() => lineListeners.forEach(listener => listener(JSON.stringify(message))))
|
|
33
|
+
const worker = options.worker ?? {}
|
|
34
|
+
|
|
35
|
+
queueMicrotask(() => {
|
|
36
|
+
if (worker.exitOnStart)
|
|
37
|
+
return exitListeners.forEach(listener => listener())
|
|
38
|
+
if (worker.ready === false)
|
|
39
|
+
return emit({ ready: false, error: 'Cannot find package \'pickier\'', missing: true })
|
|
40
|
+
emit({ ready: true, version: '0.1.66', fixText: worker.fixText ?? true })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
write(line) {
|
|
45
|
+
const request = JSON.parse(line) as PickierRequest
|
|
46
|
+
record.requests.push(request)
|
|
47
|
+
const result = worker.answer?.(request) ?? (request.op === 'lint' ? { op: 'lint', issues: [] } : { op: request.op, text: null })
|
|
48
|
+
emit({ id: request.id, ok: true, result })
|
|
49
|
+
},
|
|
50
|
+
onLine: listener => lineListeners.push(listener),
|
|
51
|
+
onExit: listener => exitListeners.push(listener),
|
|
52
|
+
kill: () => {},
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const support = createPickierSupport(fake.api as any, host, { workerPath: '/ext/dist/pickier-worker.js', lintDelayMs: 1 })
|
|
58
|
+
support.activate(fake.context)
|
|
59
|
+
return { ...fake, spawned, support }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('helpers', () => {
|
|
63
|
+
it('runs pickier on the configured Bun, else the project\'s pinned Bun, else bun on the PATH', () => {
|
|
64
|
+
expect(chooseBun(' /opt/bun ', ROOT, () => true)).toBe('/opt/bun')
|
|
65
|
+
expect(chooseBun('', ROOT, path => path === `${ROOT}/pantry/.bin/bun`)).toBe(`${ROOT}/pantry/.bin/bun`)
|
|
66
|
+
expect(chooseBun('', ROOT, () => false)).toBe('bun')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('lints exactly the files `buddy lint` does', () => {
|
|
70
|
+
expect(isLintedFile(ROOT, `${ROOT}/app/Actions/Hello.ts`)).toBeTrue()
|
|
71
|
+
expect(isLintedFile(ROOT, `${ROOT}/README.md`)).toBeTrue()
|
|
72
|
+
expect(isLintedFile(ROOT, `${ROOT}/node_modules/x/index.ts`)).toBeFalse()
|
|
73
|
+
expect(isLintedFile(ROOT, `${ROOT}/dist/index.js`)).toBeFalse()
|
|
74
|
+
expect(isLintedFile(ROOT, `${ROOT}/resources/views/index.stx`)).toBeFalse()
|
|
75
|
+
expect(isLintedFile(ROOT, '/elsewhere/a.ts')).toBeFalse()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('recognises the files pickier reads its config from', () => {
|
|
79
|
+
expect(isPickierConfigPath(`${ROOT}/config/code-style.ts`)).toBeTrue()
|
|
80
|
+
expect(isPickierConfigPath(`${ROOT}/pickier.config.ts`)).toBeTrue()
|
|
81
|
+
expect(isPickierConfigPath(`${ROOT}/.config/pickier.ts`)).toBeTrue()
|
|
82
|
+
expect(isPickierConfigPath(`${ROOT}/config/app.ts`)).toBeFalse()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('disables a rule for the next line at that line\'s indentation', () => {
|
|
86
|
+
expect(disableNextLineComment(' let x = 1', 'prefer-const', '//')).toBe(' // pickier-disable-next-line prefer-const\n')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('diagnostics', () => {
|
|
91
|
+
it('reports the project pickier\'s issues on an open file, in editor coordinates', async () => {
|
|
92
|
+
const file = document(`${ROOT}/app/x.ts`, 'import { a } from \'b\'\nlet x = 1\n')
|
|
93
|
+
const { diagnostics, spawned } = setup({
|
|
94
|
+
documents: [file],
|
|
95
|
+
worker: {
|
|
96
|
+
answer: () => ({ op: 'lint', issues: [{ line: 2, column: 5, ruleId: 'prefer-const', message: '\'x\' is never reassigned', severity: 'error', help: 'Use const' }] }),
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
await flush()
|
|
100
|
+
|
|
101
|
+
expect(spawned).toHaveLength(1)
|
|
102
|
+
expect(spawned[0]).toMatchObject({ bun: 'bun', script: '/ext/dist/pickier-worker.js', cwd: ROOT })
|
|
103
|
+
expect(spawned[0]!.requests[0]).toMatchObject({ op: 'lint', file: `${ROOT}/app/x.ts` })
|
|
104
|
+
|
|
105
|
+
const [diagnostic] = diagnostics.get(`file://${ROOT}/app/x.ts`)!
|
|
106
|
+
expect(diagnostic.source).toBe('pickier')
|
|
107
|
+
expect(diagnostic.code).toBe('prefer-const')
|
|
108
|
+
expect(diagnostic.severity).toBe(0)
|
|
109
|
+
expect(diagnostic.range.start).toMatchObject({ line: 1, character: 4 })
|
|
110
|
+
expect(diagnostic.range.end).toMatchObject({ line: 1, character: 5 })
|
|
111
|
+
expect(diagnostic.message).toBe('\'x\' is never reassigned\nUse const')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('leaves folders that are not Stacks projects alone', async () => {
|
|
115
|
+
const { spawned } = setup({ files: [], documents: [document(`${ROOT}/x.ts`, 'let x = 1\n')] })
|
|
116
|
+
await flush()
|
|
117
|
+
expect(spawned).toHaveLength(0)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('lints nothing when turned off', async () => {
|
|
121
|
+
const { spawned } = setup({ settings: { 'pickier.enable': false }, documents: [document(`${ROOT}/x.ts`, 'let x = 1\n')] })
|
|
122
|
+
await flush()
|
|
123
|
+
expect(spawned).toHaveLength(0)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('stops asking a project with no pickier, and says why once', async () => {
|
|
127
|
+
const { spawned, events, output, diagnostics } = setup({ worker: { ready: false }, documents: [document(`${ROOT}/a.ts`, '')] })
|
|
128
|
+
await flush()
|
|
129
|
+
events.opened.fire(document(`${ROOT}/b.ts`, '') as any)
|
|
130
|
+
await flush()
|
|
131
|
+
|
|
132
|
+
expect(spawned).toHaveLength(1)
|
|
133
|
+
expect(diagnostics.size).toBe(0)
|
|
134
|
+
expect(output.filter(line => line.includes('not available'))).toHaveLength(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('gives up on a worker that keeps exiting instead of respawning it on every keystroke', async () => {
|
|
138
|
+
const { spawned, events } = setup({ worker: { exitOnStart: true } })
|
|
139
|
+
for (let i = 0; i < 5; i++) {
|
|
140
|
+
events.opened.fire(document(`${ROOT}/x${i}.ts`, '') as any)
|
|
141
|
+
await flush()
|
|
142
|
+
}
|
|
143
|
+
expect(spawned).toHaveLength(3)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('fixing and formatting', () => {
|
|
148
|
+
const FIXED = 'const x = 1\n'
|
|
149
|
+
|
|
150
|
+
it('offers source.fixAll.pickier, the code action codeActionsOnSave asks for', async () => {
|
|
151
|
+
const file = document(`${ROOT}/x.ts`, 'let x = 1\n')
|
|
152
|
+
const { codeActionProviders, spawned } = setup({ worker: { answer: request => request.op === 'fix' ? { op: 'fix', text: FIXED } : { op: 'lint', issues: [] } } })
|
|
153
|
+
|
|
154
|
+
const only = CodeActionKind.SourceFixAll.append('pickier')
|
|
155
|
+
const [action] = await codeActionProviders[0].provideCodeActions(file, undefined, { only, diagnostics: [] })
|
|
156
|
+
|
|
157
|
+
expect(action.kind.value).toBe('source.fixAll.pickier')
|
|
158
|
+
expect(action.edit.edits[0].text).toBe(FIXED)
|
|
159
|
+
expect(spawned[0]!.requests.at(-1)!.op).toBe('fix')
|
|
160
|
+
|
|
161
|
+
// The generic `source.fixAll` includes it too.
|
|
162
|
+
const generic = await codeActionProviders[0].provideCodeActions(file, undefined, { only: CodeActionKind.SourceFixAll, diagnostics: [] })
|
|
163
|
+
expect(generic).toHaveLength(1)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('offers nothing when there is nothing to fix, or the project\'s pickier cannot fix text', async () => {
|
|
167
|
+
const file = document(`${ROOT}/x.ts`, FIXED)
|
|
168
|
+
const unchanged = setup({ worker: { answer: request => ({ op: request.op as 'fix', text: FIXED }) } })
|
|
169
|
+
expect(await unchanged.codeActionProviders[0].provideCodeActions(file, undefined, { only: CodeActionKind.SourceFixAll, diagnostics: [] })).toEqual([])
|
|
170
|
+
|
|
171
|
+
const old = setup({ worker: { fixText: false } })
|
|
172
|
+
expect(await old.codeActionProviders[0].provideCodeActions(document(`${ROOT}/x.ts`, 'let x = 1\n'), undefined, { only: CodeActionKind.SourceFixAll, diagnostics: [] })).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('offers quick fixes for its own diagnostics: fix all, and disable the rule for the line', async () => {
|
|
176
|
+
const file = document(`${ROOT}/x.ts`, 'function f() {\n let x = 1\n}\n')
|
|
177
|
+
const { codeActionProviders } = setup({ worker: { answer: request => request.op === 'fix' ? { op: 'fix', text: 'function f() {\n const x = 1\n}\n' } : { op: 'lint', issues: [] } } })
|
|
178
|
+
const diagnostic = { source: 'pickier', code: 'prefer-const', range: { start: { line: 1, character: 6 } } }
|
|
179
|
+
|
|
180
|
+
const actions = await codeActionProviders[0].provideCodeActions(file, undefined, { diagnostics: [diagnostic, { source: 'ts', code: 1 }] })
|
|
181
|
+
|
|
182
|
+
expect(actions.map((action: any) => action.title)).toEqual(['Fix all pickier problems', 'Disable prefer-const for this line'])
|
|
183
|
+
expect(actions[1].edit.edits[0]).toMatchObject({ position: { line: 1, character: 0 }, text: ' // pickier-disable-next-line prefer-const\n' })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('formats with the project\'s pickier', async () => {
|
|
187
|
+
const file = document(`${ROOT}/x.ts`, 'const a = "x"\n')
|
|
188
|
+
const { formatters } = setup({ worker: { answer: request => request.op === 'format' ? { op: 'format', text: 'const a = \'x\'\n' } : { op: 'lint', issues: [] } } })
|
|
189
|
+
|
|
190
|
+
const [edit] = await formatters[0].provideDocumentFormattingEdits(file)
|
|
191
|
+
expect(edit.newText).toBe('const a = \'x\'\n')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('applies the fixes to the active file from the command palette', async () => {
|
|
195
|
+
const file = document(`${ROOT}/x.ts`, 'let x = 1\n')
|
|
196
|
+
const fake = setup({ worker: { answer: request => request.op === 'fix' ? { op: 'fix', text: FIXED } : { op: 'lint', issues: [] } } })
|
|
197
|
+
fake.api.window.activeTextEditor = { document: file }
|
|
198
|
+
|
|
199
|
+
await fake.handlers.get('stacks.pickier.fixAll')!()
|
|
200
|
+
expect(fake.applied[0]!.edits[0]!.text).toBe(FIXED)
|
|
201
|
+
})
|
|
202
|
+
})
|