@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,487 @@
|
|
|
1
|
+
// File previews (stacksjs/stacks#308).
|
|
2
|
+
//
|
|
3
|
+
// Unlike the rest of the media pipeline, the renderers ARE tested here, not
|
|
4
|
+
// just the dispatch: a preview is drawn by this module rather than handed to a
|
|
5
|
+
// library that owns the encoding, so whether a WAV becomes a waveform is this
|
|
6
|
+
// file's business. Every fixture is built in the test - a PNG from ts-images,
|
|
7
|
+
// a WAV from a sine wave, a TrueType font from its tables - so nothing depends
|
|
8
|
+
// on a binary checked in beside it.
|
|
9
|
+
//
|
|
10
|
+
// The store is the in-memory one and the disks are real temp directories, as
|
|
11
|
+
// in the other file-manager suites.
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
14
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { StorageManager } from '@stacksjs/storage'
|
|
18
|
+
import { createImageData, decode, encode } from 'ts-images'
|
|
19
|
+
import type { DashboardFileNode } from './file-manager'
|
|
20
|
+
import {
|
|
21
|
+
deleteDashboardFile,
|
|
22
|
+
dispatchDashboardFileTasks,
|
|
23
|
+
getDashboardFileSnapshot,
|
|
24
|
+
renameDashboardFile,
|
|
25
|
+
reprocessDashboardFile,
|
|
26
|
+
} from './file-manager'
|
|
27
|
+
import { aggregateTaskState, createMemoryMetadataStore, runTask, StorageTaskSkipped } from './file-metadata'
|
|
28
|
+
import {
|
|
29
|
+
claimedContentType,
|
|
30
|
+
generateStoragePreview,
|
|
31
|
+
PREVIEW_MAX_EDGE,
|
|
32
|
+
previewPathFor,
|
|
33
|
+
previewPlanFor,
|
|
34
|
+
readWavFormat,
|
|
35
|
+
TEXT_PREVIEW_MAX_COLUMNS,
|
|
36
|
+
TEXT_PREVIEW_MAX_LINES,
|
|
37
|
+
textSnippet,
|
|
38
|
+
} from './file-preview'
|
|
39
|
+
|
|
40
|
+
let root = ''
|
|
41
|
+
let manager: StorageManager
|
|
42
|
+
let store = createMemoryMetadataStore()
|
|
43
|
+
let dispatched: Array<{ job: string, payload: Record<string, unknown> }> = []
|
|
44
|
+
const dispatch = async (job: string, payload: Record<string, unknown>): Promise<void> => {
|
|
45
|
+
dispatched.push({ job, payload })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
store = createMemoryMetadataStore()
|
|
50
|
+
dispatched = []
|
|
51
|
+
root = await mkdtemp(join(tmpdir(), 'stacks-file-preview-'))
|
|
52
|
+
await mkdir(join(root, 'public'), { recursive: true })
|
|
53
|
+
await mkdir(join(root, 'private'), { recursive: true })
|
|
54
|
+
manager = new StorageManager().init({
|
|
55
|
+
default: 'public',
|
|
56
|
+
disks: {
|
|
57
|
+
public: { driver: 'local', root: join(root, 'public'), url: '/storage', visibility: 'public' },
|
|
58
|
+
private: { driver: 'local', root: join(root, 'private'), visibility: 'private' },
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
manager.reset()
|
|
65
|
+
await rm(root, { force: true, recursive: true })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function nodeAt(node: DashboardFileNode, path: string): DashboardFileNode | undefined {
|
|
69
|
+
if (node.path === path)
|
|
70
|
+
return node
|
|
71
|
+
for (const child of node.items ?? []) {
|
|
72
|
+
const found = nodeAt(child, path)
|
|
73
|
+
if (found)
|
|
74
|
+
return found
|
|
75
|
+
}
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function pngFixture(width = 800, height = 400): Promise<Uint8Array> {
|
|
80
|
+
return await encode(createImageData(width, height, { hasAlpha: true, fill: { r: 200, g: 40, b: 40, a: 255 } }), 'png')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A mono 16-bit PCM WAV: one second of a swelling sine. */
|
|
84
|
+
function wavFixture(sampleRate = 8000): Uint8Array {
|
|
85
|
+
const frames = sampleRate
|
|
86
|
+
const view = new DataView(new ArrayBuffer(44 + frames * 2))
|
|
87
|
+
const tag = (offset: number, value: string): void => {
|
|
88
|
+
for (let i = 0; i < 4; i++) view.setUint8(offset + i, value.charCodeAt(i))
|
|
89
|
+
}
|
|
90
|
+
tag(0, 'RIFF')
|
|
91
|
+
view.setUint32(4, 36 + frames * 2, true)
|
|
92
|
+
tag(8, 'WAVE')
|
|
93
|
+
tag(12, 'fmt ')
|
|
94
|
+
view.setUint32(16, 16, true)
|
|
95
|
+
view.setUint16(20, 1, true) // PCM
|
|
96
|
+
view.setUint16(22, 1, true) // mono
|
|
97
|
+
view.setUint32(24, sampleRate, true)
|
|
98
|
+
view.setUint32(28, sampleRate * 2, true)
|
|
99
|
+
view.setUint16(32, 2, true)
|
|
100
|
+
view.setUint16(34, 16, true)
|
|
101
|
+
tag(36, 'data')
|
|
102
|
+
view.setUint32(40, frames * 2, true)
|
|
103
|
+
for (let i = 0; i < frames; i++)
|
|
104
|
+
view.setInt16(44 + i * 2, Math.round(Math.sin(i / 8) * 30000 * (i / frames)), true)
|
|
105
|
+
return new Uint8Array(view.buffer)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A minimal TrueType font: every printable ASCII character is a square.
|
|
110
|
+
*
|
|
111
|
+
* Built table by table - head, hhea, maxp, cmap (format 4), hmtx, loca, glyf -
|
|
112
|
+
* because the only TrueType files on hand are licensed fonts, and the
|
|
113
|
+
* rasterizer needs nothing more than these seven tables to draw.
|
|
114
|
+
*/
|
|
115
|
+
function fontFixture(): Uint8Array {
|
|
116
|
+
const glyphCount = 0x7F
|
|
117
|
+
const bytes = (size: number, fill: (view: DataView) => void): Uint8Array => {
|
|
118
|
+
const view = new DataView(new ArrayBuffer(size))
|
|
119
|
+
fill(view)
|
|
120
|
+
return new Uint8Array(view.buffer)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const head = bytes(54, (v) => {
|
|
124
|
+
v.setUint32(0, 0x00010000)
|
|
125
|
+
v.setUint32(12, 0x5F0F3CF5)
|
|
126
|
+
v.setUint16(18, 1000) // unitsPerEm
|
|
127
|
+
v.setInt16(50, 1) // long loca offsets
|
|
128
|
+
})
|
|
129
|
+
const hhea = bytes(36, (v) => {
|
|
130
|
+
v.setUint32(0, 0x00010000)
|
|
131
|
+
v.setInt16(4, 800)
|
|
132
|
+
v.setInt16(6, -200)
|
|
133
|
+
v.setUint16(34, 1) // numberOfHMetrics: every glyph shares one advance
|
|
134
|
+
})
|
|
135
|
+
const maxp = bytes(6, (v) => {
|
|
136
|
+
v.setUint32(0, 0x00005000)
|
|
137
|
+
v.setUint16(4, glyphCount)
|
|
138
|
+
})
|
|
139
|
+
const hmtx = bytes(4, v => v.setUint16(0, 700))
|
|
140
|
+
|
|
141
|
+
// One segment mapping 0x20-0x7E straight onto glyph ids, plus the 0xFFFF terminator.
|
|
142
|
+
const cmap = bytes(12 + 32, (v) => {
|
|
143
|
+
v.setUint16(2, 1) // one subtable
|
|
144
|
+
v.setUint16(4, 3) // Windows
|
|
145
|
+
v.setUint16(6, 1) // Unicode BMP
|
|
146
|
+
v.setUint32(8, 12)
|
|
147
|
+
const s = 12
|
|
148
|
+
v.setUint16(s, 4)
|
|
149
|
+
v.setUint16(s + 2, 32)
|
|
150
|
+
v.setUint16(s + 6, 4) // segCountX2
|
|
151
|
+
v.setUint16(s + 14, 0x7E) // endCodes
|
|
152
|
+
v.setUint16(s + 16, 0xFFFF)
|
|
153
|
+
v.setUint16(s + 20, 0x20) // startCodes, after reservedPad
|
|
154
|
+
v.setUint16(s + 22, 0xFFFF)
|
|
155
|
+
v.setInt16(s + 26, 1) // idDelta for the terminator; idRangeOffsets stay 0
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// A square from (100,0) to (600,700): one contour, four on-curve points, word deltas.
|
|
159
|
+
const square = bytes(34, (v) => {
|
|
160
|
+
v.setInt16(0, 1)
|
|
161
|
+
v.setInt16(2, 100)
|
|
162
|
+
v.setInt16(6, 600)
|
|
163
|
+
v.setInt16(8, 700)
|
|
164
|
+
v.setUint16(10, 3) // endPtsOfContours
|
|
165
|
+
for (let i = 0; i < 4; i++) v.setUint8(14 + i, 0x01)
|
|
166
|
+
;[100, 500, 0, -500].forEach((dx, i) => v.setInt16(18 + i * 2, dx))
|
|
167
|
+
;[0, 0, 700, 0].forEach((dy, i) => v.setInt16(26 + i * 2, dy))
|
|
168
|
+
})
|
|
169
|
+
const glyf = new Uint8Array(square.byteLength * (glyphCount - 1))
|
|
170
|
+
for (let i = 1; i < glyphCount; i++) glyf.set(square, (i - 1) * square.byteLength)
|
|
171
|
+
const loca = bytes((glyphCount + 1) * 4, (v) => {
|
|
172
|
+
v.setUint32(0, 0) // glyph 0 is empty
|
|
173
|
+
for (let i = 1; i <= glyphCount; i++) v.setUint32(i * 4, (i - 1) * square.byteLength)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const tables: Array<[string, Uint8Array]> = [['cmap', cmap], ['glyf', glyf], ['head', head], ['hhea', hhea], ['hmtx', hmtx], ['loca', loca], ['maxp', maxp]]
|
|
177
|
+
const directory = 12 + tables.length * 16
|
|
178
|
+
const total = directory + tables.reduce((sum, [, table]) => sum + table.byteLength, 0)
|
|
179
|
+
const out = new Uint8Array(total)
|
|
180
|
+
const view = new DataView(out.buffer)
|
|
181
|
+
view.setUint32(0, 0x00010000)
|
|
182
|
+
view.setUint16(4, tables.length)
|
|
183
|
+
|
|
184
|
+
let offset = directory
|
|
185
|
+
tables.forEach(([name, table], index) => {
|
|
186
|
+
const record = 12 + index * 16
|
|
187
|
+
for (let i = 0; i < 4; i++) view.setUint8(record + i, name.charCodeAt(i))
|
|
188
|
+
view.setUint32(record + 8, offset)
|
|
189
|
+
view.setUint32(record + 12, table.byteLength)
|
|
190
|
+
out.set(table, offset)
|
|
191
|
+
offset += table.byteLength
|
|
192
|
+
})
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe('previewPlanFor', () => {
|
|
197
|
+
test('draws images, WAV, TrueType and text', () => {
|
|
198
|
+
expect(previewPlanFor('image/png')).toEqual({ renderer: 'image', format: 'png' })
|
|
199
|
+
expect(previewPlanFor('image/jpeg; charset=binary')).toEqual({ renderer: 'image', format: 'png' })
|
|
200
|
+
expect(previewPlanFor('image/webp')).toEqual({ renderer: 'image', format: 'png' })
|
|
201
|
+
expect(previewPlanFor('audio/x-wav')).toEqual({ renderer: 'waveform', format: 'png' })
|
|
202
|
+
expect(previewPlanFor('font/ttf')).toEqual({ renderer: 'font', format: 'png' })
|
|
203
|
+
expect(previewPlanFor('text/markdown')).toEqual({ renderer: 'text', format: 'txt' })
|
|
204
|
+
expect(previewPlanFor('application/json')).toEqual({ renderer: 'text', format: 'txt' })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('skips, with a reason, what nothing here can draw', () => {
|
|
208
|
+
for (const mime of ['image/svg+xml', 'image/heic', 'video/mp4', 'audio/mpeg', 'application/pdf', 'font/woff2', 'font/otf'])
|
|
209
|
+
expect(previewPlanFor(mime)).toHaveProperty('skip')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('SVG is skipped as markup, never handed to a rasterizer', () => {
|
|
213
|
+
expect((previewPlanFor('image/svg+xml') as { skip: string }).skip).toMatch(/never rasterized/)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('says nothing about archives and unknown binaries', () => {
|
|
217
|
+
expect(previewPlanFor('application/zip')).toBeNull()
|
|
218
|
+
expect(previewPlanFor('application/octet-stream')).toBeNull()
|
|
219
|
+
expect(previewPlanFor(undefined)).toBeNull()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('claimedContentType', () => {
|
|
224
|
+
test('takes the reported type, and the extension only when the report says nothing', () => {
|
|
225
|
+
expect(claimedContentType('a.bin', 'image/png')).toBe('image/png')
|
|
226
|
+
// The local driver reports octet-stream for anything outside its short table.
|
|
227
|
+
expect(claimedContentType('notes.md', 'application/octet-stream')).toBe('text/markdown')
|
|
228
|
+
expect(claimedContentType('face.ttf', undefined)).toBe('font/ttf')
|
|
229
|
+
expect(claimedContentType('photo.webp', '')).toBe('image/webp')
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
describe('dispatch', () => {
|
|
234
|
+
test('an image queues its preview beside its variants and tags', async () => {
|
|
235
|
+
await manager.disk('public').write('photo.png', await pngFixture())
|
|
236
|
+
|
|
237
|
+
const tasks = await dispatchDashboardFileTasks({ path: 'photo.png', contentType: 'image/png' }, manager, store, dispatch)
|
|
238
|
+
|
|
239
|
+
expect(tasks.map(task => [task.kind, task.state])).toEqual([['optimize', 'queued'], ['tag', 'queued'], ['preview', 'queued']])
|
|
240
|
+
expect(dispatched.map(entry => entry.job)).toEqual(['OptimizeStorageImageJob', 'TagStorageMediaJob', 'GenerateStoragePreviewJob'])
|
|
241
|
+
expect(dispatched[2]?.payload).toEqual({ disk: 'public', path: 'photo.png' })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('a markdown file gets a preview even though the disk calls it octet-stream', async () => {
|
|
245
|
+
await manager.disk('public').write('notes.md', '# Notes')
|
|
246
|
+
|
|
247
|
+
const tasks = await dispatchDashboardFileTasks({ path: 'notes.md', contentType: 'application/octet-stream' }, manager, store, dispatch)
|
|
248
|
+
|
|
249
|
+
expect(tasks.map(task => task.kind)).toEqual(['preview'])
|
|
250
|
+
expect(dispatched.map(entry => entry.job)).toEqual(['GenerateStoragePreviewJob'])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('a kind nothing can draw is recorded skipped, with its reason, and never queued', async () => {
|
|
254
|
+
for (const [path, contentType] of [['doc.pdf', 'application/pdf'], ['clip.mp4', 'video/mp4'], ['logo.svg', 'image/svg+xml'], ['song.mp3', 'audio/mpeg']] as const) {
|
|
255
|
+
await manager.disk('public').write(path, 'x')
|
|
256
|
+
const tasks = await dispatchDashboardFileTasks({ path, contentType }, manager, store, dispatch)
|
|
257
|
+
const preview = tasks.find(task => task.kind === 'preview')
|
|
258
|
+
expect(preview?.state).toBe('skipped')
|
|
259
|
+
expect(preview?.error).toBeTruthy()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The mp4's tag job is queued; no preview job is, for any of them.
|
|
263
|
+
expect(dispatched.map(entry => entry.job)).toEqual(['TagStorageMediaJob'])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('an archive gets no preview row at all', async () => {
|
|
267
|
+
await manager.disk('public').write('bundle.zip', 'x')
|
|
268
|
+
|
|
269
|
+
expect(await dispatchDashboardFileTasks({ path: 'bundle.zip', contentType: 'application/zip' }, manager, store, dispatch)).toEqual([])
|
|
270
|
+
expect(await store.tasksUnder('public', '')).toEqual(new Map())
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('reprocess can re-run just the preview', async () => {
|
|
274
|
+
await manager.disk('public').write('photo.png', await pngFixture())
|
|
275
|
+
|
|
276
|
+
const result = await reprocessDashboardFile({ path: 'photo.png', kinds: ['preview'] }, manager, store, dispatch)
|
|
277
|
+
|
|
278
|
+
expect(result.tasks.map(task => task.kind)).toEqual(['preview'])
|
|
279
|
+
expect(dispatched.map(entry => entry.job)).toEqual(['GenerateStoragePreviewJob'])
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('generateStoragePreview', () => {
|
|
284
|
+
async function run(path: string, contents: string | Uint8Array, disk = 'public') {
|
|
285
|
+
const adapter = manager.disk(disk)
|
|
286
|
+
await adapter.write(path, contents)
|
|
287
|
+
const result = await generateStoragePreview(adapter, store, disk, path)
|
|
288
|
+
const task = (await store.tasksUnder(disk, path)).get(path)?.find(entry => entry.kind === 'preview')
|
|
289
|
+
return { adapter, result, task }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
test('an image is scaled to fit the preview edge and stored as PNG beside its variants', async () => {
|
|
293
|
+
const { adapter, result, task } = await run('photos/wide.png', await pngFixture(800, 400))
|
|
294
|
+
|
|
295
|
+
expect(task?.state).toBe('done')
|
|
296
|
+
expect(result?.path).toBe('.variants/photos/wide.png/preview.png')
|
|
297
|
+
const stored = await decode(new Uint8Array(await adapter.readToBuffer(result!.path)))
|
|
298
|
+
expect([stored.width, stored.height]).toEqual([PREVIEW_MAX_EDGE, PREVIEW_MAX_EDGE / 2])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('a small image is not upscaled', async () => {
|
|
302
|
+
const { result } = await run('icon.png', await pngFixture(64, 48))
|
|
303
|
+
expect([result?.width, result?.height]).toEqual([64, 48])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('a WAV becomes a waveform', async () => {
|
|
307
|
+
const { adapter, result, task } = await run('sounds/tone.wav', wavFixture())
|
|
308
|
+
|
|
309
|
+
expect(task?.state).toBe('done')
|
|
310
|
+
const stored = await decode(new Uint8Array(await adapter.readToBuffer(result!.path)))
|
|
311
|
+
expect([stored.width, stored.height]).toEqual([PREVIEW_MAX_EDGE, 120])
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('a TrueType font becomes a specimen drawn with itself', async () => {
|
|
315
|
+
const { adapter, result, task } = await run('fonts/face.ttf', fontFixture())
|
|
316
|
+
|
|
317
|
+
expect(task?.state).toBe('done')
|
|
318
|
+
const stored = await decode(new Uint8Array(await adapter.readToBuffer(result!.path)))
|
|
319
|
+
// The squares were drawn: some pixel is ink rather than the white ground.
|
|
320
|
+
let inked = false
|
|
321
|
+
for (let i = 0; i < stored.data.length && !inked; i += 4)
|
|
322
|
+
inked = stored.data[i]! < 128
|
|
323
|
+
expect(inked).toBeTrue()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('text becomes a snippet of its first lines', async () => {
|
|
327
|
+
const lines = Array.from({ length: 40 }, (_, i) => `line ${i} ${'x'.repeat(200)}`)
|
|
328
|
+
const { adapter, result, task } = await run('notes.md', lines.join('\n'))
|
|
329
|
+
|
|
330
|
+
expect(task?.state).toBe('done')
|
|
331
|
+
expect(result?.path).toBe('.variants/notes.md/preview.txt')
|
|
332
|
+
const snippet = await adapter.readToString(result!.path)
|
|
333
|
+
expect(snippet.split('\n')).toHaveLength(TEXT_PREVIEW_MAX_LINES)
|
|
334
|
+
expect(snippet.split('\n').every(line => [...line].length <= TEXT_PREVIEW_MAX_COLUMNS)).toBeTrue()
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test('re-running replaces the preview and the row rather than adding to them', async () => {
|
|
338
|
+
const adapter = manager.disk('public')
|
|
339
|
+
await adapter.write('photo.png', await pngFixture())
|
|
340
|
+
|
|
341
|
+
await generateStoragePreview(adapter, store, 'public', 'photo.png')
|
|
342
|
+
await generateStoragePreview(adapter, store, 'public', 'photo.png')
|
|
343
|
+
|
|
344
|
+
const tasks = (await store.tasksUnder('public', '')).get('photo.png') ?? []
|
|
345
|
+
expect(tasks.filter(task => task.kind === 'preview')).toHaveLength(1)
|
|
346
|
+
expect(tasks[0]?.state).toBe('done')
|
|
347
|
+
expect(tasks[0]?.attempts).toBe(2)
|
|
348
|
+
|
|
349
|
+
const files: string[] = []
|
|
350
|
+
for await (const entry of adapter.list('.variants', { deep: true })) {
|
|
351
|
+
if (entry.type === 'file')
|
|
352
|
+
files.push(String(entry.path))
|
|
353
|
+
}
|
|
354
|
+
expect(files).toHaveLength(1)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
test('a broken image fails, counting the attempt, and rethrows so the queue retries', async () => {
|
|
358
|
+
const corrupt = (await pngFixture()).slice(0, 60)
|
|
359
|
+
const adapter = manager.disk('public')
|
|
360
|
+
await adapter.write('broken.png', corrupt)
|
|
361
|
+
|
|
362
|
+
expect(generateStoragePreview(adapter, store, 'public', 'broken.png')).rejects.toThrow()
|
|
363
|
+
await Bun.sleep(0)
|
|
364
|
+
await generateStoragePreview(adapter, store, 'public', 'broken.png').catch(() => {})
|
|
365
|
+
|
|
366
|
+
const task = (await store.tasksUnder('public', '')).get('broken.png')?.[0]
|
|
367
|
+
expect(task?.state).toBe('failed')
|
|
368
|
+
expect(task?.attempts).toBe(2)
|
|
369
|
+
expect(task?.error).toBeTruthy()
|
|
370
|
+
expect(await adapter.fileExists(previewPathFor('broken.png'))).toBeFalse()
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('bytes that disagree with the name are skipped, not drawn as what they really are', async () => {
|
|
374
|
+
// A PDF named .png: the bytes say PDF, and nothing draws a PDF.
|
|
375
|
+
const { adapter, result, task } = await run('fake.png', '%PDF-1.7\n%...')
|
|
376
|
+
|
|
377
|
+
expect(result).toBeUndefined()
|
|
378
|
+
expect(task?.state).toBe('skipped')
|
|
379
|
+
expect(task?.error).toMatch(/application\/pdf/)
|
|
380
|
+
expect(await adapter.fileExists(previewPathFor('fake.png'))).toBeFalse()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('SVG markup named .png is never rasterized', async () => {
|
|
384
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
|
385
|
+
const { adapter, task } = await run('evil.png', svg).catch(async () => ({
|
|
386
|
+
adapter: manager.disk('public'),
|
|
387
|
+
task: (await store.tasksUnder('public', 'evil.png')).get('evil.png')?.[0],
|
|
388
|
+
}))
|
|
389
|
+
|
|
390
|
+
expect(task?.state).not.toBe('done')
|
|
391
|
+
expect(await adapter.fileExists(previewPathFor('evil.png'))).toBeFalse()
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
test('a compressed font is skipped, not failed', async () => {
|
|
395
|
+
const { task } = await run('face.ttf', 'wOF2 and then some compressed bytes')
|
|
396
|
+
|
|
397
|
+
expect(task?.state).toBe('skipped')
|
|
398
|
+
expect(task?.error).toMatch(/WOFF/)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('a binary file wearing a text name is skipped', async () => {
|
|
402
|
+
const { task } = await run('data.txt', new Uint8Array([104, 105, 0, 1, 2]))
|
|
403
|
+
expect(task?.state).toBe('skipped')
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
describe('the skipped state', () => {
|
|
408
|
+
test('runTask records a skip and does not rethrow it, so the queue does not retry', async () => {
|
|
409
|
+
const result = await runTask(store, 'public', 'a.pdf', 'preview', async () => {
|
|
410
|
+
throw new StorageTaskSkipped('no renderer')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
expect(result).toBeUndefined()
|
|
414
|
+
const task = (await store.tasksUnder('public', '')).get('a.pdf')?.[0]
|
|
415
|
+
expect(task).toMatchObject({ state: 'skipped', attempts: 1, error: 'no renderer' })
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('a skipped preview does not mask the work that did apply', () => {
|
|
419
|
+
const skipped = { kind: 'preview', state: 'skipped', attempts: 0 } as const
|
|
420
|
+
expect(aggregateTaskState([{ kind: 'tag', state: 'done', attempts: 1 }, skipped])).toBe('done')
|
|
421
|
+
expect(aggregateTaskState([{ kind: 'tag', state: 'failed', attempts: 1 }, skipped])).toBe('failed')
|
|
422
|
+
expect(aggregateTaskState([skipped])).toBe('skipped')
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
describe('reading previews back', () => {
|
|
427
|
+
test('the listing links a finished preview on the public disk', async () => {
|
|
428
|
+
const adapter = manager.disk('public')
|
|
429
|
+
await adapter.write('photo.png', await pngFixture())
|
|
430
|
+
await generateStoragePreview(adapter, store, 'public', 'photo.png')
|
|
431
|
+
|
|
432
|
+
const snapshot = await getDashboardFileSnapshot({}, manager, store)
|
|
433
|
+
const node = nodeAt(snapshot.root, 'photo.png')
|
|
434
|
+
|
|
435
|
+
expect(node?.preview).toBe('/.variants/photo.png/preview.png')
|
|
436
|
+
// The derivative itself stays out of the listing.
|
|
437
|
+
expect(nodeAt(snapshot.root, '.variants')).toBeUndefined()
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('a file whose preview has not run has none', async () => {
|
|
441
|
+
await manager.disk('public').write('photo.png', await pngFixture())
|
|
442
|
+
|
|
443
|
+
const snapshot = await getDashboardFileSnapshot({}, manager, store)
|
|
444
|
+
expect(nodeAt(snapshot.root, 'photo.png')?.preview).toBeUndefined()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('a private disk links nothing, but still carries text snippets', async () => {
|
|
448
|
+
const adapter = manager.disk('private')
|
|
449
|
+
await adapter.write('photo.png', await pngFixture())
|
|
450
|
+
await adapter.write('notes.md', '# Title\n\nBody')
|
|
451
|
+
await generateStoragePreview(adapter, store, 'private', 'photo.png')
|
|
452
|
+
await generateStoragePreview(adapter, store, 'private', 'notes.md')
|
|
453
|
+
|
|
454
|
+
const snapshot = await getDashboardFileSnapshot({ disk: 'private' }, manager, store)
|
|
455
|
+
|
|
456
|
+
expect(nodeAt(snapshot.root, 'photo.png')?.preview).toBeUndefined()
|
|
457
|
+
expect(nodeAt(snapshot.root, 'notes.md')?.previewText).toBe('# Title\n\nBody')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('a rename moves the preview with the file, and a delete removes it', async () => {
|
|
461
|
+
const adapter = manager.disk('public')
|
|
462
|
+
await adapter.write('media/photo.png', await pngFixture())
|
|
463
|
+
await generateStoragePreview(adapter, store, 'public', 'media/photo.png')
|
|
464
|
+
|
|
465
|
+
await renameDashboardFile({ path: 'media', name: 'images' }, manager, store)
|
|
466
|
+
expect(await adapter.fileExists(previewPathFor('images/photo.png'))).toBeTrue()
|
|
467
|
+
expect(await adapter.fileExists(previewPathFor('media/photo.png'))).toBeFalse()
|
|
468
|
+
|
|
469
|
+
const snapshot = await getDashboardFileSnapshot({}, manager, store)
|
|
470
|
+
expect(nodeAt(snapshot.root, 'images/photo.png')?.preview).toBe('/.variants/images/photo.png/preview.png')
|
|
471
|
+
|
|
472
|
+
await deleteDashboardFile({ path: 'images/photo.png' }, manager, store)
|
|
473
|
+
expect(await adapter.fileExists(previewPathFor('images/photo.png'))).toBeFalse()
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
describe('the pieces', () => {
|
|
478
|
+
test('textSnippet strips control characters and expands tabs', () => {
|
|
479
|
+
expect(textSnippet(new TextEncoder().encode('a\tb\u0007c\r\nnext'))).toBe('a bc\nnext')
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('readWavFormat refuses a compressed WAV as a skip', () => {
|
|
483
|
+
const wav = wavFixture()
|
|
484
|
+
new DataView(wav.buffer).setUint16(20, 0x55, true) // MP3-in-WAV
|
|
485
|
+
expect(() => readWavFormat(wav)).toThrow(StorageTaskSkipped)
|
|
486
|
+
})
|
|
487
|
+
})
|