diff --git a/.plans/editor-workflows-inventory.md b/.plans/editor-workflows-inventory.md index c3d1670..60e3ae2 100644 --- a/.plans/editor-workflows-inventory.md +++ b/.plans/editor-workflows-inventory.md @@ -290,8 +290,9 @@ It follows the original rule: identify the smallest reusable workflows first, th - Log out from the signed-in account surface. #### A64 — Resolve Cloud vs Device Workspace Conflict -- When prompted, compare cloud vs device YAML summaries. +- Only for ambiguous cloud recovery cases such as offline divergence, compare cloud vs device YAML summaries. - Export both snapshots, choose cloud, or keep device. +- For linked online projects, startup keeps the device workspace active and older cloud versions are recovered through Manage/History instead of a startup conflict prompt. #### A65 — Connect / Switch / Disconnect GitHub - In the Cloud pane, connect GitHub, switch the linked GitHub account, or disconnect it from PhaserForge. @@ -369,7 +370,8 @@ It follows the original rule: identify the smallest reusable workflows first, th - A63 sign in → A65 connect GitHub → A66 precheck and publish → verify the Pages URL. ### W17 — Workspace Conflict Recovery -- A63 sign in → A64 compare cloud/device snapshots → keep the desired workspace → continue editing or publish. +- A63 sign in → if an offline/ambiguous divergence is detected, A64 compare cloud/device snapshots → keep the desired workspace → continue editing or publish. +- For normal linked online startup, continue editing immediately and use Manage/History to restore or copy an older cloud-backed revision when needed. ### W18 — Multi-scene Authoring - A29 create scene → A30 switch scene → use W1-W13 inside that scene → A31 duplicate/base/clear/delete scene as needed. diff --git a/src/editor/CloudAccountPanel.tsx b/src/editor/CloudAccountPanel.tsx index 0b46ac6..d0635bf 100644 --- a/src/editor/CloudAccountPanel.tsx +++ b/src/editor/CloudAccountPanel.tsx @@ -1,5 +1,5 @@ import { type Dispatch, useEffect, useMemo, useRef, useState } from 'react'; -import { checkGithubPagesTarget, createGame, disconnectGithub, fetchCsrfToken, getGame, getGithubPagesPublishInfo, listGames, login, logout, me, publishToGithubPages, signup, updateGame, uploadEmbeddedAsset } from '../cloud/api'; +import { checkGithubPagesTarget, createGame, disconnectGithub, fetchCsrfToken, getGame, getGithubPagesPublishInfo, login, logout, me, publishToGithubPages, signup, updateGame, uploadEmbeddedAsset } from '../cloud/api'; import { canonicalizeProjectForComparison, projectsSemanticallyEqual } from '../model/projectCanonical'; import { serializeProjectToYaml } from '../model/serialization'; import type { AssetFileSource, ProjectSpec } from '../model/types'; @@ -157,15 +157,17 @@ export function CloudAccountPanel({ onLoadYaml, onLoadProject, onCloudGameLinked, + onWorkspaceConflictChange, onStatus, onError, }: { - state: Pick; + state: Pick; activeCloudGameId?: string | null; dispatch: Dispatch; onLoadYaml: (yaml: string, sourceLabel: string) => void; onLoadProject?: (project: ProjectSpec, sourceLabel: string) => void; onCloudGameLinked?: (gameId: string) => void | Promise; + onWorkspaceConflictChange?: (hasConflict: boolean) => void; onStatus: (message: string) => void; onError: (message: string) => void; }) { @@ -292,6 +294,10 @@ export function CloudAccountPanel({ cloudGameIdRef.current = cloudGameId; }, [cloudGameId]); + useEffect(() => { + onWorkspaceConflictChange?.(workspaceConflict != null); + }, [onWorkspaceConflictChange, workspaceConflict]); + useEffect(() => { let cancelled = false; if (!user) return; @@ -330,47 +336,35 @@ export function CloudAccountPanel({ savedAtMs: null as number | null, }; const mappedCloudGameId = activeCloudGameId ?? cloudGameIdRef.current; + if (!mappedCloudGameId) { + appendPersistenceDebugEntry('cloud:conflict-check-skipped-unlinked-project', { + stateProjectId: state.project.id, + }); + return; + } + if (state.syncMode === 'online') { + appendPersistenceDebugEntry('cloud:conflict-check-skipped-online-autosync', { + stateProjectId: state.project.id, + cloudGameId: mappedCloudGameId, + }); + return; + } let cloudLabel = 'Cloud'; let cloudUpdatedAt = ''; let cloudProject: ProjectSpec | null = null; - if (mappedCloudGameId) { - const full = await getGame(mappedCloudGameId); - if (!full?.game?.project) return; - cloudProject = full.game.project; - cloudUpdatedAt = full.game.updated_at; - cloudLabel = `Cloud (current project: ${full.game.title})`; - appendPersistenceDebugEntry('restore:cloud-project-fetched', { - source: 'active-cloud-game', - cloudGameId: mappedCloudGameId, - updatedAt: full.game.updated_at, - title: full.game.title, - ...summarizeYamlForDebug(serializeProjectToYaml(full.game.project)), - }); - } else { - const res = await listGames(); - const candidates = res.games ?? []; - if (candidates.length === 0) return; - const latest = candidates.reduce((best, cur) => { - const bestMs = Date.parse(best.updated_at); - const curMs = Date.parse(cur.updated_at); - if (!Number.isFinite(bestMs)) return cur; - if (!Number.isFinite(curMs)) return best; - return curMs > bestMs ? cur : best; - }); - const full = await getGame(latest.id); - if (!full?.game?.project) return; - cloudProject = full.game.project; - cloudUpdatedAt = latest.updated_at; - cloudLabel = `Cloud (last game: ${latest.title})`; - appendPersistenceDebugEntry('restore:cloud-project-fetched', { - source: 'latest-cloud-game', - cloudGameId: latest.id, - updatedAt: latest.updated_at, - title: latest.title, - ...summarizeYamlForDebug(serializeProjectToYaml(full.game.project)), - }); - } + const full = await getGame(mappedCloudGameId); + if (!full?.game?.project) return; + cloudProject = full.game.project; + cloudUpdatedAt = full.game.updated_at; + cloudLabel = `Cloud (current project: ${full.game.title})`; + appendPersistenceDebugEntry('restore:cloud-project-fetched', { + source: 'active-cloud-game', + cloudGameId: mappedCloudGameId, + updatedAt: full.game.updated_at, + title: full.game.title, + ...summarizeYamlForDebug(serializeProjectToYaml(full.game.project)), + }); if (!cloudProject) return; const isEquivalent = projectsSemanticallyEqual(device.project, cloudProject); diff --git a/src/editor/InspectorPane.tsx b/src/editor/InspectorPane.tsx index d7798bb..1a9f8ee 100644 --- a/src/editor/InspectorPane.tsx +++ b/src/editor/InspectorPane.tsx @@ -99,6 +99,7 @@ export function InspectorPane() { const cachedUser = getCachedCloudAccountUserSnapshot(); return cachedUser ? 'inspector' : 'cloud'; }); + const [hasWorkspaceConflict, setHasWorkspaceConflict] = useState(false); const userSelectedTabRef = useRef(false); const stabilityDebugKeyRef = useRef(null); @@ -136,6 +137,12 @@ export function InspectorPane() { setTab(nextTab); }; + useEffect(() => { + if (!cloudEnabled) return; + if (!hasWorkspaceConflict) return; + setTab('cloud'); + }, [cloudEnabled, hasWorkspaceConflict]); + useEffect(() => { if (!state.initialized) return; const projectId = state.project?.id; @@ -164,6 +171,7 @@ export function InspectorPane() { state={state} activeCloudGameId={activeCloudGameId} dispatch={dispatch} + onWorkspaceConflictChange={setHasWorkspaceConflict} onLoadYaml={(yaml, sourceLabel) => { appendPersistenceDebugEntry('inspector-pane:on-load-yaml-dispatch', { sourceLabel, diff --git a/tests/e2e/cloud-workspace-conflict.spec.ts b/tests/e2e/cloud-workspace-conflict.spec.ts index 2d0db8e..88f3091 100644 --- a/tests/e2e/cloud-workspace-conflict.spec.ts +++ b/tests/e2e/cloud-workspace-conflict.spec.ts @@ -1,18 +1,19 @@ import { test, expect } from '@playwright/test'; -import { enablePersistenceDebug, expectPersistenceDebugEvents, expectProjectRestoreState, gotoStudio, waitForEmptyScene } from './helpers'; +import { enablePersistenceDebug, gotoStudio } from './helpers'; import { sampleProject } from '../../src/model/sampleProject'; import { createEmptyProject } from '../../src/model/emptyProject'; import { buildStoredProjectRecord } from '../../src/editor/projectPersistence'; -test('Cloud login shows conflict picker when cloud and device diverge @smoke', async ({ page }) => { +test('Signed-in linked online projects stay local at startup and autosave to cloud without a conflict modal @smoke', async ({ page }) => { test.setTimeout(120000); const cloudProject = createEmptyProject(); await enablePersistenceDebug(page); const deviceRecord = buildStoredProjectRecord(sampleProject, { id: sampleProject.id, updatedAt: new Date(Date.now() - 60_000).toISOString(), - origin: 'local-only', - syncStatus: 'local', + origin: 'cloud-cache', + syncStatus: 'cloud', + cloudProjectId: 'g1', }); await page.addInitScript(async ({ record }) => { @@ -48,9 +49,6 @@ test('Cloud login shows conflict picker when cloud and device diverge @smoke', a await route.fulfill({ status: 200, body: JSON.stringify({ csrfToken: 'csrf' }), contentType: 'application/json' }); }); await page.route('**/api/v1/auth/me', async (route) => { - await route.fulfill({ status: 401, body: JSON.stringify({ error: 'not_logged_in' }), contentType: 'application/json' }); - }); - await page.route('**/api/v1/auth/login', async (route) => { await route.fulfill({ status: 200, body: JSON.stringify({ user: { id: 'u1', email: 'a@b.c' } }), contentType: 'application/json' }); }); await page.route('**/api/v1/games', async (route) => { @@ -61,6 +59,14 @@ test('Cloud login shows conflict picker when cloud and device diverge @smoke', a }); }); await page.route('**/api/v1/games/g1', async (route) => { + if (route.request().method() === 'PUT') { + await route.fulfill({ + status: 200, + body: JSON.stringify({ game: { id: 'g1', title: 'Workspace', created_at: '2026-05-28T10:00:00.000Z', updated_at: '2026-05-28T10:15:00.000Z', project: sampleProject } }), + contentType: 'application/json', + }); + return; + } await route.fulfill({ status: 200, body: JSON.stringify({ game: { id: 'g1', title: 'Workspace', created_at: '2026-05-28T10:00:00.000Z', updated_at: '2026-05-28T10:14:00.000Z', project: cloudProject } }), @@ -76,54 +82,20 @@ test('Cloud login shows conflict picker when cloud and device diverge @smoke', a return; } - await cloudTab.click(); - await expect(page.getByTestId('cloud-panel')).toBeVisible(); - - await page.getByLabel('Email').fill('a@b.c'); - await page.locator('input[autocomplete="current-password"]').fill('pw'); - await page.getByRole('button', { name: 'Log in' }).click(); - - await expect(page.getByTestId('workspace-conflict-modal')).toBeVisible(); - await expect(page.getByTestId('workspace-conflict-cloud-card')).toContainText('Cloud'); - await expect(page.getByTestId('workspace-conflict-device-card')).toContainText('This device'); - - const dl1 = page.waitForEvent('download'); - const dl2 = page.waitForEvent('download'); - await page.getByTestId('workspace-conflict-export-both').click(); - await Promise.all([dl1, dl2]); + await expect(page.getByTestId('workspace-conflict-modal')).toHaveCount(0); + await expect(cloudTab).toHaveAttribute('aria-selected', 'false'); - await page.getByTestId('workspace-conflict-use-cloud').click(); - await waitForEmptyScene(page); - await expectProjectRestoreState(page, { - projectId: cloudProject.id, - title: cloudProject.title ?? 'Untitled Project', - currentSceneId: cloudProject.initialSceneId, - entityCount: 0, - groupCount: 0, - }); - await expectPersistenceDebugEvents(page, [ - 'cloud:workspace-conflict-detected', - 'cloud:workspace-conflict-choice-applied', - 'restore:project-dispatched', - 'restore:inspector-entity-list-stable', - ]); - - const backup = await page.evaluate(async () => { - const openDb = () => new Promise((resolve, reject) => { - const request = window.indexedDB.open('phaserforge.persistence.v1', 1); - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result); + await expect.poll(async () => { + return await page.evaluate(() => { + const entries = window.__PHASER_FORGE_TEST__?.persistenceDebugEntries ?? []; + return entries.filter((entry: { event?: string }) => entry.event === 'cloud:autosave-flush-start').length; }); - const db = await openDb(); - const saved = await new Promise((resolve, reject) => { - const tx = db.transaction('workspaceState', 'readonly'); - const request = tx.objectStore('workspaceState').get('workspaceBackup'); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); + }).toBeGreaterThan(0); + + await expect.poll(async () => { + return await page.evaluate(() => { + const entries = window.__PHASER_FORGE_TEST__?.persistenceDebugEntries ?? []; + return entries.filter((entry: { event?: string }) => entry.event === 'cloud:workspace-conflict-detected').length; }); - return saved ?? null; - }); - expect(backup?.source).toBe('device'); - expect(backup?.project?.id).toBe(sampleProject.id); - expect(Object.keys(backup?.project?.scenes ?? {})).not.toHaveLength(0); + }).toBe(0); }); diff --git a/tests/editor/cloud-account-publish-gating.test.tsx b/tests/editor/cloud-account-publish-gating.test.tsx index 47fc6b7..ca91360 100644 --- a/tests/editor/cloud-account-publish-gating.test.tsx +++ b/tests/editor/cloud-account-publish-gating.test.tsx @@ -55,6 +55,7 @@ import { CloudAccountPanel, __resetCloudAccountPanelAuthCacheForTests } from '.. function baseState(): any { return { + syncMode: 'online', project: { assets: { images: {}, spriteSheets: {}, fonts: {} }, audio: { sounds: {} } }, }; } @@ -471,7 +472,7 @@ describe('CloudAccountPanel publish gating', () => { } }); - it('checks conflict against the active mapped cloud project before falling back to the latest cloud game', async () => { + it('shows a workspace conflict for a linked project only when sync mode is offline', async () => { api.me.mockResolvedValueOnce({ user: { id: 'u1', email: 'dev@example.com' } }); const deviceProject = createEmptyProject(); @@ -504,29 +505,13 @@ describe('CloudAccountPanel publish gating', () => { }, }; } - if (id === 'g2') { - return { - game: { - id: 'g2', - title: 'Latest Unrelated Game', - created_at: 'c', - updated_at: 'u2', - project: createEmptyProject(), - }, - }; - } throw new Error(`unexpected game id ${id}`); }); - api.listGames.mockResolvedValueOnce({ - games: [ - { id: 'g2', title: 'Latest Unrelated Game', created_at: 'c', updated_at: '2026-06-21T13:00:00.000Z' }, - ], - }); const onLoadProject = vi.fn(); const view = renderIntoDom( {}} @@ -541,7 +526,6 @@ describe('CloudAccountPanel publish gating', () => { await flushEffects(); expect(api.getGame).toHaveBeenCalledWith('g1'); - expect(api.getGame).not.toHaveBeenCalledWith('g2'); expect(document.querySelector('[data-testid="workspace-conflict-modal"]')).toBeTruthy(); expect(document.querySelector('[data-testid="workspace-conflict-cloud-card"]')?.textContent).toContain('Current Cloud Project'); (document.querySelector('[data-testid="workspace-conflict-use-cloud"]') as HTMLButtonElement | null)?.click(); @@ -552,6 +536,68 @@ describe('CloudAccountPanel publish gating', () => { } }); + it('does not raise a startup conflict for an unlinked local project just because cloud games exist', async () => { + api.me.mockResolvedValueOnce({ user: { id: 'u1', email: 'dev@example.com' } }); + + const deviceProject = createEmptyProject(); + deviceProject.id = 'project-1'; + deviceProject.title = 'Device Project'; + + const onStatus = vi.fn(); + const view = renderIntoDom( + {}} + onStatus={onStatus} + onError={vi.fn()} + /> + ); + + try { + await flushEffects(); + await flushEffects(); + + expect(api.getGame).not.toHaveBeenCalled(); + expect(api.listGames).not.toHaveBeenCalled(); + expect(document.querySelector('[data-testid="workspace-conflict-modal"]')).toBeNull(); + expect(onStatus).not.toHaveBeenCalledWith(expect.stringContaining('Workspace conflict detected')); + } finally { + view.cleanup(); + } + }); + + it('does not raise a startup conflict for a linked online project and lets autosave reconcile it', async () => { + api.me.mockResolvedValueOnce({ user: { id: 'u1', email: 'dev@example.com' } }); + + const deviceProject = createEmptyProject(); + deviceProject.id = 'project-1'; + deviceProject.title = 'Device Project'; + + const onStatus = vi.fn(); + const view = renderIntoDom( + {}} + onStatus={onStatus} + onError={vi.fn()} + /> + ); + + try { + await flushEffects(); + await flushEffects(); + + expect(api.getGame).not.toHaveBeenCalled(); + expect(document.querySelector('[data-testid="workspace-conflict-modal"]')).toBeNull(); + expect(onStatus).not.toHaveBeenCalledWith(expect.stringContaining('Workspace conflict detected')); + } finally { + view.cleanup(); + } + }); + it('shows a compact Publish section when not signed in', async () => { api.me.mockImplementationOnce(async () => { throw new Error('not_signed_in'); diff --git a/tests/editor/inspector-pane-tabs.test.tsx b/tests/editor/inspector-pane-tabs.test.tsx index 2e93295..d5cb4aa 100644 --- a/tests/editor/inspector-pane-tabs.test.tsx +++ b/tests/editor/inspector-pane-tabs.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; const inspectorPaneStore = vi.hoisted(() => ({ state: { id: 'state' }, @@ -11,6 +11,7 @@ const inspectorPaneStore = vi.hoisted(() => ({ const cloudPanelSpy = vi.hoisted(() => ({ onLoadYaml: undefined as ((yaml: string, sourceLabel: string) => void) | undefined, onLoadProject: undefined as ((project: any, sourceLabel: string) => void) | undefined, + onWorkspaceConflictChange: undefined as ((hasConflict: boolean) => void) | undefined, onStatus: undefined as ((message: string) => void) | undefined, onError: undefined as ((message: string) => void) | undefined, cachedUser: undefined as { id: string; email: string } | null | undefined, @@ -35,11 +36,13 @@ vi.mock('../../src/editor/CloudAccountPanel', () => ({ CloudAccountPanel: (props: { onLoadYaml: (yaml: string, sourceLabel: string) => void; onLoadProject?: (project: any, sourceLabel: string) => void; + onWorkspaceConflictChange?: (hasConflict: boolean) => void; onStatus: (message: string) => void; onError: (message: string) => void; }) => { cloudPanelSpy.onLoadYaml = props.onLoadYaml; cloudPanelSpy.onLoadProject = props.onLoadProject; + cloudPanelSpy.onWorkspaceConflictChange = props.onWorkspaceConflictChange; cloudPanelSpy.onStatus = props.onStatus; cloudPanelSpy.onError = props.onError; return
Cloud body
; @@ -56,6 +59,7 @@ describe('InspectorPane tabs', () => { delete (globalThis as { location?: { hostname: string } }).location; cloudPanelSpy.onLoadYaml = undefined; cloudPanelSpy.onLoadProject = undefined; + cloudPanelSpy.onWorkspaceConflictChange = undefined; cloudPanelSpy.onStatus = undefined; cloudPanelSpy.onError = undefined; cloudPanelSpy.cachedUser = undefined; @@ -191,4 +195,21 @@ describe('InspectorPane tabs', () => { expect(screen.getByTestId('inspector-pane-panel-inspector').hidden).toBe(true); expect(window.sessionStorage.getItem('phaserforge.cloud.return_to_cloud_after_auth')).toBeNull(); }); + + it('switches to Cloud when the hidden cloud panel detects a workspace conflict', () => { + (globalThis as { location?: { hostname: string } }).location = { hostname: 'phaserforge.app' }; + cloudPanelSpy.cachedUser = { id: 'u1', email: 'alice@example.com' }; + + render(); + + expect(screen.getByTestId('inspector-pane-panel-inspector').hidden).toBe(false); + expect(screen.getByTestId('inspector-pane-panel-cloud').hidden).toBe(true); + + act(() => { + cloudPanelSpy.onWorkspaceConflictChange?.(true); + }); + + expect(screen.getByTestId('inspector-pane-panel-cloud').hidden).toBe(false); + expect(screen.getByTestId('inspector-pane-panel-inspector').hidden).toBe(true); + }); });