diff --git a/.github/workflows/e2e-nightly-full-matrix.yml b/.github/workflows/e2e-nightly-full-matrix.yml index 901ee6f4..16156777 100644 --- a/.github/workflows/e2e-nightly-full-matrix.yml +++ b/.github/workflows/e2e-nightly-full-matrix.yml @@ -28,8 +28,15 @@ jobs: shard: [1, 2, 3, 4, 5, 6, 7, 8] shards: [8] steps: + - name: Install Git LFS + run: | + apt-get update + apt-get install -y git-lfs + - name: Checkout uses: actions/checkout@v7 + with: + lfs: true - name: Setup Node uses: actions/setup-node@v6 diff --git a/.github/workflows/e2e-pr.yml b/.github/workflows/e2e-pr.yml index 426d74ce..22341f27 100644 --- a/.github/workflows/e2e-pr.yml +++ b/.github/workflows/e2e-pr.yml @@ -24,8 +24,15 @@ jobs: shard: [1, 2] shards: [2] steps: + - name: Install Git LFS + run: | + apt-get update + apt-get install -y git-lfs + - name: Checkout uses: actions/checkout@v7 + with: + lfs: true - name: Setup Node uses: actions/setup-node@v6 diff --git a/package.json b/package.json index 4d80e56a..a4631d38 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "docs:screenshots:app": "node --import tsx scripts/docs/capture-docs-screenshots.mjs", "docs:screenshots": "npm run docs:screenshots:storybook && npm run docs:screenshots:app", "docs:stage": "node scripts/docs/stage-docs-for-pages.mjs", + "prebuild": "node scripts/ensure-lfs-assets.mjs", + "prebuild-nolog": "node scripts/ensure-lfs-assets.mjs", "server:dev": "node --import tsx server/src/index.ts", "server:start": "node --import tsx server/src/index.ts", "prisma:generate": "prisma generate", diff --git a/scripts/e2e-external-webserver.cjs b/scripts/e2e-external-webserver.cjs index d61d99e5..45cde1ef 100644 --- a/scripts/e2e-external-webserver.cjs +++ b/scripts/e2e-external-webserver.cjs @@ -167,7 +167,22 @@ function createLifecycleTracker({ command, host, port, logDir }) { } function createManagedServerError(message, logDir) { - return new Error(`${message}. Managed server logs: ${logDir}`); + const readTail = (name) => { + try { + const text = fs.readFileSync(path.join(logDir, name), 'utf8'); + const lines = text.trimEnd().split(/\r?\n/); + return lines.slice(-80).join('\n'); + } catch { + return ''; + } + }; + const stdoutTail = readTail('stdout.log'); + const stderrTail = readTail('stderr.log'); + const detail = [ + stdoutTail ? `\n--- managed server stdout tail ---\n${stdoutTail}` : '', + stderrTail ? `\n--- managed server stderr tail ---\n${stderrTail}` : '', + ].join(''); + return new Error(`${message}. Managed server logs: ${logDir}${detail}`); } async function startManagedExternalWebServer({ diff --git a/scripts/ensure-lfs-assets.mjs b/scripts/ensure-lfs-assets.mjs new file mode 100644 index 00000000..2ede21bd --- /dev/null +++ b/scripts/ensure-lfs-assets.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; + +const LFS_POINTER_PREFIX = 'version https://git-lfs.github.com/spec/v1'; +const REQUIRED_ASSETS = [ + 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + 'assets/demo-pack/audio/punch-deck-the-soul-crushing-monotony-of-isolation-instrumental-mix(chosic.com).mp3', + 'assets/demo-pack/audio/sb_indreams(chosic.com).mp3', +]; + +function isPointer(path) { + try { + return readFileSync(path, 'utf8').slice(0, LFS_POINTER_PREFIX.length) === LFS_POINTER_PREFIX; + } catch { + return false; + } +} + +function pointerAssets() { + return REQUIRED_ASSETS.filter((assetPath) => isPointer(assetPath)); +} + +const pointers = pointerAssets(); +if (pointers.length > 0) { + throw new Error( + [ + '[phaserforge] Git LFS assets are pointer files, not playable audio bytes.', + `Run \`git lfs pull --include="${REQUIRED_ASSETS.join(',')}"\` before building.`, + `Pointer assets: ${pointers.join(', ')}`, + ].join('\n'), + ); +} diff --git a/server/src/server/services/publishService.ts b/server/src/server/services/publishService.ts index 1ef4ad7a..e049a912 100644 --- a/server/src/server/services/publishService.ts +++ b/server/src/server/services/publishService.ts @@ -144,6 +144,12 @@ function decodeEmbeddedDataUrl(dataUrl: string): Uint8Array | null { } } +function isGitLfsPointer(bytes: Uint8Array): boolean { + const prefix = 'version https://git-lfs.github.com/spec/v1'; + if (bytes.length < prefix.length) return false; + return Buffer.from(bytes.subarray(0, prefix.length)).toString('utf8') === prefix; +} + async function materializeProjectForPublish( repositories: Repositories, userId: string, @@ -179,6 +185,7 @@ async function materializeProjectForPublish( if (!copiedPathSources.has(relPath)) { try { const bytes = new Uint8Array(await fs.readFile(absPath)); + if (isGitLfsPointer(bytes)) return null; files.push({ path: relPath, bytes }); copiedPathSources.add(relPath); } catch { @@ -190,12 +197,14 @@ async function materializeProjectForPublish( if (source.kind === 'embedded') { const bytes = decodeEmbeddedDataUrl(source.dataUrl); if (!bytes) return null; + if (isGitLfsPointer(bytes)) return null; const relPath = allocatePath(source, fallbackBase); files.push({ path: relPath, bytes }); return { kind: 'path', path: relPath, ...(source.originalName ? { originalName: source.originalName } : {}), ...(source.mimeType ? { mimeType: source.mimeType } : {}) }; } const asset = await repositories.assets.findByIdForUser(source.assetId, userId); if (!asset) return null; + if (isGitLfsPointer(asset.bytes)) return null; const relPath = allocatePath( { kind: 'cloud', assetId: source.assetId, originalName: source.originalName ?? asset.originalName ?? undefined, mimeType: source.mimeType ?? asset.mimeType ?? undefined }, fallbackBase, diff --git a/src/PlayApp.tsx b/src/PlayApp.tsx index 7e04b397..8b547dfd 100644 --- a/src/PlayApp.tsx +++ b/src/PlayApp.tsx @@ -24,6 +24,21 @@ function readYamlUrl(): string | null { return typeof globalVal === 'string' && globalVal.trim().length > 0 ? globalVal.trim() : null; } +function unlockAudioFromStartGesture(): void { + const game = (window as any).__phaserGame; + const sound = game?.sound; + try { + sound?.unlock?.(); + } catch { + // ignore + } + try { + void sound?.context?.resume?.(); + } catch { + // ignore + } +} + export default function PlayApp() { const gameId = useMemo(() => readPlayGameId(), []); const yamlUrl = useMemo(() => readYamlUrl(), []); @@ -112,13 +127,16 @@ export default function PlayApp() {
- {hasStarted ? setSceneReady(true)} /> : null} + setSceneReady(true)} /> {!hasStarted ? ( diff --git a/tests/e2e/audio-runtime.spec.ts b/tests/e2e/audio-runtime.spec.ts index 97170d98..1ac41d07 100644 --- a/tests/e2e/audio-runtime.spec.ts +++ b/tests/e2e/audio-runtime.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; import { dismissViewHint, getSceneSnapshot, getState, openSceneScope, seedProject } from './helpers'; import { sampleProject } from '../../src/model/sampleProject'; import { createEmptyProject } from '../../src/model/emptyProject'; +import { serializeProjectToYaml } from '../../src/model/serialization'; test.setTimeout(120000); @@ -101,6 +102,69 @@ test('entering play mode eventually starts delayed path-backed demo-pack music @ }, { timeout: 15000 }).toBeGreaterThan(0); }); +test('published play page starts path-backed demo-pack music @slow', async ({ page }) => { + const sceneId = sampleProject.initialSceneId; + const project = { + ...sampleProject, + audio: { + sounds: { + theme: { + id: 'theme', + source: { + kind: 'path', + path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + originalName: 'Simulacra-chosic.com_.mp3', + mimeType: 'audio/mpeg', + }, + }, + }, + }, + scenes: { + ...sampleProject.scenes, + [sceneId]: { + ...sampleProject.scenes[sceneId], + music: { assetId: 'theme', loop: true, volume: 0.65, fadeMs: 250 }, + }, + }, + }; + + await page.route('**/game.yaml', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/x-yaml', + body: serializeProjectToYaml(project as any), + }); + }); + + await page.goto('./?yamlUrl=./game.yaml&audioDebug=1'); + await expect(page.getByTestId('play-start-gate')).toBeVisible(); + await page.getByTestId('play-start-gate').click(); + + await expect.poll(async () => { + const snap = await getSceneSnapshot<{ + audio?: { musicAssetId?: string }; + audioPlayback?: { musicIsPlaying?: boolean }; + audioDebug?: { contextState?: string; outputRange?: number; usingWebAudio?: boolean; currentMusicResolvedUrl?: string }; + }>(page); + return { + music: snap?.audio?.musicAssetId, + isPlaying: Boolean(snap?.audioPlayback?.musicIsPlaying), + contextState: snap?.audioDebug?.contextState ?? null, + usingWebAudio: Boolean(snap?.audioDebug?.usingWebAudio), + }; + }, { timeout: 15000 }).toEqual({ + music: 'theme', + isPlaying: true, + contextState: 'running', + usingWebAudio: true, + }); + + await expect.poll(async () => { + const snap = await getSceneSnapshot<{ audioDebug?: { outputRange?: number } }>(page); + return Number(snap?.audioDebug?.outputRange ?? 0); + }, { timeout: 15000 }).toBeGreaterThan(0); +}); + [ { assetId: 'simulacra-chosic-com', diff --git a/tests/server/publish-github-pages.test.ts b/tests/server/publish-github-pages.test.ts index 39d5e889..66bc8a0c 100644 --- a/tests/server/publish-github-pages.test.ts +++ b/tests/server/publish-github-pages.test.ts @@ -657,4 +657,66 @@ describe('publish github pages', () => { .find((content) => content.includes('initialSceneId')); expect(yamlBlob).toContain('path: assets/demo-pack/audio/Simulacra-chosic.com_.mp3'); }); + + it('publish rejects repo path-backed audio when the file is only a Git LFS pointer', async () => { + const { app, repositories } = makeApp(); + const agent = request.agent(app); + const { userId, csrf } = await signup(agent); + await linkGithub(repositories, userId); + await addGame(repositories, userId); + + const game = await repositories.games.findByIdForUser('g1', userId); + if (!game) throw new Error('expected game'); + game.project.audio.sounds.theme = { + id: 'theme', + source: { + kind: 'path', + path: 'assets/demo-pack/audio/Simulacra-chosic.com_.mp3', + originalName: 'Simulacra-chosic.com_.mp3', + mimeType: 'audio/mpeg', + }, + } as any; + game.project.scenes.s1.music = { assetId: 'theme', loop: true, volume: 0.8 }; + await repositories.games.updateForUser('g1', userId, { + project: game.project, + updatedAt: '2026-06-04T01:00:00.000Z', + }); + + await fs.mkdir(path.join(tempDir, 'assets', 'demo-pack', 'audio'), { recursive: true }); + await fs.writeFile( + path.join(tempDir, 'assets', 'demo-pack', 'audio', 'Simulacra-chosic.com_.mp3'), + [ + 'version https://git-lfs.github.com/spec/v1', + 'oid sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'size 12345', + '', + ].join('\n'), + ); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === 'https://api.github.com/repos/alice/zoof') { + if (!init?.method || init.method === 'GET') return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404 }); + } + if (url === 'https://api.github.com/user/repos') { + return new Response(JSON.stringify({ name: 'zoof', full_name: 'alice/zoof', default_branch: 'main' }), { status: 201 }); + } + if (url === 'https://api.github.com/repos/alice/zoof/pages') { + if (!init?.method || init.method === 'GET') return new Response(JSON.stringify({ message: 'Not Found' }), { status: 404 }); + return new Response(JSON.stringify({ html_url: 'https://alice.github.io/zoof' }), { status: 201 }); + } + throw new Error(`Unhandled fetch ${url}`); + }) as any, + ); + + const res = await agent + .post('/api/v1/publish/github-pages') + .set('x-csrf-token', csrf) + .send({ gameId: 'g1', repo: 'zoof' }) + .expect(400); + + expect(res.body).toEqual({ error: 'cloud_asset_missing' }); + }); }); diff --git a/tests/ui/play-app-start-gate.test.tsx b/tests/ui/play-app-start-gate.test.tsx index 5903ee9c..39e0ab22 100644 --- a/tests/ui/play-app-start-gate.test.tsx +++ b/tests/ui/play-app-start-gate.test.tsx @@ -75,6 +75,7 @@ describe('PlayApp start gate', () => { afterEach(() => { cleanup(); + delete (window as any).__phaserGame; vi.unstubAllGlobals(); }); @@ -82,7 +83,7 @@ describe('PlayApp start gate', () => { render(); expect((await screen.findByTestId('play-start-gate')).textContent).toContain('Click to Start'); - expect(screen.queryByTestId('mock-phaser-game')).toBeNull(); + expect(await screen.findByTestId('mock-phaser-game')).not.toBeNull(); expect(eventBus.emit).not.toHaveBeenCalledWith('runtime:load-project', expect.anything(), expect.anything(), 'play'); }); @@ -99,4 +100,21 @@ describe('PlayApp start gate', () => { expect(screen.queryByTestId('play-start-gate')).toBeNull(); }); }); + + it('uses the start click to unlock the mounted Phaser audio context', async () => { + const unlock = vi.fn(); + const resume = vi.fn(); + (window as any).__phaserGame = { + sound: { + unlock, + context: { resume }, + }, + }; + render(); + + fireEvent.click(await screen.findByTestId('play-start-gate')); + + expect(unlock).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledTimes(1); + }); });