From ed3594bc7d4cf54f4054dc9b66735064a51f095a Mon Sep 17 00:00:00 2001 From: KeinerM Date: Wed, 6 May 2026 12:26:55 -0400 Subject: [PATCH 1/2] feat(start): validate watched saves before upload (SIR-1523) Co-authored-by: Cursor --- src/start.js | 218 +++++++++++++++++++++++++++++--------------- tests/start.test.js | 192 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 73 deletions(-) create mode 100644 tests/start.test.js diff --git a/src/start.js b/src/start.js index 6c8fcdf..f2d9764 100644 --- a/src/start.js +++ b/src/start.js @@ -24,9 +24,153 @@ import { updateComponentFile, updateComponentConfig, } from './actions/components.js'; +import { validateCampaignSass, validateComponent } from './actions/validate.js'; import { getToken } from './actions/auth.js'; import { loadConfig } from './config.js'; +function startLoader(message, oraImpl = ora) { + return oraImpl(message).start(); +} + +export async function handleCampaignChange( + filenameRaw, + { + campaignsDir, + token, + uploadStylesFn = uploadStyles, + validateCampaignSassFn = validateCampaignSass, + oraFn = ora, + } = {} +) { + const relative = path.relative(campaignsDir, filenameRaw); + const parts = relative.split(path.sep); + // Only handle stylesheet changes: /stylesheets/... + if (parts.length < 3 || parts[1] !== 'stylesheets') return; + const campaignPath = parts[0]; + const loader = startLoader(`Saving ${relative}`, oraFn); + const validation = await validateCampaignSassFn({ + campaign: campaignPath, + token, + }); + if (!validation.ok) { + loader.fail(validation.error); + return; + } + await uploadStylesFn(campaignPath); + loader.succeed(); +} + +export async function handleComponentChange( + filenameRaw, + { + componentsDir, + config, + fsModule = fs, + pathModule = path, + updateComponentFileFn = updateComponentFile, + updateComponentConfigFn = updateComponentConfig, + validateComponentFn = validateComponent, + oraFn = ora, + errorFn = error, + } = {} +) { + const filename = pathModule.relative(componentsDir, filenameRaw); + const componentName = filename.split(pathModule.sep)[0]; + if (!componentName) return; + const loader = startLoader(`Saving ${filename}`, oraFn); + const validation = await validateComponentFn({ name: componentName }); + if (!validation.ok) { + loader.fail(validation.error); + return; + } + + try { + if (filename.includes('.json')) { + await updateComponentConfigFn( + { + filename, + file: fsModule.readFileSync( + pathModule.join( + componentsDir, + filename.replace('.json', '.js') + ), + 'utf8' + ), + config: JSON.parse( + fsModule.readFileSync(pathModule.join(componentsDir, filename), 'utf8') + ), + }, + config + ); + } else { + await updateComponentFileFn( + { + filename, + file: fsModule.readFileSync(pathModule.join(componentsDir, filename), 'utf8'), + config: JSON.parse( + fsModule.readFileSync( + pathModule.join( + componentsDir, + filename.replace('.js', '.json') + ), + 'utf8' + ) + ), + }, + config + ); + } + } catch (e) { + return errorFn(e, loader); + } + + loader.succeed(); +} + +export function registerStartWatchers( + { + campaignsDir, + componentsDir, + config, + watchFn = watch, + } = {}, + dependencies = {} +) { + const campaignWatcher = watchFn( + campaignsDir, + { encoding: 'utf8', recursive: true }, + async (eventType, filenameRaw) => { + await handleCampaignChange(filenameRaw, { + campaignsDir, + token: config.token, + uploadStylesFn: dependencies.uploadStylesFn, + validateCampaignSassFn: dependencies.validateCampaignSassFn, + oraFn: dependencies.oraFn, + }); + } + ); + + const componentWatcher = watchFn( + componentsDir, + { encoding: 'utf8', recursive: true }, + async (eventType, filenameRaw) => { + await handleComponentChange(filenameRaw, { + componentsDir, + config, + fsModule: dependencies.fsModule, + pathModule: dependencies.pathModule, + updateComponentFileFn: dependencies.updateComponentFileFn, + updateComponentConfigFn: dependencies.updateComponentConfigFn, + validateComponentFn: dependencies.validateComponentFn, + oraFn: dependencies.oraFn, + errorFn: dependencies.errorFn, + }); + } + ); + + return { campaignWatcher, componentWatcher }; +} + export default async function start() { const layout = detectLayout(process.cwd()); if (shouldRefuseLayoutForCommand('start', layout)) { @@ -60,77 +204,5 @@ export default async function start() { // watch folders const campaignsDir = path.join(process.cwd(), 'campaigns'); const componentsDir = path.join(process.cwd(), 'components'); - watch( - campaignsDir, - { encoding: 'utf8', recursive: true }, - async (eventType, filenameRaw) => { - const relative = path.relative(campaignsDir, filenameRaw); - const parts = relative.split(path.sep); - // Only handle stylesheet changes: /stylesheets/... - if (parts.length < 3 || parts[1] !== 'stylesheets') return; - const campaignPath = parts[0]; - const loader = ora(`Saving ${relative}`).start(); - - await uploadStyles(campaignPath); - - loader.succeed(); - } - ); - - watch( - componentsDir, - { encoding: 'utf8', recursive: true }, - async (eventType, filenameRaw) => { - const filename = path.relative(componentsDir, filenameRaw); - const loader = ora(`Saving ${filename}`).start(); - - try { - if (filename.includes('.json')) { - await updateComponentConfig( - { - filename, - file: fs.readFileSync( - path.join( - componentsDir, - filename.replace('.json', '.js') - ), - 'utf8' - ), - config: JSON.parse( - fs.readFileSync( - path.join(componentsDir, filename), - 'utf8' - ) - ), - }, - config - ); - } else { - const result = await updateComponentFile( - { - filename, - file: fs.readFileSync( - path.join(componentsDir, filename), - 'utf8' - ), - config: JSON.parse( - fs.readFileSync( - path.join( - componentsDir, - filename.replace('.js', '.json') - ), - 'utf8' - ) - ), - }, - config - ); - } - } catch (e) { - return error(e, loader); - } - - loader.succeed(); - } - ); + registerStartWatchers({ campaignsDir, componentsDir, config }); } diff --git a/tests/start.test.js b/tests/start.test.js new file mode 100644 index 0000000..a983313 --- /dev/null +++ b/tests/start.test.js @@ -0,0 +1,192 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + handleCampaignChange, + handleComponentChange, + registerStartWatchers, +} from '../src/start.js'; + +function createOraHarness() { + const loaders = []; + const oraFn = (message) => ({ + start() { + const loader = { + message, + succeeded: false, + failed: null, + succeed() { + this.succeeded = true; + }, + fail(errorMessage) { + this.failed = errorMessage; + }, + }; + loaders.push(loader); + return loader; + }, + }); + return { oraFn, loaders }; +} + +describe('start per-save validation', () => { + test('bad SCSS save fails inline and skips upload', async () => { + let uploadCalls = 0; + const { oraFn, loaders } = createOraHarness(); + + await handleCampaignChange('/repo/campaigns/acme/stylesheets/main.scss', { + campaignsDir: '/repo/campaigns', + token: 'token-123', + validateCampaignSassFn: async ({ campaign, token }) => { + assert.equal(campaign, 'acme'); + assert.equal(token, 'token-123'); + return { ok: false, error: 'SassError: expected "}"' }; + }, + uploadStylesFn: async () => { + uploadCalls += 1; + }, + oraFn, + }); + + assert.equal(uploadCalls, 0); + assert.equal(loaders.length, 1); + assert.equal(loaders[0].failed, 'SassError: expected "}"'); + assert.equal(loaders[0].succeeded, false); + }); + + test('bad component save fails inline and skips upload', async () => { + let updateFileCalls = 0; + const { oraFn, loaders } = createOraHarness(); + const componentFs = { + readFileSync(filePath) { + if (filePath === '/repo/components/Hero/Hero.js') { + return 'export default () =>
;'; + } + if (filePath === '/repo/components/Hero/Hero.json') { + return '{"uuid":"component-1","fields":[]}'; + } + throw new Error(`Unexpected file read: ${filePath}`); + }, + }; + + await handleComponentChange('/repo/components/Hero/Hero.js', { + componentsDir: '/repo/components', + config: { token: 'token-123' }, + fsModule: componentFs, + validateComponentFn: async ({ name }) => { + assert.equal(name, 'Hero'); + return { ok: false, error: 'Unexpected token (1:25)' }; + }, + updateComponentFileFn: async () => { + updateFileCalls += 1; + }, + updateComponentConfigFn: async () => { + throw new Error('Config update should not run for JS save'); + }, + oraFn, + }); + + assert.equal(updateFileCalls, 0); + assert.equal(loaders.length, 1); + assert.equal(loaders[0].failed, 'Unexpected token (1:25)'); + assert.equal(loaders[0].succeeded, false); + }); + + test('next valid save uploads after a failed validation', async () => { + let validationCalls = 0; + let updateFileCalls = 0; + const { oraFn, loaders } = createOraHarness(); + const componentFs = { + readFileSync(filePath) { + if (filePath === '/repo/components/Hero/Hero.js') { + return 'export default () =>
;'; + } + if (filePath === '/repo/components/Hero/Hero.json') { + return '{"uuid":"component-1","fields":[]}'; + } + throw new Error(`Unexpected file read: ${filePath}`); + }, + }; + + const invoke = () => + handleComponentChange('/repo/components/Hero/Hero.js', { + componentsDir: '/repo/components', + config: { token: 'token-123' }, + fsModule: componentFs, + validateComponentFn: async () => { + validationCalls += 1; + if (validationCalls === 1) { + return { ok: false, error: 'Unexpected token (1:25)' }; + } + return { ok: true }; + }, + updateComponentFileFn: async () => { + updateFileCalls += 1; + }, + updateComponentConfigFn: async () => { + throw new Error('Config update should not run for JS save'); + }, + oraFn, + }); + + await invoke(); + await invoke(); + + assert.equal(validationCalls, 2); + assert.equal(updateFileCalls, 1); + assert.equal(loaders[0].failed, 'Unexpected token (1:25)'); + assert.equal(loaders[1].succeeded, true); + }); + + test('watcher callback stays active after failed validation', async () => { + const callbacks = new Map(); + const watchFn = (target, _options, callback) => { + callbacks.set(target, callback); + return { close() {} }; + }; + const { oraFn, loaders } = createOraHarness(); + let validationCalls = 0; + let uploadCalls = 0; + + registerStartWatchers( + { + campaignsDir: '/repo/campaigns', + componentsDir: '/repo/components', + config: { token: 'token-123' }, + watchFn, + }, + { + oraFn, + validateCampaignSassFn: async () => { + validationCalls += 1; + if (validationCalls === 1) { + return { ok: false, error: 'SassError: expected "}"' }; + } + return { ok: true }; + }, + uploadStylesFn: async () => { + uploadCalls += 1; + }, + validateComponentFn: async () => ({ ok: true }), + updateComponentFileFn: async () => {}, + updateComponentConfigFn: async () => {}, + fsModule: { + readFileSync() { + return '{}'; + }, + }, + } + ); + + const campaignCallback = callbacks.get('/repo/campaigns'); + assert.equal(typeof campaignCallback, 'function'); + + await campaignCallback('update', '/repo/campaigns/acme/stylesheets/main.scss'); + await campaignCallback('update', '/repo/campaigns/acme/stylesheets/main.scss'); + + assert.equal(validationCalls, 2); + assert.equal(uploadCalls, 1); + assert.equal(loaders[0].failed, 'SassError: expected "}"'); + assert.equal(loaders[1].succeeded, true); + }); +}); From 6f305bb589f52d821882f052d0ddfdd65da7b06b Mon Sep 17 00:00:00 2001 From: KeinerM Date: Thu, 7 May 2026 10:52:32 -0400 Subject: [PATCH 2/2] test(start): migrate start tests to vitest (SIR-1523) Co-authored-by: Cursor --- tests/start.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/start.test.js b/tests/start.test.js index a983313..fdc3c71 100644 --- a/tests/start.test.js +++ b/tests/start.test.js @@ -1,4 +1,4 @@ -import { describe, test } from 'node:test'; +import { describe, test } from 'vitest'; import assert from 'node:assert/strict'; import {