From c597943ef1dc816153b7f350412e25f9fae5e2f6 Mon Sep 17 00:00:00 2001 From: SylvainDruaux Date: Wed, 28 Oct 2026 19:25:12 +0100 Subject: [PATCH 1/4] fix(guide): create guide.json on the first change, not on open Opening a project without .stacki/guide.json wrote the default guide settings to it right away, so a new empty Astro site, or any project opened in Stacki for the first time, showed an uncommitted .stacki/ folder before the user had changed anything. The file is now written on open only when the project has guide settings stored in the app by an earlier Stacki version, so they still move into the project. Otherwise the defaults are used without writing, and the first change to the guide creates the file. --- src/features/preview/guidePersistence.ts | 11 ++++--- src/features/preview/guideSettings.ts | 11 ++++--- .../renderer/preview/guidePersistence.test.js | 33 +++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/features/preview/guidePersistence.ts b/src/features/preview/guidePersistence.ts index ac17ab57..34e71dc1 100644 --- a/src/features/preview/guidePersistence.ts +++ b/src/features/preview/guidePersistence.ts @@ -113,14 +113,15 @@ class ProjectGuideController { return; } this.source = stored.source; + // Without a file, only settings from app storage are written; defaults wait for a change. + const legacy = + this.source === undefined ? loadLegacyGuideSettings(this.project) : undefined; this.settings = - this.source === undefined - ? loadLegacyGuideSettings(this.project) - : guideFromSource(this.source); + this.source === undefined ? (legacy ?? GUIDE_DEFAULTS) : guideFromSource(this.source); this.status = 'ready'; this.report(''); - if (this.source === undefined) { - this.pending = this.settings; + if (legacy !== undefined) { + this.pending = legacy; await this.save(); } }) diff --git a/src/features/preview/guideSettings.ts b/src/features/preview/guideSettings.ts index 195aaf70..9fe59567 100644 --- a/src/features/preview/guideSettings.ts +++ b/src/features/preview/guideSettings.ts @@ -165,15 +165,16 @@ function storageKey(projectKey: string): string { return GUIDE_STORAGE_PREFIX + projectKey; } -export function loadLegacyGuideSettings(projectKey: string | undefined): GuideSettings { +// Undefined when nothing was stored, so there is nothing to migrate into the project file. +export function loadLegacyGuideSettings(projectKey: string | undefined): GuideSettings | undefined { if (projectKey === undefined) { - return GUIDE_DEFAULTS; + return undefined; } const key = storageKey(projectKey); try { - const raw = localStorage.getItem(key); - if (raw === null) { - return GUIDE_DEFAULTS; + const raw = localStorage.getItem(key) ?? undefined; + if (raw === undefined) { + return undefined; } if (raw.length > GUIDE_STORED_CHARS_MAX) { return GUIDE_DEFAULTS; diff --git a/test/renderer/preview/guidePersistence.test.js b/test/renderer/preview/guidePersistence.test.js index bf04e159..e6875c56 100644 --- a/test/renderer/preview/guidePersistence.test.js +++ b/test/renderer/preview/guidePersistence.test.js @@ -68,3 +68,36 @@ test('guide migration, coalesced saves, and stale editor protection', async () = delete globalThis.localStorage; delete globalThis.CSS; }); + +test('without a guide file or stored settings, the first change creates the file', async () => { + let source; + const writes = []; + globalThis.CSS = { supports: () => true }; + globalThis.localStorage = { getItem: () => undefined }; + globalThis.window = { + avb: { + projectMetadata: async () => ({ ok: true, value: source === undefined ? {} : { source } }), + writeProjectMetadata: async ({ write: input }) => { + source = input.source; + writes.push(input); + return { ok: true, value: { source } }; + }, + }, + }; + const states = []; + const controller = createGuideController('/project', (state) => states.push(state)); + controller.reload(); + await until(() => states.at(-1)?.loading === false); + // Let a save that should not happen have its chance to run. + await delay(50); + assert.equal(writes.length, 0); + assert.equal(source, undefined); + controller.change((settings) => ({ ...settings, opacity: 0.15 })); + await until(() => writes.length === 1); + assert.equal(writes[0].base, undefined); + assert.equal(JSON.parse(source).settings.opacity, 0.15); + controller.close(); + delete globalThis.window; + delete globalThis.localStorage; + delete globalThis.CSS; +}); From ded59a40a5c177e82698efc263a43e5c726199eb Mon Sep 17 00:00:00 2001 From: Timothy Ricks Date: Thu, 29 Oct 2026 06:39:40 -0500 Subject: [PATCH 2/4] fix(settings): preserve first-save metadata across Desktop and Studio Keep new projects untouched until guide settings change or readable legacy preferences migrate. Corrupt or unavailable browser storage now leaves defaults in memory instead of creating a guide file. Exercise every shared settings kind as the first save with no .stacki directory, including slot component defaults and publishing configuration. Verify named-slot insertion, stale-write rejection, and actual Desktop-to-Studio publish settings exchange through .stacki/publish.json. Align release packages at 0.1.91. Targeted persistence tests, Studio verification, the real browser suite, and changed-file checks passed. Updating from main incorporates the Windows watcher creation-event regression fix. --- docs/contracts.md | 5 + docs/releases.md | 16 ++ package-lock.json | 4 +- package.json | 2 +- packages/create-stacki/package.json | 2 +- src/features/preview/guideSettings.ts | 17 +- studio/package-lock.json | 4 +- studio/package.json | 2 +- studio/src/shared/release.json | 2 +- studio/test/projectMetadata.test.ts | 147 +++++++++++++++++- test/electron/project/projectMetadata.test.ts | 64 ++++++++ .../renderer/preview/guidePersistence.test.js | 72 +++++---- .../publishing/publishSettings.test.js | 1 + 13 files changed, 292 insertions(+), 46 deletions(-) diff --git a/docs/contracts.md b/docs/contracts.md index ecbfb042..e82aa698 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -668,6 +668,8 @@ recipes explicit project data. Existing page instances are never rewritten. against shared bounds before storage and consumption. Browser storage is consulted only when the project file is absent, for migration. Changes use a single pending value and serialized writes; conflicts stop saving until the user reloads the project settings. +Opening a project without saved guide preferences uses defaults in memory. It does not create +`.stacki/guide.json`; the first guide edit or migration of a readable legacy record creates it. `.stacki/pages.json` contains `{ version: 1, entries: [...] }`. Entries are relative to `src/pages`; folder keys end in `/`. Desktop and Studio apply the same order to sibling pages @@ -682,6 +684,9 @@ Desktop confines them to the open project and writes through the document actors the supplied base. Studio exposes the same records through `/studio/api/metadata`, its serialized document rooms and durable draft storage. Neither endpoint permits arbitrary metadata paths. Settings writes reject stale bases rather than overwriting newer edits. +Each metadata save creates `.stacki/` when needed, independently of the guide file. Saving slot +component defaults, publish settings, page order or component sources never requires opening or +changing the guide first. Reads and invalid writes do not create project settings files. These files participate in Studio publishing and GitHub sync. The legacy local `.stacki/` Git exclusion is narrowed to `.stacki/preview/` and `.stacki/canvas/`; user-authored ignore rules are untouched. diff --git a/docs/releases.md b/docs/releases.md index 26345c4a..573a05f6 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,6 +15,22 @@ Use an isolated checkout when other tasks are editing the working directory. Mat Studio, Studio release manifest, and npm starter versions. Never move an existing release tag to include later changes. +## 0.1.91 project settings + +Opening a project without saved guide preferences now uses in-memory defaults without creating +`.stacki/guide.json`. The first guide edit still saves the file, and readable legacy preferences +still migrate. Missing, corrupt and inaccessible browser storage do not create a defaults file. + +Settings saves remain independent: slot component defaults, publish destinations and scripts, +page order and component sources create their own `.stacki` files when first saved. Desktop and +Studio read and write `.stacki/publish.json`; credentials remain in host authentication storage. +Regression tests exercise first saves without a metadata directory, named-slot insertion, +stale-write protection and publish settings exchanged between the actual Desktop and Studio hosts. + +PR 66's Windows failure was the creation-event watcher assertion already corrected in 0.1.89. +Updating the branch from main retains that regression fix and the removed variables-panel probe. +Desktop, Studio and the starter are aligned at 0.1.91. + ## 0.1.90 release validation At the user's request, remove the variables-panel layout probe because its visible Electron diff --git a/package-lock.json b/package-lock.json index bf6af4d7..6137afba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stacki", - "version": "0.1.90", + "version": "0.1.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stacki", - "version": "0.1.90", + "version": "0.1.91", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ff0039d2..3fee5b05 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stacki", "productName": "Stacki", - "version": "0.1.90", + "version": "0.1.91", "description": "Stacki \u2014 a visual Astro builder with live preview and publishing.", "homepage": "https://github.com/flowtricks/stacki", "main": "dist/electron/main.js", diff --git a/packages/create-stacki/package.json b/packages/create-stacki/package.json index 12e0d185..7043e0fe 100644 --- a/packages/create-stacki/package.json +++ b/packages/create-stacki/package.json @@ -1,6 +1,6 @@ { "name": "create-stacki", - "version": "0.1.90", + "version": "0.1.91", "description": "Add Stacki Studio to an existing Astro project", "type": "module", "bin": { diff --git a/src/features/preview/guideSettings.ts b/src/features/preview/guideSettings.ts index 9fe59567..94ee1ff6 100644 --- a/src/features/preview/guideSettings.ts +++ b/src/features/preview/guideSettings.ts @@ -165,7 +165,7 @@ function storageKey(projectKey: string): string { return GUIDE_STORAGE_PREFIX + projectKey; } -// Undefined when nothing was stored, so there is nothing to migrate into the project file. +// Missing or unreadable records cannot justify creating a project settings file on open. export function loadLegacyGuideSettings(projectKey: string | undefined): GuideSettings | undefined { if (projectKey === undefined) { return undefined; @@ -177,13 +177,22 @@ export function loadLegacyGuideSettings(projectKey: string | undefined): GuideSe return undefined; } if (raw.length > GUIDE_STORED_CHARS_MAX) { - return GUIDE_DEFAULTS; + return undefined; } const stored: unknown = JSON.parse(raw); + if (typeof stored !== 'object') { + return undefined; + } + if (stored === null) { + return undefined; + } + if (Array.isArray(stored)) { + return undefined; + } return parseGuideSettings(stored); } catch { - // Unreadable storage or a corrupt record: the defaults, never a failed load. - return GUIDE_DEFAULTS; + // The controller uses in-memory defaults without migrating inaccessible storage. + return undefined; } } diff --git a/studio/package-lock.json b/studio/package-lock.json index d027064a..474ac1b1 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -1,12 +1,12 @@ { "name": "@stacki/studio", - "version": "0.1.90", + "version": "0.1.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@stacki/studio", - "version": "0.1.90", + "version": "0.1.91", "license": "MIT", "dependencies": { "@astrojs/compiler": "^4.0.0", diff --git a/studio/package.json b/studio/package.json index b7410fdd..55678cc0 100644 --- a/studio/package.json +++ b/studio/package.json @@ -1,6 +1,6 @@ { "name": "@stacki/studio", - "version": "0.1.90", + "version": "0.1.91", "type": "module", "license": "MIT", "engines": { diff --git a/studio/src/shared/release.json b/studio/src/shared/release.json index 06d830a1..aff1c304 100644 --- a/studio/src/shared/release.json +++ b/studio/src/shared/release.json @@ -1,3 +1,3 @@ { - "version": "0.1.90" + "version": "0.1.91" } diff --git a/studio/test/projectMetadata.test.ts b/studio/test/projectMetadata.test.ts index 750fc305..92a476ff 100644 --- a/studio/test/projectMetadata.test.ts +++ b/studio/test/projectMetadata.test.ts @@ -1,10 +1,12 @@ // Verify project metadata through real HTTP routes, durable rooms, and source insertion. // Temporary projects isolate conflicts, serialization, guide validation, and publish path checks. +// Exercise each settings file as the first save, without a guide file or metadata directory. import assert from 'node:assert/strict'; import test from 'node:test'; import { createServer } from 'node:http'; import { once } from 'node:events'; -import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { createRooms } from '../src/features/collaboration/rooms'; @@ -14,12 +16,142 @@ import { structuralEdits } from '../src/features/source/sourceOperations'; import { applyEdits, parsePage } from '../src/features/source/source'; import { filePath, record } from '../src/shared/validation'; import { metadataFiles } from '../src/features/settings/projectMetadata'; +import { + METADATA_FILES, + metadataKind, + parseMetadataResult, + type MetadataKind, +} from '../vendor/stacki/shared/properties/projectMetadata'; +import { + DEFAULT_PUBLISH_CONFIG, + parsePublishSource, +} from '../vendor/stacki/shared/properties/publishConfig'; + +const firstSettings = { + components: { + version: 1, + components: { + 'src/components/Wrapper.astro': { + children: [{ component: 'src/components/Heading.astro', props: { slot: 'aside' } }], + }, + }, + }, + guide: { + version: 1, + settings: { + visible: true, + numbers: false, + color: '#888', + opacity: 0.1, + columns: { kind: 'count', count: 8 }, + }, + }, + pages: { version: 1, entries: ['about/', 'index.astro'] }, + componentSources: { version: 1, sources: [] }, + publish: { + ...DEFAULT_PUBLISH_CONFIG, + defaultTarget: 'hosting', + githubAccount: 'owner', + githubWorkflow: { kind: 'pull-request' }, + hosting: { provider: 'cloudflare-workers', accountId: 'account', project: 'website' }, + buildScript: 'build-site', + beforePublish: ['prepare-site'], + afterPublish: ['verify-site'], + }, +} satisfies Readonly>; + +test('Desktop and Studio exchange publish settings through the same project file', async () => { + const setup = await fixture(); + try { + const source = JSON.stringify(firstSettings.publish); + const initial = desktopPublishMetadata(setup.root, { source }); + assert.deepEqual(initial, { ok: true, value: { source } }); + assert.equal((await setup.get('publish'))['source'], source); + const changed = JSON.stringify({ ...firstSettings.publish, defaultTarget: 'github' }); + assert.equal( + (await setup.post({ kind: 'publish', source: changed, base: source })).status, + 200, + ); + assert.deepEqual(desktopPublishMetadata(setup.root, {}), { + ok: true, + value: { source: changed }, + }); + assert.deepEqual(await metadataFiles(setup.root), ['.stacki/publish.json']); + } finally { + await setup.close(); + } +}); + +function desktopPublishMetadata(root: string, input: { readonly source?: string }) { + // Desktop's module graph is deliberately absent from the Studio bundle. Run its actual + // TypeScript entry point in a separate host process, without weakening Studio's imports. + const output = execFileSync( + process.execPath, + [ + '--import', + 'tsx', + '--eval', + `const { readProjectMetadata, writeProjectMetadata } = + require('../electron/project/projectMetadata.ts'); + const { parseMetadataWrite } = require('../shared/properties/projectMetadata.ts'); + const { readPublishConfig } = require('../electron/project/publishConfig.ts'); + const root = process.argv[1]; + const input = JSON.parse(process.argv[2]); + const result = input.source === undefined + ? readProjectMetadata(root, 'publish') + : writeProjectMetadata(root, parseMetadataWrite({kind: 'publish', ...input})); + readPublishConfig(root); + process.stdout.write(JSON.stringify(result));`, + root, + JSON.stringify(input), + ], + { + cwd: path.resolve(import.meta.dirname, '..'), + encoding: 'utf8', + timeout: 10000, + maxBuffer: 65536, + }, + ); + const result: unknown = JSON.parse(output); + return parseMetadataResult(result); +} + +for (const [key, value] of Object.entries(firstSettings)) { + const kind = metadataKind(key); + test(`${kind} creates its own file on first Studio save without guide settings`, async () => { + const setup = await fixture(); + try { + const directory = path.join(setup.root, '.stacki'); + assert.deepEqual(await setup.get(kind), {}); + assert.equal((await setup.post({ kind, source: '{broken' })).status, 400); + await assert.rejects(readdir(directory), { code: 'ENOENT' }); + const source = JSON.stringify(value); + const changed = JSON.stringify(value, undefined, 2) + '\n'; + assert.equal((await setup.post({ kind, source })).status, 200); + assert.deepEqual(await readdir(directory), [path.basename(METADATA_FILES[kind])]); + assert.equal((await setup.get(kind))['source'], source); + assert.equal((await setup.post({ kind, source: changed, base: source })).status, 200); + assert.equal((await setup.get(kind))['source'], changed); + assert.equal((await setup.post({ kind, source, base: source })).status, 400); + assert.equal(await readFile(path.join(setup.root, METADATA_FILES[kind]), 'utf8'), changed); + assert.deepEqual(await metadataFiles(setup.root), [METADATA_FILES[kind]]); + if (kind === 'publish') { + assert.deepEqual(parsePublishSource(changed), firstSettings.publish); + } + } finally { + await setup.close(); + } + }); +} async function fixture() { const root = await mkdtemp(path.join(tmpdir(), 'studio-metadata-')); await mkdir(path.join(root, 'src/components'), { recursive: true }); await writeFile(path.join(root, 'src/components/Heading.astro'), '

