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..2bd2690d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,6 +15,27 @@ 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. +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 At the user's request, remove the variables-panel layout probe because its visible Electron 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/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/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..94ee1ff6 100644 --- a/src/features/preview/guideSettings.ts +++ b/src/features/preview/guideSettings.ts @@ -165,24 +165,34 @@ function storageKey(projectKey: string): string { return GUIDE_STORAGE_PREFIX + projectKey; } -export function loadLegacyGuideSettings(projectKey: string | undefined): GuideSettings { +// 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 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; + 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/electron/project/thumbs.test.js b/test/electron/project/thumbs.test.js index 8c32de5a..56ce0a68 100644 --- a/test/electron/project/thumbs.test.js +++ b/test/electron/project/thumbs.test.js @@ -55,6 +55,43 @@ 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) { + // 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); + }; +}); + +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 +129,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 +175,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)); diff --git a/test/renderer/preview/guidePersistence.test.js b/test/renderer/preview/guidePersistence.test.js index bf04e159..cd7d6dea 100644 --- a/test/renderer/preview/guidePersistence.test.js +++ b/test/renderer/preview/guidePersistence.test.js @@ -68,3 +68,46 @@ test('guide migration, coalesced saves, and stale editor protection', async () = delete globalThis.localStorage; delete globalThis.CSS; }); + +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; + }); +} 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 } }; },