Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ type SiteInfo = SliceSitesModule.SiteInfo;
const EDITED_BLUEPRINT = '{"steps":[{"step":"login"}]}';
const mocks = vi.hoisted(() => ({
changeCode: undefined as ((code: string) => void) | undefined,
clientEntities: {} as Record<
string,
{ opfsSync?: { status: 'syncing' | 'error' } }
>,
createStoredSite: vi.fn(),
dispatch: vi.fn(),
fileExplorerProps: undefined as Record<string, unknown> | undefined,
Expand Down Expand Up @@ -57,6 +61,11 @@ vi.mock('../../lib/hooks/use-blueprint-url-hash', () => ({

vi.mock('../../lib/state/redux/store', () => ({
useAppDispatch: () => mocks.dispatch,
useAppSelector: (
selector: (state: {
clients: { entities: typeof mocks.clientEntities };
}) => unknown
) => selector({ clients: { entities: mocks.clientEntities } }),
setActiveSite: mocks.setActiveSite,
}));

Expand Down Expand Up @@ -108,6 +117,7 @@ describe('BlueprintBundleEditor Run barrier', () => {
writeFile,
} as unknown as EventedFilesystem;
mocks.changeCode = undefined;
mocks.clientEntities = {};
mocks.createStoredSite.mockReset();
mocks.fileExplorerProps = undefined;
mocks.dispatch.mockReset();
Expand Down Expand Up @@ -201,6 +211,72 @@ describe('BlueprintBundleEditor Run barrier', () => {
expect(mocks.resolveRuntimeConfiguration).not.toHaveBeenCalled();
});

it('queues Run until the source Playground finishes syncing to OPFS', async () => {
const sourceSite = createStoredSiteInfo('autosave');
const newSite = createStoredSiteInfo('autosave', 'blueprint-copy');
const createAction = { type: 'create-stored-site' };
mocks.clientEntities[sourceSite.slug] = {
opfsSync: { status: 'syncing' },
};
mocks.createStoredSite.mockReturnValue(createAction);
mocks.pruneAutosavedSites.mockReturnValue({ type: 'prune-autosaves' });
mocks.dispatch.mockImplementation((action) =>
action === createAction ? Promise.resolve(newSite) : action
);
await renderEditor({ site: sourceSite });
const runButton = getRunButton();
expect(runButton.disabled).toBe(false);

await act(async () => runButton.click());

expect(mocks.createStoredSite).not.toHaveBeenCalled();
expect(runButton.disabled).toBe(true);

mocks.clientEntities[sourceSite.slug] = {};
await renderEditor({ site: sourceSite });

expect(mocks.createStoredSite).toHaveBeenCalledOnce();
expect(mocks.setActiveSite).toHaveBeenCalledOnce();
expect(mocks.setActiveSite).toHaveBeenCalledWith(newSite.slug);
});

it('cancels a queued Run when OPFS synchronization fails', async () => {
const sourceSite = createStoredSiteInfo('autosave');
mocks.clientEntities[sourceSite.slug] = {
opfsSync: { status: 'syncing' },
};
await renderEditor({ site: sourceSite });
const runButton = getRunButton();

await act(async () => runButton.click());

mocks.clientEntities[sourceSite.slug] = {
opfsSync: { status: 'error' },
};
await renderEditor({ site: sourceSite });

expect(mocks.createStoredSite).not.toHaveBeenCalled();
expect(getRunButton().disabled).toBe(false);
});

it('does not block Run on pending-sync metadata alone', async () => {
const sourceSite = createStoredSiteInfo('autosave');
sourceSite.metadata.initialOpfsSyncPending = true;
const newSite = createStoredSiteInfo('autosave', 'blueprint-copy');
const createAction = { type: 'create-stored-site' };
mocks.createStoredSite.mockReturnValue(createAction);
mocks.pruneAutosavedSites.mockReturnValue({ type: 'prune-autosaves' });
mocks.dispatch.mockImplementation((action) =>
action === createAction ? Promise.resolve(newSite) : action
);
const editorRef = await renderEditor({ site: sourceSite });

await act(async () => editorRef.current!.runBlueprint());

expect(mocks.createStoredSite).toHaveBeenCalled();
expect(mocks.setActiveSite).toHaveBeenCalledWith(newSite.slug);
});

it('keeps running a temporary Blueprint in the current Playground', async () => {
const runtimeConfiguration = {
phpVersion: '8.3',
Expand Down Expand Up @@ -416,6 +492,16 @@ describe('BlueprintBundleEditor Run barrier', () => {
return editorRef;
}

function getRunButton(): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(
'button[data-testid="run-blueprint"]'
);
if (!button) {
throw new Error('Run button not found');
}
return button;
}
Comment thread
adamziel marked this conversation as resolved.

function createStoredSiteInfo(
persistence: 'autosave' | 'explicit',
slug = 'source-site'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ import {
type SiteInfo,
updateSite,
} from '../../lib/state/redux/slice-sites';
import { setActiveSite, useAppDispatch } from '../../lib/state/redux/store';
import {
setActiveSite,
useAppDispatch,
useAppSelector,
} from '../../lib/state/redux/store';
import { setDockPaneOpen } from '../../lib/state/redux/slice-ui';
import styles from './blueprint-bundle-editor.module.css';
import hideRootStyles from './hide-root.module.css';
Expand Down Expand Up @@ -314,10 +318,24 @@ export const BlueprintBundleEditor = forwardRef<
>(null);
const [displayPath, setDisplayPath] = useState<string | null>(null);
const [isRunningBlueprint, setIsRunningBlueprint] = useState(false);
const [queuedRunSiteSlug, setQueuedRunSiteSlug] = useState<string | null>(
null
);
const isWaitingToRun = queuedRunSiteSlug !== null;
const isBlueprintRunPending = isRunningBlueprint || isWaitingToRun;
const [validationResult, setValidationResult] =
useState<BlueprintValidationResult | null>(null);
const hasValidationErrors =
validationResult !== null && !validationResult.valid;
const storedSiteSlug = site && isStoredSite(site) ? site.slug : undefined;
// initialOpfsSyncPending can remain set after a failed boot. Queue only while
// the live client reports a copy; cancel on an explicit sync error.
const opfsSyncStatus = useAppSelector((state) => {
if (!storedSiteSlug) {
return undefined;
}
return state.clients.entities[storedSiteSlug]?.opfsSync?.status;
});
Comment thread
adamziel marked this conversation as resolved.
const copyBlueprintUrlHintId = useId();
const [stringEditorState, setStringEditorState] =
useState<StringEditorState>({
Expand Down Expand Up @@ -380,15 +398,15 @@ export const BlueprintBundleEditor = forwardRef<

const handleCodeChange = useCallback(
(newCode: string) => {
if (readOnly || isRunningBlueprint) {
if (readOnly || isBlueprintRunPending) {
return;
}
setCode(newCode);
if (currentPath) {
saveFile(currentPath, newCode);
}
},
[currentPath, isRunningBlueprint, readOnly, saveFile]
[currentPath, isBlueprintRunPending, readOnly, saveFile]
);

// Load initial blueprint.json and focus tree
Expand Down Expand Up @@ -430,6 +448,10 @@ export const BlueprintBundleEditor = forwardRef<
) {
return;
}
if (opfsSyncStatus === 'syncing') {
setQueuedRunSiteSlug(site.slug);
return;
}
runInProgressRef.current = true;
const runInNewPlayground = isStoredSite(site);
try {
Expand Down Expand Up @@ -502,7 +524,35 @@ export const BlueprintBundleEditor = forwardRef<
runInProgressRef.current = false;
setIsRunningBlueprint(false);
}
}, [dispatch, filesystem, hasValidationErrors, readOnly, saveFile, site]);
}, [
dispatch,
filesystem,
hasValidationErrors,
opfsSyncStatus,
readOnly,
saveFile,
site,
]);

// A queued Run belongs to the current editor. Resume when no live sync remains;
// cancel on a sync error or when the editor changes sites.
useEffect(() => {
if (queuedRunSiteSlug === null) {
return;
}
if (!site || site.slug !== queuedRunSiteSlug) {
setQueuedRunSiteSlug(null);
return;
}
if (opfsSyncStatus === 'syncing') {
return;
}
setQueuedRunSiteSlug(null);
if (opfsSyncStatus === 'error') {
return;
}
void handleRunBlueprint();
}, [handleRunBlueprint, opfsSyncStatus, queuedRunSiteSlug, site]);

// autorun token hook
useEffect(() => {
Expand Down Expand Up @@ -572,7 +622,7 @@ export const BlueprintBundleEditor = forwardRef<
// Handle saving from the string editor modal
const handleStringEditorSave = useCallback(
(newValue: string) => {
if (readOnly || isRunningBlueprint) {
if (readOnly || isBlueprintRunPending) {
return;
}
const view = cmViewRef.current;
Expand All @@ -593,7 +643,7 @@ export const BlueprintBundleEditor = forwardRef<
setTimeout(() => formatEditor(view), 0);
},
[
isRunningBlueprint,
isBlueprintRunPending,
readOnly,
stringEditorState.contentEnd,
stringEditorState.contentStart,
Expand Down Expand Up @@ -728,7 +778,8 @@ export const BlueprintBundleEditor = forwardRef<

const isAutosaved = site ? isAutosavedSite(site) : false;
const isStored = site ? isStoredSite(site) : false;
const disableRunButton = isRunningBlueprint || !site || hasValidationErrors;
const disableRunButton =
isBlueprintRunPending || !site || hasValidationErrors;
const mobileExplorerToggle = (
<Button
className={styles.mobileToggle}
Expand Down Expand Up @@ -878,7 +929,7 @@ export const BlueprintBundleEditor = forwardRef<
onSelectionCleared={handleClearSelection}
onShowMessage={handleShowMessage}
documentRoot="/"
readOnly={readOnly || isRunningBlueprint}
readOnly={readOnly || isBlueprintRunPending}
{...(dockPresentation
? {
title: 'Blueprint',
Expand Down Expand Up @@ -962,8 +1013,9 @@ export const BlueprintBundleEditor = forwardRef<
}
)}
onClick={handleRunBlueprint}
isBusy={isRunningBlueprint}
isBusy={isBlueprintRunPending}
disabled={disableRunButton}
data-testid="run-blueprint"
title={
hasValidationErrors
? 'Fix validation errors before running'
Expand All @@ -975,9 +1027,11 @@ export const BlueprintBundleEditor = forwardRef<
styles.editorToolbarPlayIcon
}
/>
{isStored
? 'Run in a new Playground'
: 'Discard current Playground & run Blueprint'}
{isWaitingToRun
? 'Finishing Playground sync…'
: isStored
? 'Run in a new Playground'
: 'Discard current Playground & run Blueprint'}
</Button>
)}
</div>
Expand Down Expand Up @@ -1010,12 +1064,19 @@ export const BlueprintBundleEditor = forwardRef<
) : null}
{isStored ? (
<p className={styles.runHint}>
Running this Blueprint creates a fresh autosaved
Playground. “{site?.metadata.name}” stays in{' '}
{isAutosaved
? 'Recent autosaves'
: 'Saved Playgrounds'}
.
{isWaitingToRun ? (
'Run will wait for this Playground to finish saving.'
) : (
<>
Running this Blueprint creates a fresh
autosaved Playground. “
{site?.metadata.name}” stays in{' '}
{isAutosaved
? 'Recent autosaves'
: 'Saved Playgrounds'}
.
</>
)}
</p>
) : null}
{currentPath || code || messageContent ? (
Expand All @@ -1032,7 +1093,7 @@ export const BlueprintBundleEditor = forwardRef<
currentPath={currentPath}
className={styles.editor}
readOnly={
readOnly || isRunningBlueprint
readOnly || isBlueprintRunPending
}
additionalExtensions={
currentPath === BLUEPRINT_JSON_PATH
Expand Down
Loading