@vizzly-testing/cli 0.38.1 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -136,12 +136,20 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
136
136
  const tddService = new TddService(config, workingDir, setBaseline);
137
137
  const reportPath = join(workingDir, '.vizzly', 'report-data.json');
138
138
  const detailsPath = join(workingDir, '.vizzly', 'comparison-details.json');
139
+ let capture = {
140
+ accepted: 0,
141
+ rejected: 0
142
+ };
139
143
  let clearJsonFile = path => {
140
144
  if (existsSync(path)) {
141
145
  unlinkSync(path);
142
146
  }
143
147
  };
144
148
  let prepareRunArtifacts = () => {
149
+ capture = {
150
+ accepted: 0,
151
+ rejected: 0
152
+ };
145
153
  clearJsonFile(reportPath);
146
154
  clearJsonFile(detailsPath);
147
155
  tddService.clearRunData?.();
@@ -284,7 +292,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
284
292
  output.debug('tdd', `baseline: ${baseline.buildName}`);
285
293
  }
286
294
  };
287
- const handleScreenshot = async (_buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) => {
295
+ let processScreenshot = async (_buildId, name, image, properties = {}, type, warnings = [], screenshotOptions = {}) => {
288
296
  let handlerStart = Date.now();
289
297
  output.debug('tdd', `${name} received`);
290
298
 
@@ -411,6 +419,7 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
411
419
  aaPixelsIgnored: comparison.aaPixelsIgnored,
412
420
  aaPercentage: comparison.aaPercentage,
413
421
  heightDiff: comparison.heightDiff,
422
+ heightChange: comparison.heightChange,
414
423
  error: comparison.error,
415
424
  warnings,
416
425
  originalName: name,
@@ -498,8 +507,30 @@ export const createTddHandler = (config, workingDir, baselineBuild, baselineComp
498
507
  }
499
508
  };
500
509
  };
510
+ let handleScreenshot = async (...args) => {
511
+ let result;
512
+ try {
513
+ result = await processScreenshot(...args);
514
+ } catch (error) {
515
+ result = {
516
+ statusCode: error.code === 'INVALID_IMAGE' ? 400 : 500,
517
+ body: {
518
+ error: error.message,
519
+ tddMode: true
520
+ }
521
+ };
522
+ }
523
+ if (result.statusCode >= 400) capture.rejected++;else capture.accepted++;
524
+ return result;
525
+ };
501
526
  const getResults = async () => {
502
- return await tddService.printResults();
527
+ let results = await tddService.printResults();
528
+ return {
529
+ ...results,
530
+ capture: {
531
+ ...capture
532
+ }
533
+ };
503
534
  };
