From edd8b6e22550c85893313407e19137fb70eefeac Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 16 Sep 2026 15:14:33 +0200 Subject: [PATCH 1/2] fix: write the uniwind.css artifact atomically `buildCSS` rewrote `uniwind.css` in place with `fs.writeFileSync`, which truncates the file to zero bytes before refilling it (~22 KB in a real project). Metro runs transforms in a worker pool and `expo export -p web` builds the client and SSR graphs concurrently, so one worker could sit inside that write while another worker's Tailwind pass read the same file through `@import "uniwind"`. The reader got a partial file, and Tailwind reported the failure against the consumer's entry file instead: SyntaxError: src/styles/global.css: Missing closing } at @theme `@theme {` sits near the end of the generated artifact, which is why a truncated read almost always landed there. The early return on unchanged content is what made this look flaky: the write only happens when the artifact is stale, which after a fresh install it always is, so it failed in CI and almost never on a developer machine where the file had been correct since the first build. Write to a unique temporary file beside the target and rename it into place. A rename within a filesystem is atomic, so a concurrent reader sees either the whole old file or the whole new one. The temporary name carries the pid and a random suffix, because the racing writers are separate Metro workers and a shared name would only move the race. The rename also fixes a second bug on the same line: package managers hardlink `uniwind.css` from a content-addressable store, so pnpm installs gave it a link count above 1 and writing in place mutated the store copy for every project on the machine. Replacing the directory entry breaks the link and leaves the store's inode alone. Tests spawn four writer/reader processes against one path and assert every read is byte-identical to one of the written contents, assert the hardlink is broken rather than followed, and assert a regeneration still produces the same bytes as before while a warm build leaves the inode untouched. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 1 + .../src/bundler/artifacts/css/index.ts | 6 +- .../src/bundler/artifacts/writeFileAtomic.ts | 37 ++++ .../web/bundler/write-file-atomic.test.ts | 183 ++++++++++++++++++ 4 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts create mode 100644 packages/uniwind/tests/web/bundler/write-file-atomic.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index f3ca4ab4..5bcdce14 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -111,6 +111,7 @@ Compilation flow: - `compileWebCSS` runs Lightning CSS with `UniwindCSSVisitor` and returns CSS. - `compileNativeCSS` runs `ProcessorBuilder`, serializes variables, scoped variables, and native stylesheet metadata into JS source. - `UniwindBundlerConfig.generateArtifacts` writes CSS artifacts and generated theme typings. +- The CSS artifact is rewritten in place inside the installed package, and Metro transforms it from a worker pool, so `buildCSS` writes through `writeFileAtomicSync`: a unique temporary file next to the target, renamed over it. Readers racing the write see the whole old file or the whole new one, and the rename breaks the package manager's hardlink into its content-addressable store instead of mutating the shared copy. - Internal package aliases such as `@/*` are only safe inside `packages/uniwind/src/bundler`. Bundler files are built and transformed to JS, but runtime/component/hook/HOC files are published directly as `.ts`/`.tsx` React Native entrypoints, so aliases in those files are not rewritten. Metro integration: diff --git a/packages/uniwind/src/bundler/artifacts/css/index.ts b/packages/uniwind/src/bundler/artifacts/css/index.ts index 420c421c..ac43b5e2 100644 --- a/packages/uniwind/src/bundler/artifacts/css/index.ts +++ b/packages/uniwind/src/bundler/artifacts/css/index.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import { writeFileAtomicSync } from '../writeFileAtomic' import { EXTRA_UTILITIES_CSS } from './extraUtilities' import { INSETS_CSS } from './insets' import { OVERWRITE_CSS } from './overwrite' @@ -23,8 +24,5 @@ export const buildCSS = async (themes: Array, input: string, cssFilePath return } - fs.writeFileSync( - cssFilePath, - newCssFile, - ) + writeFileAtomicSync(cssFilePath, newCssFile) } diff --git a/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts b/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts new file mode 100644 index 00000000..eef88a97 --- /dev/null +++ b/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts @@ -0,0 +1,37 @@ +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' + +/** + * Writes `content` to `filePath` by filling a unique temporary file next to it and + * renaming that file into place. + * + * Generated artifacts sit inside the installed package and are regenerated on every + * build. Metro runs transforms in a worker pool, so another worker can read an artifact + * (through `@import "uniwind"`, for instance) while this one rewrites it. Writing in + * place truncates the file first, which makes that reader see a partial stylesheet and + * report a syntax error against its own entry file. A rename within a filesystem is + * atomic, so readers see either the whole old file or the whole new one. + * + * Renaming also replaces the directory entry instead of writing through it, which breaks + * the hardlink package managers such as pnpm create from their content-addressable store + * instead of mutating the copy shared by every project on the machine. + */ +export const writeFileAtomicSync = (filePath: string, content: string) => { + // The racing writers are separate Metro workers, so a shared temporary name would + // only move the race. Workers can be processes or threads, hence the random suffix + // on top of the pid. + const tmpPath = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`, + ) + + try { + fs.writeFileSync(tmpPath, content) + fs.renameSync(tmpPath, filePath) + } catch (error) { + fs.rmSync(tmpPath, { force: true }) + + throw error + } +} diff --git a/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts b/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts new file mode 100644 index 00000000..8fcab567 --- /dev/null +++ b/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment node +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import { transformWithOxc } from 'vite' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { buildCSS } from '../../../src/bundler/artifacts/css' +import { EXTRA_UTILITIES_CSS } from '../../../src/bundler/artifacts/css/extraUtilities' +import { INSETS_CSS } from '../../../src/bundler/artifacts/css/insets' +import { OVERWRITE_CSS } from '../../../src/bundler/artifacts/css/overwrite' +import { generateCSSForThemes } from '../../../src/bundler/artifacts/css/themes' +import { VARIANTS_CSS } from '../../../src/bundler/artifacts/css/variants' +import { writeFileAtomicSync } from '../../../src/bundler/artifacts/writeFileAtomic' + +const execFileAsync = promisify(execFile) + +const HELPER_PATH = path.resolve('./src/bundler/artifacts/writeFileAtomic.ts') +const CSS_ENTRY_FILE = './tests/test.css' +const THEMES = ['light', 'dark'] +const WORKERS = 4 +const WRITES_PER_WORKER = 60 +const READS_PER_WRITE = 10 + +// Shaped like the real artifact: big enough that a truncated write is observable, and +// ending with the `@theme` block that a partial read used to cut in half. +const buildContent = (id: number) => + [ + `/* uniwind artifact ${id} */`, + ...Array.from({ length: 2000 }, (_, index) => `.uniwind-${id}-${index} { color: red; }`), + '@theme {', + ` --uniwind-marker: ${id};`, + '}', + '', + ].join('\n') + +const CONTENTS = [0, 1, 2].map(buildContent) + +// Each worker rewrites the same path and keeps reading it, so every read races the other +// workers' writes. A read that is not byte-identical to one of the known contents is a +// torn read. +const WORKER_SCRIPT = ` +import fs from 'node:fs' +import { writeFileAtomicSync } from './writeFileAtomic.mjs' + +const [target, contentsPath, seed, writes, reads] = process.argv.slice(2) +const contents = JSON.parse(fs.readFileSync(contentsPath, 'utf-8')) +const expected = new Set(contents) +const fail = message => { + process.stdout.write(JSON.stringify({ error: message })) + process.exit(1) +} + +let observed = 0 + +for (let write = 0; write < Number(writes); write++) { + writeFileAtomicSync(target, contents[(Number(seed) + write) % contents.length]) + + for (let read = 0; read < Number(reads); read++) { + let content + + try { + content = fs.readFileSync(target, 'utf-8') + } catch (error) { + fail(\`read failed with \${error.code ?? error.message}\`) + } + + observed++ + + if (!expected.has(content)) { + fail(\`read \${content.length} bytes that match none of the written contents\`) + } + } +} + +process.stdout.write(JSON.stringify({ observed })) +` + +describe('writeFileAtomicSync', () => { + let workingDir = '' + + beforeAll(async () => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniwind-atomic-')) + + // The workers run in their own processes, so they need the implementation as + // plain JS. Transpiling the real source keeps them on the shipped code. + const { code } = await transformWithOxc(fs.readFileSync(HELPER_PATH, 'utf-8'), HELPER_PATH) + + fs.writeFileSync(path.join(workingDir, 'writeFileAtomic.mjs'), code) + fs.writeFileSync(path.join(workingDir, 'worker.mjs'), WORKER_SCRIPT) + fs.writeFileSync(path.join(workingDir, 'contents.json'), JSON.stringify(CONTENTS)) + }) + + afterAll(() => { + fs.rmSync(workingDir, { recursive: true, force: true }) + }) + + test('concurrent writers and readers never observe a partial file', async () => { + const target = path.join(workingDir, 'uniwind.css') + const results = await Promise.all( + Array.from({ length: WORKERS }, (_, seed) => + execFileAsync(process.execPath, [ + path.join(workingDir, 'worker.mjs'), + target, + path.join(workingDir, 'contents.json'), + String(seed), + String(WRITES_PER_WORKER), + String(READS_PER_WRITE), + ]) + // A worker that saw a torn read exits with a non-zero code, which + // rejects here - its report is still on stdout. + .catch(error => error as { stdout?: string; stderr?: string }) + .then(({ stdout, stderr }) => + JSON.parse(stdout || JSON.stringify({ error: `worker crashed: ${stderr}` })) as { + observed?: number + error?: string + } + )), + ) + + expect(results.map(result => result.error).filter(Boolean)).toEqual([]) + expect(results.reduce((total, result) => total + (result.observed ?? 0), 0)).toBe( + WORKERS * WRITES_PER_WORKER * READS_PER_WRITE, + ) + expect(CONTENTS).toContain(fs.readFileSync(target, 'utf-8')) + // Every temporary file was renamed away, none were left behind. + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }, 60_000) + + test('replaces the file instead of writing through a package manager hardlink', () => { + const storePath = path.join(workingDir, 'store.css') + const installedPath = path.join(workingDir, 'installed.css') + + fs.writeFileSync(storePath, 'store content') + fs.linkSync(storePath, installedPath) + + expect(fs.statSync(installedPath).nlink).toBe(2) + + writeFileAtomicSync(installedPath, 'rebuilt content') + + expect(fs.readFileSync(installedPath, 'utf-8')).toBe('rebuilt content') + expect(fs.readFileSync(storePath, 'utf-8')).toBe('store content') + expect(fs.statSync(installedPath).nlink).toBe(1) + }) +}) + +describe('buildCSS', () => { + let workingDir = '' + + beforeAll(() => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniwind-build-css-')) + }) + + afterAll(() => { + fs.rmSync(workingDir, { recursive: true, force: true }) + }) + + test('writes the artifact once and leaves it alone while it is up to date', async () => { + const cssFilePath = path.join(workingDir, 'uniwind.css') + + await buildCSS(THEMES, CSS_ENTRY_FILE, cssFilePath) + + const expected = [ + VARIANTS_CSS, + INSETS_CSS, + OVERWRITE_CSS, + EXTRA_UTILITIES_CSS, + await generateCSSForThemes(THEMES, CSS_ENTRY_FILE), + ].join('\n') + + expect(fs.readFileSync(cssFilePath, 'utf-8')).toBe(expected) + + // A rename swaps the inode, so an unchanged inode proves the warm build wrote + // nothing at all. + const inode = fs.statSync(cssFilePath).ino + + await buildCSS(THEMES, CSS_ENTRY_FILE, cssFilePath) + + expect(fs.statSync(cssFilePath).ino).toBe(inode) + expect(fs.readFileSync(cssFilePath, 'utf-8')).toBe(expected) + }, 60_000) +}) From e71d86b8d36ded4f1191382c261d4de2d4f99f79 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 22 Sep 2026 10:11:33 +0200 Subject: [PATCH 2/2] fix: retry locked renames and write the dts artifact atomically Addresses review on #677. `buildDtsFile` has the same shape as `buildCSS` - read, compare, early return, write - and runs in the same `generateArtifacts` call from the same Metro worker pool, so it carried the same torn-read and pnpm hardlink exposure. It writes through `writeFileAtomicSync` now too. Windows uses mandatory file locking: a rename over the target needs delete access on it, so an antivirus scanner, the search indexer or another worker holding a handle makes `renameSync` fail with EPERM, EACCES or EBUSY. The error would propagate out of the Metro transformer and fail the build. This is the bug graceful-fs patches `rename` for on Windows, which is not a dependency here, so retry those codes five times with exponential backoff (~620 ms in total) before giving up, still cleaning up the temporary file. The retry is not gated on win32: EBUSY also shows up on network filesystems, and gating it would make the path untestable on CI. A permanently failing rename, a read-only node_modules for instance, now takes ~620 ms longer to report the same error. Also trims the helper's doc comment down to why the rename is there. --- CONTEXT.md | 2 +- packages/uniwind/src/bundler/artifacts/dts.ts | 3 +- .../src/bundler/artifacts/writeFileAtomic.ts | 49 +++++++---- .../web/bundler/write-file-atomic.test.ts | 83 ++++++++++++++++++- 4 files changed, 117 insertions(+), 20 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5bcdce14..291ebe1d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -111,7 +111,7 @@ Compilation flow: - `compileWebCSS` runs Lightning CSS with `UniwindCSSVisitor` and returns CSS. - `compileNativeCSS` runs `ProcessorBuilder`, serializes variables, scoped variables, and native stylesheet metadata into JS source. - `UniwindBundlerConfig.generateArtifacts` writes CSS artifacts and generated theme typings. -- The CSS artifact is rewritten in place inside the installed package, and Metro transforms it from a worker pool, so `buildCSS` writes through `writeFileAtomicSync`: a unique temporary file next to the target, renamed over it. Readers racing the write see the whole old file or the whole new one, and the rename breaks the package manager's hardlink into its content-addressable store instead of mutating the shared copy. +- Generated artifacts are rewritten in place and Metro regenerates them from a worker pool, so `buildCSS` and `buildDtsFile` write through `writeFileAtomicSync`: a unique temporary file next to the target, renamed over it. Readers racing the write see the whole old file or the whole new one, the rename breaks the package manager's hardlink into its content-addressable store instead of mutating the shared copy, and a rename a lock refuses is retried before it fails the build. - Internal package aliases such as `@/*` are only safe inside `packages/uniwind/src/bundler`. Bundler files are built and transformed to JS, but runtime/component/hook/HOC files are published directly as `.ts`/`.tsx` React Native entrypoints, so aliases in those files are not rewritten. Metro integration: diff --git a/packages/uniwind/src/bundler/artifacts/dts.ts b/packages/uniwind/src/bundler/artifacts/dts.ts index 2df073f4..2a054d4e 100644 --- a/packages/uniwind/src/bundler/artifacts/dts.ts +++ b/packages/uniwind/src/bundler/artifacts/dts.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import { writeFileAtomicSync } from './writeFileAtomic' export const buildDtsFile = (dtsPath: string, stringifiedThemes: string) => { const oldDtsContent = fs.existsSync(dtsPath) @@ -22,5 +23,5 @@ export const buildDtsFile = (dtsPath: string, stringifiedThemes: string) => { return } - fs.writeFileSync(dtsPath, dtsContent) + writeFileAtomicSync(dtsPath, dtsContent) } diff --git a/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts b/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts index eef88a97..021ec645 100644 --- a/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts +++ b/packages/uniwind/src/bundler/artifacts/writeFileAtomic.ts @@ -2,25 +2,40 @@ import crypto from 'crypto' import fs from 'fs' import path from 'path' +// A rename over an open file is refused while another process holds a lock on it, which +// on Windows covers an antivirus scanner or the search indexer touching the artifact we +// just wrote. Those locks clear in milliseconds. +const TRANSIENT_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']) +const RETRIES = 5 +const RETRY_DELAY = 20 + +const sleepSync = (ms: number) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) + +const renameSyncWithRetries = (tmpPath: string, filePath: string) => { + for (let attempt = 0;; attempt++) { + try { + return fs.renameSync(tmpPath, filePath) + } catch (error) { + const isTransient = TRANSIENT_ERRORS.has((error as NodeJS.ErrnoException).code ?? '') + + if (!isTransient || attempt === RETRIES) { + throw error + } + + sleepSync(RETRY_DELAY * 2 ** attempt) + } + } +} + /** - * Writes `content` to `filePath` by filling a unique temporary file next to it and - * renaming that file into place. - * - * Generated artifacts sit inside the installed package and are regenerated on every - * build. Metro runs transforms in a worker pool, so another worker can read an artifact - * (through `@import "uniwind"`, for instance) while this one rewrites it. Writing in - * place truncates the file first, which makes that reader see a partial stylesheet and - * report a syntax error against its own entry file. A rename within a filesystem is - * atomic, so readers see either the whole old file or the whole new one. - * - * Renaming also replaces the directory entry instead of writing through it, which breaks - * the hardlink package managers such as pnpm create from their content-addressable store - * instead of mutating the copy shared by every project on the machine. + * Writes `content` to a unique temporary file next to `filePath`, then renames it into + * place. Metro regenerates artifacts from a worker pool, so writing in place would let + * another worker read a half-written file. The rename also replaces the directory entry + * instead of writing through the hardlink package managers create from their store. */ export const writeFileAtomicSync = (filePath: string, content: string) => { - // The racing writers are separate Metro workers, so a shared temporary name would - // only move the race. Workers can be processes or threads, hence the random suffix - // on top of the pid. + // A shared temporary name would only move the race between workers, which can be + // processes or threads, hence the random suffix on top of the pid. const tmpPath = path.join( path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`, @@ -28,7 +43,7 @@ export const writeFileAtomicSync = (filePath: string, content: string) => { try { fs.writeFileSync(tmpPath, content) - fs.renameSync(tmpPath, filePath) + renameSyncWithRetries(tmpPath, filePath) } catch (error) { fs.rmSync(tmpPath, { force: true }) diff --git a/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts b/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts index 8fcab567..d5cd5fba 100644 --- a/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts +++ b/packages/uniwind/tests/web/bundler/write-file-atomic.test.ts @@ -5,13 +5,14 @@ import os from 'node:os' import path from 'node:path' import { promisify } from 'node:util' import { transformWithOxc } from 'vite' -import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vitest' import { buildCSS } from '../../../src/bundler/artifacts/css' import { EXTRA_UTILITIES_CSS } from '../../../src/bundler/artifacts/css/extraUtilities' import { INSETS_CSS } from '../../../src/bundler/artifacts/css/insets' import { OVERWRITE_CSS } from '../../../src/bundler/artifacts/css/overwrite' import { generateCSSForThemes } from '../../../src/bundler/artifacts/css/themes' import { VARIANTS_CSS } from '../../../src/bundler/artifacts/css/variants' +import { buildDtsFile } from '../../../src/bundler/artifacts/dts' import { writeFileAtomicSync } from '../../../src/bundler/artifacts/writeFileAtomic' const execFileAsync = promisify(execFile) @@ -143,6 +144,53 @@ describe('writeFileAtomicSync', () => { expect(fs.readFileSync(storePath, 'utf-8')).toBe('store content') expect(fs.statSync(installedPath).nlink).toBe(1) }) + + describe('when a lock refuses the rename', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const lockError = () => Object.assign(new Error('EPERM: operation not permitted, rename'), { code: 'EPERM' }) + + test('retries until the lock clears', () => { + const target = path.join(workingDir, 'locked-then-free.css') + const renameSync = fs.renameSync + const rename = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { + throw lockError() + }).mockImplementationOnce(() => { + throw lockError() + }).mockImplementation(renameSync) + + writeFileAtomicSync(target, 'written under a lock') + + expect(rename).toHaveBeenCalledTimes(3) + expect(fs.readFileSync(target, 'utf-8')).toBe('written under a lock') + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }) + + test('gives up and cleans up when the lock never clears', () => { + const target = path.join(workingDir, 'locked-forever.css') + const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { + throw lockError() + }) + + expect(() => writeFileAtomicSync(target, 'never written')).toThrow('EPERM') + expect(rename).toHaveBeenCalledTimes(6) + expect(fs.existsSync(target)).toBe(false) + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }) + + test('does not retry an error that is not a lock', () => { + const target = path.join(workingDir, 'broken.css') + const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { + throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' }) + }) + + expect(() => writeFileAtomicSync(target, 'never written')).toThrow('ENOSPC') + expect(rename).toHaveBeenCalledTimes(1) + expect(fs.readdirSync(workingDir).filter(entry => entry.endsWith('.tmp'))).toEqual([]) + }) + }) }) describe('buildCSS', () => { @@ -181,3 +229,36 @@ describe('buildCSS', () => { expect(fs.readFileSync(cssFilePath, 'utf-8')).toBe(expected) }, 60_000) }) + +describe('buildDtsFile', () => { + let workingDir = '' + + beforeAll(() => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniwind-dts-')) + }) + + afterAll(() => { + fs.rmSync(workingDir, { recursive: true, force: true }) + }) + + test('writes the declaration file once and leaves it alone while it is up to date', () => { + const dtsPath = path.join(workingDir, 'uniwind-types.d.ts') + + buildDtsFile(dtsPath, '[\'light\', \'dark\']') + + const content = fs.readFileSync(dtsPath, 'utf-8') + + expect(content).toContain('themes: readonly [\'light\', \'dark\']') + + const inode = fs.statSync(dtsPath).ino + + buildDtsFile(dtsPath, '[\'light\', \'dark\']') + + expect(fs.statSync(dtsPath).ino).toBe(inode) + + buildDtsFile(dtsPath, '[\'light\', \'dark\', \'sepia\']') + + expect(fs.statSync(dtsPath).ino).not.toBe(inode) + expect(fs.readFileSync(dtsPath, 'utf-8')).toContain('themes: readonly [\'light\', \'dark\', \'sepia\']') + }) +})