246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
|
|
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||
|
|
import fs from 'fs'
|
||
|
|
import path from 'path'
|
||
|
|
import { fileURLToPath } from 'url'
|
||
|
|
|
||
|
|
const __filename = fileURLToPath(import.meta.url)
|
||
|
|
const __dirname = path.dirname(__filename)
|
||
|
|
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots')
|
||
|
|
|
||
|
|
// Unique run prefix so parallel/repeat runs don't collide
|
||
|
|
const RUN_ID = `e2e-${Date.now()}`
|
||
|
|
|
||
|
|
// Track IDs for cleanup
|
||
|
|
const createdFileIds: string[] = []
|
||
|
|
|
||
|
|
// Save screenshot and display in Ghostty terminal via kitty graphics protocol
|
||
|
|
async function snap(page: import('@playwright/test').Page, label: string) {
|
||
|
|
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
|
||
|
|
const filePath = path.join(SCREENSHOT_DIR, `${label}.png`)
|
||
|
|
await page.screenshot({ path: filePath, fullPage: true })
|
||
|
|
|
||
|
|
const data = fs.readFileSync(filePath)
|
||
|
|
const b64 = data.toString('base64')
|
||
|
|
const chunkSize = 4096
|
||
|
|
for (let i = 0; i < b64.length; i += chunkSize) {
|
||
|
|
const chunk = b64.slice(i, i + chunkSize)
|
||
|
|
const isLast = i + chunkSize >= b64.length
|
||
|
|
if (i === 0) {
|
||
|
|
process.stdout.write(`\x1b_Ga=T,f=100,m=${isLast ? 0 : 1};${chunk}\x1b\\`)
|
||
|
|
} else {
|
||
|
|
process.stdout.write(`\x1b_Gm=${isLast ? 0 : 1};${chunk}\x1b\\`)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
process.stdout.write('\n')
|
||
|
|
console.log(` Screenshot: ${label}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper: create a file and track it for cleanup
|
||
|
|
async function createFile(
|
||
|
|
request: APIRequestContext,
|
||
|
|
data: { filename: string; mimetype: string; parent_id?: string | null },
|
||
|
|
) {
|
||
|
|
const res = await request.post('/api/files', { data })
|
||
|
|
expect(res.ok()).toBeTruthy()
|
||
|
|
const body = await res.json()
|
||
|
|
const file = body.file ?? body
|
||
|
|
createdFileIds.push(file.id)
|
||
|
|
return file
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper: create a folder and track it for cleanup
|
||
|
|
async function createFolder(
|
||
|
|
request: APIRequestContext,
|
||
|
|
data: { name: string; parent_id?: string | null },
|
||
|
|
) {
|
||
|
|
const res = await request.post('/api/folders', { data })
|
||
|
|
expect(res.ok()).toBeTruthy()
|
||
|
|
const body = await res.json()
|
||
|
|
const folder = body.folder ?? body.file ?? body
|
||
|
|
createdFileIds.push(folder.id)
|
||
|
|
return folder
|
||
|
|
}
|
||
|
|
|
||
|
|
test.describe.serial('Drive E2E Integration', () => {
|
||
|
|
|
||
|
|
// Cleanup: delete all files we created, regardless of pass/fail
|
||
|
|
test.afterAll(async ({ request }) => {
|
||
|
|
// Delete in reverse order (children before parents)
|
||
|
|
for (const id of [...createdFileIds].reverse()) {
|
||
|
|
try {
|
||
|
|
// Hard-delete: soft-delete then... we just leave them soft-deleted.
|
||
|
|
// The test user prefix keeps them isolated anyway.
|
||
|
|
await request.delete(`/api/files/${id}`)
|
||
|
|
} catch { /* best-effort */ }
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
test('health check', async ({ request }) => {
|
||
|
|
const res = await request.get('/health')
|
||
|
|
expect(res.ok()).toBeTruthy()
|
||
|
|
expect((await res.json()).ok).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
test('session returns test user', async ({ request }) => {
|
||
|
|
const res = await request.get('/api/auth/session')
|
||
|
|
expect(res.ok()).toBeTruthy()
|
||
|
|
const body = await res.json()
|
||
|
|
expect(body.user.email).toBe('e2e@test.local')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('app loads — explorer with sidebar', async ({ page }) => {
|
||
|
|
await page.goto('/')
|
||
|
|
await page.waitForURL(/\/explorer/, { timeout: 5000 })
|
||
|
|
await page.waitForTimeout(1000)
|
||
|
|
|
||
|
|
await expect(page.getByText('Drive', { exact: true }).first()).toBeVisible()
|
||
|
|
await expect(page.getByText('My Files').first()).toBeVisible()
|
||
|
|
|
||
|
|
await snap(page, '01-app-loaded')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('create a folder', async ({ request }) => {
|
||
|
|
const folder = await createFolder(request, {
|
||
|
|
name: `Game Assets ${RUN_ID}`,
|
||
|
|
parent_id: null,
|
||
|
|
})
|
||
|
|
expect(folder.is_folder).toBe(true)
|
||
|
|
expect(folder.filename).toContain('Game Assets')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('create files', async ({ request }) => {
|
||
|
|
const files = [
|
||
|
|
{ filename: `${RUN_ID}-character.fbx`, mimetype: 'application/octet-stream' },
|
||
|
|
{ filename: `${RUN_ID}-brick-texture.png`, mimetype: 'image/png' },
|
||
|
|
{ filename: `${RUN_ID}-theme-song.mp3`, mimetype: 'audio/mpeg' },
|
||
|
|
{ filename: `${RUN_ID}-game-design.docx`, mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
|
||
|
|
{ filename: `${RUN_ID}-level-data.json`, mimetype: 'application/json' },
|
||
|
|
]
|
||
|
|
for (const f of files) {
|
||
|
|
await createFile(request, { ...f, parent_id: null })
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
test('file listing shows created files', async ({ page }) => {
|
||
|
|
await page.goto('/explorer')
|
||
|
|
await page.waitForTimeout(2000)
|
||
|
|
|
||
|
|
await expect(page.getByText(`${RUN_ID}-character.fbx`)).toBeVisible({ timeout: 5000 })
|
||
|
|
await expect(page.getByText(`${RUN_ID}-brick-texture.png`)).toBeVisible()
|
||
|
|
await expect(page.getByText(`${RUN_ID}-game-design.docx`)).toBeVisible()
|
||
|
|
|
||
|
|
await snap(page, '02-files-listed')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('upload via pre-signed URL', async ({ request }) => {
|
||
|
|
const file = await createFile(request, {
|
||
|
|
filename: `${RUN_ID}-uploaded-scene.glb`,
|
||
|
|
mimetype: 'model/gltf-binary',
|
||
|
|
parent_id: null,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Get pre-signed upload URL
|
||
|
|
const urlRes = await request.post(`/api/files/${file.id}/upload-url`, {
|
||
|
|
data: { content_type: 'model/gltf-binary' },
|
||
|
|
})
|
||
|
|
expect(urlRes.ok()).toBeTruthy()
|
||
|
|
const urlData = await urlRes.json()
|
||
|
|
const uploadUrl = urlData.url ?? urlData.upload_url
|
||
|
|
expect(uploadUrl).toBeTruthy()
|
||
|
|
|
||
|
|
// Upload content directly to S3
|
||
|
|
const content = Buffer.from('glTF-binary-test-content-placeholder')
|
||
|
|
const putRes = await fetch(uploadUrl, {
|
||
|
|
method: 'PUT',
|
||
|
|
body: content,
|
||
|
|
headers: { 'Content-Type': 'model/gltf-binary' },
|
||
|
|
})
|
||
|
|
expect(putRes.ok).toBeTruthy()
|
||
|
|
|
||
|
|
// Verify download URL
|
||
|
|
const dlRes = await request.get(`/api/files/${file.id}/download`)
|
||
|
|
expect(dlRes.ok()).toBeTruthy()
|
||
|
|
const dlData = await dlRes.json()
|
||
|
|
expect(dlData.url).toBeTruthy()
|
||
|
|
})
|
||
|
|
|
||
|
|
test('file CRUD: rename and soft-delete', async ({ request }) => {
|
||
|
|
const file = await createFile(request, {
|
||
|
|
filename: `${RUN_ID}-lifecycle.txt`,
|
||
|
|
mimetype: 'text/plain',
|
||
|
|
parent_id: null,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Rename
|
||
|
|
const renameRes = await request.put(`/api/files/${file.id}`, {
|
||
|
|
data: { filename: `${RUN_ID}-renamed.txt` },
|
||
|
|
})
|
||
|
|
expect(renameRes.ok()).toBeTruthy()
|
||
|
|
const renamed = await renameRes.json()
|
||
|
|
expect((renamed.file ?? renamed).filename).toBe(`${RUN_ID}-renamed.txt`)
|
||
|
|
|
||
|
|
// Soft delete
|
||
|
|
const delRes = await request.delete(`/api/files/${file.id}`)
|
||
|
|
expect(delRes.ok()).toBeTruthy()
|
||
|
|
|
||
|
|
// Should appear in trash
|
||
|
|
const trashRes = await request.get('/api/trash')
|
||
|
|
expect(trashRes.ok()).toBeTruthy()
|
||
|
|
const trashFiles = (await trashRes.json()).files ?? []
|
||
|
|
expect(trashFiles.some((f: { id: string }) => f.id === file.id)).toBeTruthy()
|
||
|
|
|
||
|
|
// Restore
|
||
|
|
const restoreRes = await request.post(`/api/files/${file.id}/restore`)
|
||
|
|
expect(restoreRes.ok()).toBeTruthy()
|
||
|
|
|
||
|
|
// No longer in trash
|
||
|
|
const trashRes2 = await request.get('/api/trash')
|
||
|
|
const trashFiles2 = (await trashRes2.json()).files ?? []
|
||
|
|
expect(trashFiles2.some((f: { id: string }) => f.id === file.id)).toBeFalsy()
|
||
|
|
})
|
||
|
|
|
||
|
|
test('WOPI token + CheckFileInfo', async ({ request }) => {
|
||
|
|
const file = await createFile(request, {
|
||
|
|
filename: `${RUN_ID}-wopi-test.odt`,
|
||
|
|
mimetype: 'application/vnd.oasis.opendocument.text',
|
||
|
|
parent_id: null,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Generate WOPI token
|
||
|
|
const tokenRes = await request.post('/api/wopi/token', { data: { file_id: file.id } })
|
||
|
|
expect(tokenRes.ok()).toBeTruthy()
|
||
|
|
const tokenData = await tokenRes.json()
|
||
|
|
expect(tokenData.access_token).toBeTruthy()
|
||
|
|
expect(tokenData.access_token_ttl).toBeGreaterThan(Date.now())
|
||
|
|
|
||
|
|
// CheckFileInfo via WOPI endpoint
|
||
|
|
const checkRes = await request.get(`/wopi/files/${file.id}?access_token=${tokenData.access_token}`)
|
||
|
|
expect(checkRes.ok()).toBeTruthy()
|
||
|
|
const info = await checkRes.json()
|
||
|
|
expect(info.BaseFileName).toBe(`${RUN_ID}-wopi-test.odt`)
|
||
|
|
expect(info.SupportsLocks).toBe(true)
|
||
|
|
expect(info.UserCanWrite).toBe(true)
|
||
|
|
expect(info.UserId).toBe('e2e-test-user-00000000')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('navigate pages: recent, favorites, trash', async ({ page }) => {
|
||
|
|
await page.goto('/recent')
|
||
|
|
await page.waitForTimeout(1000)
|
||
|
|
await snap(page, '03-recent-page')
|
||
|
|
|
||
|
|
await page.goto('/favorites')
|
||
|
|
await page.waitForTimeout(1000)
|
||
|
|
await snap(page, '04-favorites-page')
|
||
|
|
|
||
|
|
await page.goto('/trash')
|
||
|
|
await page.waitForTimeout(1000)
|
||
|
|
await snap(page, '05-trash-page')
|
||
|
|
})
|
||
|
|
|
||
|
|
test('final screenshot — full explorer with files', async ({ page }) => {
|
||
|
|
await page.goto('/explorer')
|
||
|
|
await page.waitForTimeout(2000)
|
||
|
|
await snap(page, '06-final-explorer')
|
||
|
|
})
|
||
|
|
})
|