'); - await writeFile(path.join(root, 'src/components/Wrapper.astro'), '
'); + await writeFile( + path.join(root, 'src/components/Wrapper.astro'), + '
', + ); const rooms = createRooms(root); const server = createServer((request, response) => { void metadataRoute(root, rooms, request, response, '/studio/api/metadata', async () => { @@ -63,7 +195,14 @@ test('Studio saves shared recipes, rejects stale saves, and inserts the saved ne props: { hidden: false }, }, 'src/components/Wrapper.astro': { - children: [{ component: 'src/components/Heading.astro' }], + children: [ + { component: 'src/components/Heading.astro' }, + { + component: 'src/components/Heading.astro', + props: { slot: 'aside' }, + text: 'Sidebar default', + }, + ], }, }, }); @@ -90,6 +229,8 @@ test('Studio saves shared recipes, rejects stale saves, and inserts the saved ne assert.match(output, /Keep this comment/); assert.match(output, /import Heading from/); assert.match(output, /hidden=\{false\}/); + assert.match(output, /slot="aside"/); + assert.match(output, /Sidebar default/); assert.doesNotMatch(output, //); assert.equal(parsePage(output).editable, true); const changed = source.replace('Literal', 'Updated'); diff --git a/test/electron/project/projectMetadata.test.ts b/test/electron/project/projectMetadata.test.ts index 9462a2ad..51ef1ac4 100644 --- a/test/electron/project/projectMetadata.test.ts +++ b/test/electron/project/projectMetadata.test.ts @@ -9,6 +9,70 @@ import { readProjectMetadata, writeProjectMetadata, } from '#dist/electron/project/projectMetadata.js'; +import { + METADATA_FILES, + metadataKind, + type MetadataKind, +} from '#dist/shared/properties/projectMetadata.js'; +import { DEFAULT_PUBLISH_CONFIG } from '#dist/shared/properties/publishConfig.js'; +import { readPublishConfig } from '#dist/electron/project/publishConfig.js'; + +const firstSettings = { + components: { + version: 1, + components: { + 'src/components/Wrapper.astro': { + children: [{ component: 'src/components/Heading.astro', props: { slot: 'aside' } }], + }, + }, + }, + guide: { + version: 1, + settings: { + visible: true, + numbers: false, + color: '#888', + opacity: 0.1, + columns: { kind: 'count', count: 8 }, + }, + }, + pages: { version: 1, entries: ['about/', 'index.astro'] }, + componentSources: { version: 1, sources: [] }, + publish: { + ...DEFAULT_PUBLISH_CONFIG, + defaultTarget: 'hosting', + githubAccount: 'owner', + githubWorkflow: { kind: 'pull-request' }, + hosting: { provider: 'cloudflare-workers', accountId: 'account', project: 'website' }, + buildScript: 'build-site', + beforePublish: ['prepare-site'], + afterPublish: ['verify-site'], + }, +} satisfies Readonly>; + +for (const [key, value] of Object.entries(firstSettings)) { + const kind = metadataKind(key); + test(`${kind} creates its own file on first Desktop save without guide settings`, (context) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stacki-first-settings-')); + context.after(() => fs.rmSync(root, { recursive: true, force: true })); + const directory = path.join(root, '.stacki'); + assert.deepEqual(readProjectMetadata(root, kind), { ok: true, value: {} }); + assert.equal(writeProjectMetadata(root, { kind, source: '{broken' }).ok, false); + assert.equal(fs.existsSync(directory), false); + const source = JSON.stringify(value); + const changed = JSON.stringify(value, undefined, 2) + '\n'; + assert.equal(writeProjectMetadata(root, { kind, source }).ok, true); + assert.deepEqual(fs.readdirSync(directory), [path.basename(METADATA_FILES[kind])]); + assert.deepEqual(readProjectMetadata(root, kind), { ok: true, value: { source } }); + assert.equal(writeProjectMetadata(root, { kind, source: changed, base: source }).ok, true); + assert.equal(writeProjectMetadata(root, { kind, source, base: source }).ok, false); + assert.deepEqual(readProjectMetadata(root, kind), { ok: true, value: { source: changed } }); + assert.equal(fs.readFileSync(path.join(root, METADATA_FILES[kind]), 'utf8'), changed); + if (kind === 'publish') { + assert.deepEqual(readPublishConfig(root), firstSettings.publish); + } + }); +} test('metadata creates once, saves against its base and reports external edits', (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stacki-metadata-')); diff --git a/test/renderer/preview/guidePersistence.test.js b/test/renderer/preview/guidePersistence.test.js index e6875c56..cd7d6dea 100644 --- a/test/renderer/preview/guidePersistence.test.js +++ b/test/renderer/preview/guidePersistence.test.js @@ -69,35 +69,45 @@ test('guide migration, coalesced saves, and stale editor protection', async () = delete globalThis.CSS; }); -test('without a guide file or stored settings, the first change creates the file', async () => { - let source; - const writes = []; - globalThis.CSS = { supports: () => true }; - globalThis.localStorage = { getItem: () => undefined }; - globalThis.window = { - avb: { - projectMetadata: async () => ({ ok: true, value: source === undefined ? {} : { source } }), - writeProjectMetadata: async ({ write: input }) => { - source = input.source; - writes.push(input); - return { ok: true, value: { source } }; +for (const legacy of [ + JSON.parse('null'), + undefined, + '{broken', + 'null', + '[]', + '42', + 'x'.repeat(2049), +]) { + test(`guide saves on change with legacy record ${String(legacy).slice(0, 20)}`, async () => { + let source; + const writes = []; + globalThis.CSS = { supports: () => true }; + globalThis.localStorage = { getItem: () => legacy }; + globalThis.window = { + avb: { + projectMetadata: async () => ({ ok: true, value: source === undefined ? {} : { source } }), + writeProjectMetadata: async ({ write: input }) => { + source = input.source; + writes.push(input); + return { ok: true, value: { source } }; + }, }, - }, - }; - const states = []; - const controller = createGuideController('/project', (state) => states.push(state)); - controller.reload(); - await until(() => states.at(-1)?.loading === false); - // Let a save that should not happen have its chance to run. - await delay(50); - assert.equal(writes.length, 0); - assert.equal(source, undefined); - controller.change((settings) => ({ ...settings, opacity: 0.15 })); - await until(() => writes.length === 1); - assert.equal(writes[0].base, undefined); - assert.equal(JSON.parse(source).settings.opacity, 0.15); - controller.close(); - delete globalThis.window; - delete globalThis.localStorage; - delete globalThis.CSS; -}); + }; + const states = []; + const controller = createGuideController('/project', (state) => states.push(state)); + controller.reload(); + await until(() => states.at(-1)?.loading === false); + // Let a save that should not happen have its chance to run. + await delay(50); + assert.equal(writes.length, 0); + assert.equal(source, undefined); + controller.change((settings) => ({ ...settings, opacity: 0.15 })); + await until(() => writes.length === 1); + assert.equal(writes[0].base, undefined); + assert.equal(JSON.parse(source).settings.opacity, 0.15); + controller.close(); + delete globalThis.window; + delete globalThis.localStorage; + delete globalThis.CSS; + }); +} diff --git a/test/renderer/publishing/publishSettings.test.js b/test/renderer/publishing/publishSettings.test.js index f044328f..c9020cb1 100644 --- a/test/renderer/publishing/publishSettings.test.js +++ b/test/renderer/publishing/publishSettings.test.js @@ -50,6 +50,7 @@ function fixture(context, source) { }, }), writeProjectMetadata: async ({ write }) => { + assert.equal(write.kind, 'publish'); writes.push(JSON.parse(write.source)); return { ok: true, value: { source: write.source } }; }, From 8534b296af3d2ff49f3f00ff3f978a3218f2057b Mon Sep 17 00:00:00 2001 From: Timothy Ricks Date: Thu, 29 Oct 2026 06:55:01 -0500 Subject: [PATCH 3/4] fix(thumbnails): bound and retry transient compositor capture failures Ubuntu CI rejected the first thumbnail capture with UnknownVizError while a later capture succeeded. Replaying that exact failure against the real Electron test reproduced the missing thumbnail locally. Retry only that compositor error once after a short delay, bound each capture to three seconds, and keep the hidden window and existing screenshot on failure. Add deterministic coverage for recovery, repeated compositor errors, unrelated errors, and captures that never settle. --- docs/releases.md | 5 ++++ electron/project/thumbs.ts | 41 +++++++++++++++++++++++----- test/electron/project/thumbs.test.js | 38 ++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 573a05f6..2bd2690d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -29,6 +29,11 @@ stale-write protection and publish settings exchanged between the actual Desktop PR 66's Windows failure was the creation-event watcher assertion already corrected in 0.1.89. Updating the branch from main retains that regression fix and the removed variables-panel probe. +The refreshed Ubuntu check exposed a separate first-capture `UnknownVizError` from Electron. +Replaying that exact rejection in the real local capture test reproduced the missing thumbnail. +Capture now retries that error once, bounds each attempt to three seconds, and preserves the +previous image on failure. Tests cover recovery, persistent failure, other errors and a stalled +capture without showing the window. The native Ubuntu gate remains required for verification. Desktop, Studio and the starter are aligned at 0.1.91. ## 0.1.90 release validation diff --git a/electron/project/thumbs.ts b/electron/project/thumbs.ts index 5632a18b..11a6a75a 100644 --- a/electron/project/thumbs.ts +++ b/electron/project/thumbs.ts @@ -1,7 +1,8 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; -import { BrowserWindow } from 'electron'; +import { BrowserWindow, type NativeImage, type WebContents } from 'electron'; +import { ok, err, type Result } from '../../shared/core/result'; import { toRecord } from '../../shared/core/record'; @@ -27,6 +28,8 @@ const THUMB_WIDTH = 720; // still gets its picture taken. const SETTLE_MS = 1800; const LOAD_TIMEOUT = 15000; +const CAPTURE_TIMEOUT_MS = 3000; +const CAPTURE_RETRY_MS = 150; const thumbsDirectory = (userDataPath: string): string => path.join(userDataPath, 'thumbs'); const keyFor = (projectPath: string): string => @@ -185,6 +188,31 @@ async function within(promise: Promise, ms: number, message: string): Prom } } +async function captureImage(contents: WebContents): Promise> { + const first = await captureImageAttempt(contents); + if (first.ok) { + return first; + } + if (first.error !== 'UnknownVizError') { + return first; + } + // A cold compositor can reject its first copy even after load and animation frames. + // Retry only that transient error, once, without reloading or showing the window. + await wait(CAPTURE_RETRY_MS); + return captureImageAttempt(contents); +} + +function captureImageAttempt(contents: WebContents): Promise> { + return within( + contents.capturePage( + { x: 0, y: 0, width: VIEWPORT.width, height: VIEWPORT.height }, + { stayHidden: false, stayAwake: false }, + ), + CAPTURE_TIMEOUT_MS, + 'the page capture did not finish', + ).then(ok, (error: unknown) => err(error instanceof Error ? error.message : String(error))); +} + type CaptureResult = { readonly ok: true } | { readonly ok: false; readonly error: string }; /** @@ -245,12 +273,11 @@ async function capture( ).catch(() => {}); await wait(150); - const image = await win.webContents.capturePage({ - x: 0, - y: 0, - width: VIEWPORT.width, - height: VIEWPORT.height, - }); + const captured = await captureImage(win.webContents); + if (!captured.ok) { + return captured; + } + const image = captured.value; if (image.isEmpty()) { return { ok: false, error: 'the capture came back empty' }; } diff --git a/test/electron/project/thumbs.test.js b/test/electron/project/thumbs.test.js index 8c32de5a..afff1c06 100644 --- a/test/electron/project/thumbs.test.js +++ b/test/electron/project/thumbs.test.js @@ -55,6 +55,39 @@ const PAGE = ` `; +// Replay the compositor failure reported by Linux CI before allowing a real screenshot. +// This makes the cold-render failure deterministic without showing a test window. +let captureCalls = 0; +app.once('browser-window-created', (_event, window) => { + const capturePage = window.webContents.capturePage.bind(window.webContents); + window.webContents.capturePage = (...args) => { + captureCalls += 1; + if (captureCalls === 1) { + return Promise.reject(new Error('UnknownVizError')); + } + return capturePage(...args); + }; +}); + +async function checkCaptureFailure(userData, projectDirectory, url, message, attempts) { + const file = thumbs.thumbPathFor(userData, projectDirectory); + const previous = fs.readFileSync(file); + let calls = 0; + app.once('browser-window-created', (_event, window) => { + window.webContents.capturePage = () => { + calls += 1; + return message === 'timeout' ? new Promise(() => {}) : Promise.reject(new Error(message)); + }; + }); + const started = Date.now(); + const result = await thumbs.capture(userData, projectDirectory, url); + const expected = message === 'timeout' ? 'the page capture did not finish' : message; + check(`${message} is reported`, result.ok === false && result.error === expected, result.error); + check(`${message} has bounded attempts`, calls === attempts, String(calls)); + check(`${message} finishes within its bound`, Date.now() - started < 15000); + check(`${message} preserves the previous thumbnail`, previous.equals(fs.readFileSync(file))); +} + // The pixel at (x, y) of a PNG on disk. function pixelAt(file, x, y) { const image = nativeImage.createFromPath(file); @@ -92,6 +125,7 @@ app.whenReady().then(async () => { // --- the capture --------------------------------------------------------- const result = await thumbs.capture(userData, projectDirectory, url); check('the capture succeeds', result.ok === true, result.error); + check('a cold compositor failure retries once', captureCalls === 2, String(captureCalls)); const file = thumbs.thumbPathFor(userData, projectDirectory); check('a file is written', fs.existsSync(file)); @@ -137,6 +171,10 @@ app.whenReady().then(async () => { check('an unreachable site fails quietly', dead.ok === false && typeof dead.error === 'string'); check('and keeps the picture it had', fs.existsSync(file)); + await checkCaptureFailure(userData, projectDirectory, url, 'UnknownVizError', 2); + await checkCaptureFailure(userData, projectDirectory, url, 'capture unavailable', 1); + await checkCaptureFailure(userData, projectDirectory, url, 'timeout', 1); + // --- forgetting ---------------------------------------------------------- thumbs.forget(userData, projectDirectory); check('removing a project takes its picture with it', !fs.existsSync(file)); From 2b708873860346bf3b9f9b3a9c10c4704c3004f0 Mon Sep 17 00:00:00 2001 From: Timothy Ricks Date: Thu, 29 Oct 2026 07:04:05 -0500 Subject: [PATCH 4/4] test(thumbnails): replay compositor failures after native capture setup Run Electron's actual first capture before forcing the recorded rejection. Its native setup can then warm the compositor exactly as a failed real copy does, so the retry is not artificially turned into the first native capture. The targeted real-Electron regression and changed-file checks pass; the complete gate runs before push. --- test/electron/project/thumbs.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/electron/project/thumbs.test.js b/test/electron/project/thumbs.test.js index afff1c06..56ce0a68 100644 --- a/test/electron/project/thumbs.test.js +++ b/test/electron/project/thumbs.test.js @@ -63,7 +63,11 @@ app.once('browser-window-created', (_event, window) => { window.webContents.capturePage = (...args) => { captureCalls += 1; if (captureCalls === 1) { - return Promise.reject(new Error('UnknownVizError')); + // Exercise native capture setup too: replacing it outright would leave the retry + // as the compositor's first real attempt and introduce a second, unrelated cold failure. + return capturePage(...args).then(() => { + throw new Error('UnknownVizError'); + }); } return capturePage(...args); };