- {{ error }} +
+ {{ errorMessage }}
- +{{ document.name }}
diff --git a/tests/e2e/storage/workspace.spec.ts b/tests/e2e/storage/workspace.spec.ts
index b36d4e2e0..232b1ed92 100644
--- a/tests/e2e/storage/workspace.spec.ts
+++ b/tests/e2e/storage/workspace.spec.ts
@@ -4,8 +4,12 @@ import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
-test('configured storage lists and opens a remote document', async ({ page }) => {
+test('configured storage lists previews through ranges before opening the document', async ({
+ page
+}) => {
const fixture = readFileSync('tests/fixtures/gold-preview.fig')
+ let fullDocumentGets = 0
+ let rangeGets = 0
await page.route('https://s3.example.com/**', async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('list-type') === '2') {
@@ -29,7 +33,47 @@ test('configured storage lists and opens a remote document', async ({ page }) =>
})
return
}
+ if (url.pathname.endsWith('/remote-1.fig') && route.request().headers().range) {
+ const range = route.request().headers().range
+ const explicit = range?.match(/^bytes=(\d+)-(\d+)$/)
+ const suffix = range?.match(/^bytes=-(\d+)$/)
+ let start: number
+ let end: number
+ if (explicit) {
+ start = Number(explicit[1])
+ end = Math.min(Number(explicit[2]), fixture.byteLength - 1)
+ } else if (suffix) {
+ const length = Math.min(Number(suffix[1]), fixture.byteLength)
+ start = fixture.byteLength - length
+ end = fixture.byteLength - 1
+ } else {
+ await route.fulfill({ status: 416 })
+ return
+ }
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) {
+ await route.fulfill({ status: 416 })
+ return
+ }
+ await route.fulfill({
+ status: 206,
+ headers: {
+ 'Content-Range': `bytes ${start}-${end}/${fixture.byteLength}`
+ },
+ contentType: 'application/octet-stream',
+ body: fixture.subarray(start, end + 1)
+ })
+ rangeGets++
+ return
+ }
+ if (url.pathname.endsWith('/remote-1.fig') && route.request().method() === 'HEAD') {
+ await route.fulfill({
+ status: 200,
+ headers: { 'Content-Length': String(fixture.byteLength) }
+ })
+ return
+ }
if (url.pathname.endsWith('/remote-1.fig')) {
+ fullDocumentGets++
await route.fulfill({ contentType: 'application/octet-stream', body: fixture })
return
}
@@ -54,11 +98,17 @@ test('configured storage lists and opens a remote document', async ({ page }) =>
await page.getByTestId('settings-storage-open-workspace').click()
await expect(page.getByTestId('storage-workspace')).toBeVisible()
await expect(page.getByText('Remote design')).toBeVisible()
+ const preview = page.locator('[data-document-id="remote-1"] img')
+ await expect(preview).toBeVisible()
+ await expect(preview).toHaveAttribute('src', /^blob:/)
+ expect(rangeGets).toBe(3)
+ expect(fullDocumentGets).toBe(0)
await page.locator('[data-document-id="remote-1"]').click()
await expect(page).toHaveURL(/\/$/)
await canvas.waitForInit()
await expect(page.getByText('Remote design').first()).toBeVisible()
+ expect(fullDocumentGets).toBe(1)
})
test('storage workspace directs unconfigured users to Settings', async ({ page }) => {
diff --git a/tests/engine/app/storage/persist.test.ts b/tests/engine/app/storage/persist.test.ts
index c32e84b6b..6061844cb 100644
--- a/tests/engine/app/storage/persist.test.ts
+++ b/tests/engine/app/storage/persist.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test, vi } from 'bun:test'
+import { readFileSync } from 'node:fs'
import { createMemoryLocalCanvasStore } from '@/app/storage/local-store'
import { persistStorageCanvasLocally } from '@/app/storage/sync/persist'
@@ -30,4 +31,26 @@ describe('local-first storage persistence', () => {
providerId: 's3-compatible'
})
})
+
+ test('stores the embedded preview with the document', async () => {
+ const store = createMemoryLocalCanvasStore()
+ const enqueueCanvas = vi.fn(() => Promise.resolve())
+ const figBytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
+
+ await persistStorageCanvasLocally(
+ {
+ providerId: 's3-compatible',
+ canvasId: 'canvas-preview',
+ name: 'Preview design',
+ figBytes
+ },
+ { store, enqueueCanvas }
+ )
+
+ const thumbnail = await store.readThumb('canvas-preview')
+ expect(thumbnail?.byteLength).toBeGreaterThan(0)
+ expect(thumbnail?.subarray(0, 8)).toEqual(
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+ )
+ })
})
diff --git a/tests/engine/io/fig/thumbnail-range.test.ts b/tests/engine/io/fig/thumbnail-range.test.ts
new file mode 100644
index 000000000..ff527920c
--- /dev/null
+++ b/tests/engine/io/fig/thumbnail-range.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, test } from 'bun:test'
+import { readFileSync } from 'node:fs'
+
+import { zipSync } from 'fflate'
+
+import { extractFigThumbnailFromReader } from '@open-pencil/fig'
+
+function memoryReader(bytes: Uint8Array, ranges: Array<[number, number]>) {
+ return {
+ size: bytes.byteLength,
+ async read(start: number, endExclusive: number) {
+ ranges.push([start, endExclusive])
+ return bytes.slice(start, endExclusive)
+ }
+ }
+}
+
+describe('fig ranged thumbnail extraction', () => {
+ test('extracts thumbnail.png without reading the complete fig', async () => {
+ const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
+ const ranges: Array<[number, number]> = []
+ const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, ranges), {
+ maxTailBytes: 4 * 1024 * 1024
+ })
+
+ expect(thumbnail?.subarray(0, 8)).toEqual(
+ new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+ )
+ expect(ranges.length).toBeGreaterThanOrEqual(2)
+ expect(ranges.every(([start, end]) => start !== 0 || end !== bytes.byteLength)).toBe(true)
+ expect(ranges.reduce((total, [start, end]) => total + end - start, 0)).toBeLessThan(
+ bytes.byteLength
+ )
+ })
+
+ test('rejects malformed thumbnail payloads', async () => {
+ const bytes = zipSync({
+ 'canvas.fig': new Uint8Array([1]),
+ 'thumbnail.png': new TextEncoder().encode('not a png')
+ })
+ expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull()
+ })
+
+ test('returns null when deflated thumbnail data is corrupt', async () => {
+ const png = new Uint8Array([
+ 0x89,
+ 0x50,
+ 0x4e,
+ 0x47,
+ 0x0d,
+ 0x0a,
+ 0x1a,
+ 0x0a,
+ ...Array.from({ length: 64 }, () => 1)
+ ])
+ const bytes = zipSync({ 'thumbnail.png': png })
+ const name = new TextEncoder().encode('thumbnail.png')
+ const nameOffset = bytes.findIndex((byte, index) =>
+ name.every((nameByte, nameIndex) => bytes[index + nameIndex] === nameByte)
+ )
+ const headerOffset = nameOffset - 30
+ const header = new DataView(bytes.buffer, bytes.byteOffset + headerOffset, 30)
+ expect(header.getUint16(8, true)).toBe(8)
+ const dataOffset = nameOffset + name.byteLength + header.getUint16(28, true)
+ bytes.fill(0xff, dataOffset, dataOffset + header.getUint32(18, true))
+
+ expect(await extractFigThumbnailFromReader(memoryReader(bytes, []))).toBeNull()
+ })
+
+ test('rejects thumbnails above configured output limits', async () => {
+ const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
+ const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, []), {
+ maxOutputBytes: 32
+ })
+ expect(thumbnail).toBeNull()
+ })
+})
diff --git a/tests/engine/vue/document-workspace.test.ts b/tests/engine/vue/document-workspace.test.ts
new file mode 100644
index 000000000..6ff36a83c
--- /dev/null
+++ b/tests/engine/vue/document-workspace.test.ts
@@ -0,0 +1,257 @@
+import { afterEach, describe, expect, test, vi } from 'bun:test'
+
+import { createRenderer, defineComponent, h, type ComponentPublicInstance } from 'vue'
+
+import {
+ useDocumentWorkspace,
+ type DocumentWorkspaceItem,
+ type DocumentWorkspaceSource
+} from '@open-pencil/vue'
+
+type HostNode = {
+ children: HostNode[]
+ parent: HostNode | null
+ text: string
+}
+
+type Deferred