504
535
  const acceptBaseline = async comparisonId => {
505
536
  try {
@@ -26,8 +26,14 @@ import { TestRunner } from './test-runner.js';
26
26
  * @returns {Object} Services object for plugins
27
27
  */
28
28
  export function createServices(config) {
29
- let serverManager = createServerManager(config, {});
30
- let testRunner = new TestRunner(config, serverManager);
29
+ // Direct programmatic config is explicit; loadConfig already distinguishes
30
+ // explicit comparison settings from its local fallback defaults.
31
+ let serviceConfig = {
32
+ ...config,
33
+ _comparisonOverrides: config._comparisonOverrides ?? config.comparison ?? {}
34
+ };
35
+ let serverManager = createServerManager(serviceConfig, {});
36
+ let testRunner = new TestRunner(serviceConfig, serverManager);
31
37
  return {
32
38
  serverManager,
33
39
  testRunner
@@ -27,7 +27,8 @@ export async function compareImages(baselinePath, currentPath, diffPath, options
27
27
  let analysis = await analyze(baselinePath, currentPath, {
28
28
  threshold,
29
29
  antialiasing: true,
30
- minimumRegionPixels: minClusterSize
30
+ minimumRegionPixels: minClusterSize,
31
+ alignHeightChanges: true
31
32
  }, {
32
33
  diffPath,
33
34
  overwrite: true
@@ -45,6 +46,7 @@ export async function compareImages(baselinePath, currentPath, diffPath, options
45
46
  aaPixelsIgnored: comparison.pixels.suppressed.antialiasing.count,
46
47
  aaPercentage: totalPixels > 0 ? comparison.pixels.suppressed.antialiasing.count / totalPixels * 100 : 0,
47
48
  boundingBox: comparison.difference.spatial?.bounds || null,
49
+ heightChange: heightChange || null,
48
50
  heightDiff: heightChange ? heightChange.direction === 'added' ? heightChange.rowCount : -heightChange.rowCount : 0,
49
51
  intensityStats: comparison.difference.appearance,
50
52
  diffClusters: comparison.difference.regions.map(region => ({
@@ -181,6 +183,7 @@ export function buildFailedComparison(params) {
181
183
  aaPercentage: honeydiffResult.aaPercentage,
182
184
  boundingBox: honeydiffResult.boundingBox,
183
185
  heightDiff: honeydiffResult.heightDiff,
186
+ heightChange: honeydiffResult.heightChange,
184
187
  intensityStats: honeydiffResult.intensityStats,
185
188
  diffClusters
186
189
  };
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, writeFileSync as defaultWriteFileSync } from 'node:fs';
12
+ import { compareSync } from '@vizzly-testing/honeydiff';
12
13
  import { createApiClient as defaultCreateApiClient, getBuilds as defaultGetBuilds, getComparison as defaultGetComparison, getTddBaselines as defaultGetTddBaselines } from '../api/index.js';
13
- import { NetworkError } from '../errors/vizzly-error.js';
14
+ import { NetworkError, VizzlyError } from '../errors/vizzly-error.js';
14
15
  import { colors as defaultColors } from '../utils/colors.js';
15
16
  import { fetchWithTimeout as defaultFetchWithTimeout } from '../utils/fetch-utils.js';
16
17
  import { getDefaultBranch as defaultGetDefaultBranch } from '../utils/git.js';
@@ -809,6 +810,17 @@ export class TddService {
809
810
  let currentImagePath = getCurrentPath(this.currentPath, filename);
810
811
  let baselineImagePath = getBaselinePath(this.baselinePath, filename);
811
812
  let diffImagePath = getDiffPath(this.diffPath, filename);
813
+ let hasBaseline = baselineExists(this.baselinePath, filename);
814
+
815
+ // Existing baselines are decoded by normal comparison. New or explicitly
816
+ // replaced baselines need the same validation before persisting any bytes.
817
+ if (this.setBaseline || !hasBaseline) {
818
+ try {
819
+ compareSync(imageBuffer, imageBuffer);
820
+ } catch (error) {
821
+ throw new VizzlyError(`Invalid screenshot image: ${error.message}`, 'INVALID_IMAGE');
822
+ }
823
+ }
812
824
 
813
825
  // Save current screenshot
814
826
  saveCurrent(this.currentPath, filename, imageBuffer);
@@ -819,7 +831,7 @@ export class TddService {
819
831
  }
820
832
 
821
833
  // Check if baseline exists
822
- if (!baselineExists(this.baselinePath, filename)) {
834
+ if (!hasBaseline) {
823
835
  // Create new baseline
824
836
  saveBaseline(this.baselinePath, filename, imageBuffer);
825
837
 
@@ -119,11 +119,20 @@ export function determineBuildMode(tdd) {
119
119
  * Build result object for disabled run (no Vizzly integration)
120
120
  * @returns {Object} Result object
121
121
  */
122
- export function buildDisabledRunResult() {
122
+ export function buildDisabledRunResult(reason = null) {
123
123
  return {
124
124
  testsPassed: 1,
125
125
  testsFailed: 0,
126
- screenshotsCaptured: 0
126
+ screenshotsCaptured: 0,
127
+ ...(reason ? {
128
+ capture: {
129
+ setup: 'disabled',
130
+ reason,
131
+ accepted: 0,
132
+ rejected: 0,
133
+ errors: 0
134
+ }
135
+ } : {})
127
136
  };
128
137
  }
129
138
 
@@ -157,7 +166,23 @@ export function buildRunResult({
157
166
  uploads: uploadStats
158
167
  } : {}),
159
168
  comparisons: tddResults?.comparisons || null,
160
- failed: (tddResults?.failed || 0) > 0
169
+ failed: (tddResults?.failed || 0) > 0,
170
+ ...(tddResults ? {
171
+ summary: {
172
+ total: tddResults.total ?? screenshotCount,
173
+ passed: tddResults.passed || 0,
174
+ failed: tddResults.failed || 0,
175
+ new: tddResults.new || 0,
176
+ errors: tddResults.errors || 0
177
+ },
178
+ capture: {
179
+ setup: 'ready',
180
+ reason: tddResults.error || null,
181
+ accepted: tddResults.capture?.accepted ?? screenshotCount,
182
+ rejected: tddResults.capture?.rejected || 0,
183
+ errors: tddResults.errors || 0
184
+ }
185
+ } : {})
161
186
  };
162
187
  }
163
188
 
@@ -55,7 +55,7 @@ export async function createBuild({
55
55
  throw new Error('No API key available for build creation');
56
56
  }
57
57
  let client = createApiClient(clientOptions);
58
- let payload = buildApiBuildPayload(runOptions, config.comparison);
58
+ let payload = buildApiBuildPayload(runOptions, config._comparisonOverrides);
59
59
  let buildResult = await createApiBuild(client, payload);
60
60
  if (!buildResult?.id) {
61
61
  throw new Error('Vizzly API did not return a build ID');
@@ -242,22 +242,31 @@ export function executeTestCommand({
242
242
  async function executeDisabledTestRun({
243
243
  testCommand,
244
244
  json,
245
+ reason,
245
246
  deps
246
247
  }) {
247
248
  let {
248
249
  spawn,
249
250
  createError
250
251
  } = deps;
251
- await executeTestCommand({
252
- command: testCommand,
253
- env: buildDisabledEnv(),
254
- json,
255
- deps: {
256
- spawn,
257
- createError
258
- }
259
- });
260
- return buildDisabledRunResult();
252
+ let result = buildDisabledRunResult(reason);
253
+ try {
254
+ await executeTestCommand({
255
+ command: testCommand,
256
+ env: buildDisabledEnv(),
257
+ json,
258
+ deps: {
259
+ spawn,
260
+ createError
261
+ }
262
+ });
263
+ } catch (error) {
264
+ result.testsPassed = 0;
265
+ result.testsFailed = 1;
266
+ error.runResult = result;
267
+ throw error;
268
+ }
269
+ return result;
261
270
  }
262
271
 
263
272
  // ============================================================================
@@ -389,6 +398,7 @@ export async function runTests({
389
398
  return executeDisabledTestRun({
390
399
  testCommand,
391
400
  json: runOptions.json,
401
+ reason: error.message,
392
402
  deps: {
393
403
  spawn,
394
404
  createError
@@ -430,6 +440,15 @@ export async function runTests({
430
440
  output.debug('tdd', 'failed to get results', {
431
441
  error: tddError.message
432
442
  });
443
+ tddResults = {
444
+ total: 0,
445
+ errors: 1,
446
+ error: tddError.message,
447
+ capture: {
448
+ accepted: 0,
449
+ rejected: 0
450
+ }
451
+ };
433
452
  }
434
453
  }
435
454
 
@@ -474,23 +493,25 @@ export async function runTests({
474
493
  output.error('Failed to stop server:', stopError);
475
494
  }
476
495
  }
496
+ let result = buildRunResult({
497
+ buildId,
498
+ buildUrl,
499
+ testSuccess,
500
+ screenshotCount,
501
+ tddResults,
502
+ uploadStats
503
+ });
477
504
 
478
- // Throw test error after cleanup
505
+ // Throw test error after cleanup, retaining any completed visual evidence.
479
506
  if (testError) {
480
507
  if (!runOptions.json) {
481
508
  output.error('Test run failed:', testError);
482
509
  }
483
510
  if (uploadStats) testError.uploads = uploadStats;
511
+ if (tdd) testError.runResult = result;
484
512
  throw testError;
485
513
  }
486
- return buildRunResult({
487
- buildId,
488
- buildUrl,
489
- testSuccess,
490
- screenshotCount,
491
- tddResults,
492
- uploadStats
493
- });
514
+ return result;
494
515
  }
495
516
 
496
517
  /**
@@ -48,9 +48,16 @@ export interface TddConfig {
48
48
  openReport?: boolean;
49
49
  }
50
50
 
51
+ export interface ProjectConfig {
52
+ organizationSlug: string;
53
+ projectSlug: string;
54
+ }
55
+
51
56
  export interface VizzlyConfig {
52
57
  apiKey?: string;
53
58
  apiUrl?: string;
59
+ /** Nonsecret repository destination. Upload credentials stay in user storage. */
60
+ project?: ProjectConfig;
54
61
  server?: ServerConfig;
55
62
  build?: BuildConfig;
56
63
  upload?: UploadConfig;
@@ -131,6 +138,7 @@ export interface TddResults {
131
138
  failed: number;
132
139
  new: number;
133
140
  errors: number;
141
+ capture?: { accepted: number; rejected: number };
134
142
  comparisons: ComparisonResult[];
135
143
  baseline: BaselineData | null;
136
144
  }
@@ -206,6 +206,7 @@ export function fileToScreenshotFormat(file) {
206
206
  return {
207
207
  sha256: file.sha256,
208
208
  name: file.filename.replace(/\.png$/, ''),
209
+ filename: file.filename,
209
210
  properties: browser ? {
210
211
  browser
211
212
  } : {}
@@ -0,0 +1,73 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { cosmiconfigSync, defaultLoadersSync, getDefaultSearchPlacesSync } from 'cosmiconfig';
4
+ let searchPlaces = getDefaultSearchPlacesSync('vizzly');
5
+ export function createConfigExplorer() {
6
+ return cosmiconfigSync('vizzly', {
7
+ searchPlaces,
8
+ searchStrategy: 'none'
9
+ });
10
+ }
11
+ export async function readOptionalFile(filename) {
12
+ try {
13
+ return await readFile(filename, 'utf8');
14
+ } catch (error) {
15
+ if (error.code === 'ENOENT') return null;
16
+ throw error;
17
+ }
18
+ }
19
+ export function configFormat(filename) {
20
+ if (path.basename(filename) === 'package.json') return 'package';
21
+ let extension = path.extname(filename);
22
+ if (extension === '.json') return 'json';
23
+ if (extension === '.ts') return 'typescript';
24
+ if (extension === '.js') return 'javascript';
25
+ if (extension === '.cjs') return 'commonjs';
26
+ if (['.yaml', '.yml', ''].includes(extension)) return 'yaml';
27
+ return 'unknown';
28
+ }
29
+ async function inspectConfig(filename, allowInvalid) {
30
+ let source = await readOptionalFile(filename);
31
+ let result = {
32
+ configPath: filename,
33
+ exists: source !== null,
34
+ format: configFormat(filename),
35
+ source
36
+ };
37
+ if (source === null) return result;
38
+ try {
39
+ if (result.format === 'package') {
40
+ result.packageConfig = JSON.parse(source);
41
+ result.parsedConfig = result.packageConfig.vizzly;
42
+ } else if (result.format === 'json') {
43
+ result.parsedConfig = JSON.parse(source);
44
+ } else if (result.format === 'yaml') {
45
+ result.parsedConfig = defaultLoadersSync.noExt(filename, source);
46
+ }
47
+ } catch (error) {
48
+ // Replacing a standalone file does not need its old values. Package config
49
+ // still needs valid JSON so unrelated scripts and dependencies survive.
50
+ if (!allowInvalid || result.format === 'package') throw error;
51
+ result.parseError = error.message;
52
+ }
53
+ return result;
54
+ }
55
+
56
+ // Setup discovers source files without evaluating executable configuration or
57
+ // reading credentials. Runtime loading remains the explorer's responsibility.
58
+ export async function discoverConfig({
59
+ cwd = process.cwd(),
60
+ configPath = null,
61
+ allowInvalid = false
62
+ } = {}) {
63
+ if (configPath) return inspectConfig(path.resolve(cwd, configPath), allowInvalid);
64
+ for (let place of searchPlaces) {
65
+ let candidate = await inspectConfig(path.resolve(cwd, place), allowInvalid);
66
+ if (!candidate.exists || !candidate.source.trim()) continue;
67
+ if (['package', 'json', 'yaml'].includes(candidate.format) && candidate.parsedConfig == null && !candidate.parseError) {
68
+ continue;
69
+ }
70
+ return candidate;
71
+ }
72
+ return inspectConfig(path.resolve(cwd, 'vizzly.config.js'), allowInvalid);
73
+ }
@@ -1,14 +1,14 @@
1
1
  import { resolve } from 'node:path';
2
- import { cosmiconfigSync } from 'cosmiconfig';
3
2
  import { CONFIG_DEFAULTS, deepMerge } from '../config/core.js';
3
+ import { createConfigExplorer } from './config-discovery.js';
4
4
  import { validateVizzlyConfigWithDefaults } from './config-schema.js';
5
5
  import { getApiToken, getApiUrl, getBuildName, getMinClusterSize, getParallelId, getThreshold } from './environment-config.js';
6
6
  import { getAccessToken } from './global-config.js';
7
7
  import * as output from './output.js';
8
- import { getActiveProjectLink } from './project-link-store.js';
8
+ import { getProjectLink } from './project-link-store.js';
9
9
  export async function loadConfig(configPath = null, cliOverrides = {}) {
10
10
  // 1. Load from config file using cosmiconfig
11
- let explorer = cosmiconfigSync('vizzly');
11
+ let explorer = createConfigExplorer();
12
12
  let result = configPath ? explorer.load(configPath) : explorer.search();
13
13
  let fileConfig = {};
14
14
  if (result?.config) {
@@ -18,6 +18,12 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
18
18
 
19
19
  // 2. Validate config file using Zod schema
20
20
  let validatedFileConfig = validateVizzlyConfigWithDefaults(fileConfig);
21
+ let comparisonOverrides = {};
22
+ for (let key of ['threshold', 'minClusterSize']) {
23
+ if (fileConfig.comparison?.[key] !== undefined) {
24
+ comparisonOverrides[key] = validatedFileConfig.comparison[key];
25
+ }
26
+ }
21
27
 
22
28
  // Create a proper clone of the default config to avoid shared object references
23
29
  let config = deepMerge(CONFIG_DEFAULTS, {});
@@ -42,9 +48,13 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
42
48
  output.debug('config', 'using build name from environment');
43
49
  }
44
50
  if (envParallelId) config.parallelId = envParallelId;
45
- if (envThreshold !== undefined) config.comparison.threshold = envThreshold;
51
+ if (envThreshold !== undefined) {
52
+ config.comparison.threshold = envThreshold;
53
+ comparisonOverrides.threshold = envThreshold;
54
+ }
46
55
  if (envMinClusterSize !== undefined) {
47
56
  config.comparison.minClusterSize = envMinClusterSize;
57
+ comparisonOverrides.minClusterSize = envMinClusterSize;
48
58
  }
49
59
 
50
60
  // 4. Apply CLI overrides (highest priority)
@@ -52,11 +62,21 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
52
62
  output.debug('config', 'using token from --token flag');
53
63
  }
54
64
  applyCLIOverrides(config, cliOverrides);
65
+ for (let key of ['threshold', 'minClusterSize']) {
66
+ if (cliOverrides[key] !== undefined) {
67
+ comparisonOverrides[key] = config.comparison[key];
68
+ }
69
+ }
70
+ let validatedComparison = validateVizzlyConfigWithDefaults({
71
+ comparison: comparisonOverrides
72
+ }).comparison;
73
+ config._comparisonOverrides = Object.fromEntries(Object.keys(comparisonOverrides).map(key => [key, validatedComparison[key]]));
55
74
 
56
- // 5. Fall back to a linked project token for cloud upload commands.
57
- if (!config.apiKey) {
58
- let linkedProject = await getActiveProjectLink({
59
- apiUrl: config.apiUrl
75
+ // 5. Use only the credential belonging to this repository's destination.
76
+ if (!config.apiKey && config.project) {
77
+ let linkedProject = await getProjectLink({
78
+ apiUrl: config.apiUrl,
79
+ ...config.project
60
80
  });
61
81
  if (linkedProject?.token) {
62
82
  config.apiKey = linkedProject.token;
@@ -83,6 +103,7 @@ export async function loadConfig(configPath = null, cliOverrides = {}) {
83
103
  output.debug('config', 'using user login for user-authenticated commands');
84
104
  }
85
105
  config._configPath = result?.filepath || null;
106
+ config._configApiUrl = validatedFileConfig.apiUrl;
86
107
  return config;
87
108
  }
88
109
 
@@ -50,6 +50,10 @@ const comparisonSchema = z.object({
50
50
  const tddSchema = z.object({
51
51
  openReport: z.boolean().default(CONFIG_DEFAULTS.tdd.openReport)
52
52
  });
53
+ let projectSchema = z.object({
54
+ organizationSlug: z.string().trim().min(1),
55
+ projectSlug: z.string().trim().min(1)
56
+ });
53
57
 
54
58
  /**
55
59
  * Core Vizzly configuration schema
@@ -59,6 +63,7 @@ export const vizzlyConfigSchema = z.object({
59
63
  // Core Vizzly config
60
64
  apiKey: z.string().optional(),
61
65
  apiUrl: z.string().url().default(CONFIG_DEFAULTS.apiUrl),
66
+ project: projectSchema.optional(),
62
67
  server: serverSchema.default(CONFIG_DEFAULTS.server),
63
68
  build: buildSchema.default(CONFIG_DEFAULTS.build),
64
69
  upload: uploadSchema.default(CONFIG_DEFAULTS.upload),
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { access, lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { readOptionalFile } from './config-discovery.js';
6
+ export async function fileChange(filename, after) {
7
+ let before = await readOptionalFile(filename);
8
+ return before === after ? null : {
9
+ path: filename,
10
+ before,
11
+ after
12
+ };
13
+ }
14
+ async function writableParent(filename) {
15
+ let parent = path.dirname(filename);
16
+ while (true) {
17
+ try {
18
+ await access(parent, constants.W_OK);
19
+ return;
20
+ } catch (error) {
21
+ if (error.code !== 'ENOENT' || path.dirname(parent) === parent) {
22
+ throw error;
23
+ }
24
+ parent = path.dirname(parent);
25
+ }
26
+ }
27
+ }
28
+ async function checkChange(change) {
29
+ // Reject redirects in parent folders too, including explicit config paths.
30
+ // Top-level OS aliases such as /tmp on macOS are safe to follow.
31
+ for (let parent = path.dirname(change.path); path.dirname(parent) !== parent; parent = path.dirname(parent)) {
32
+ try {
33
+ if (path.dirname(parent) !== path.parse(parent).root && (await lstat(parent)).isSymbolicLink()) {
34
+ throw new Error(`Setup will not write through a symlink: ${parent}`);
35
+ }
36
+ } catch (error) {
37
+ if (error.code !== 'ENOENT') throw error;
38
+ }
39
+ }
40
+ let info;
41
+ try {
42
+ info = await lstat(change.path);
43
+ } catch (error) {
44
+ if (error.code !== 'ENOENT') throw error;
45
+ }
46
+ if (info?.isSymbolicLink()) {
47
+ throw new Error(`Setup will not replace a symlink: ${change.path}`);
48
+ }
49
+ let current = await readOptionalFile(change.path);
50
+ if (current !== change.before) {
51
+ throw new Error(`${change.path} changed during setup; run init again.`);
52
+ }
53
+ if (current !== null) {
54
+ if (!info.isFile()) {
55
+ throw new Error(`Setup needs a regular file: ${change.path}`);
56
+ }
57
+ await access(change.path, constants.W_OK);
58
+ }
59
+ await writableParent(change.path);
60
+ }
61
+ export async function preflightChanges(changes) {
62
+ for (let change of changes.filter(Boolean)) await checkChange(change);
63
+ }
64
+ export async function applyChanges(changes) {
65
+ let planned = changes.filter(Boolean);
66
+ await preflightChanges(planned);
67
+ let completed = [];
68
+ try {
69
+ for (let change of planned) {
70
+ await checkChange(change);
71
+ await mkdir(path.dirname(change.path), {
72
+ recursive: true
73
+ });
74
+ if (change.before === null) {
75
+ await writeFile(change.path, change.after, {
76
+ flag: 'wx'
77
+ });
78
+ } else {
79
+ let temporary = `${change.path}.${randomUUID()}.tmp`;
80
+ try {
81
+ let {
82
+ mode
83
+ } = await lstat(change.path);
84
+ await writeFile(temporary, change.after, {
85
+ flag: 'wx',
86
+ mode
87
+ });
88
+ await rename(temporary, change.path);
89
+ } finally {
90
+ await rm(temporary, {
91
+ force: true
92
+ });
93
+ }
94
+ }
95
+ completed.push({
96
+ path: change.path,
97
+ status: 'written'
98
+ });
99
+ }
100
+ } catch (error) {
101
+ error.completed = completed;
102
+ throw error;
103
+ }
104
+ return completed;
105
+ }