diff --git a/lib/assets.js b/lib/assets.js index d0d50c5b..feb9ab99 100644 --- a/lib/assets.js +++ b/lib/assets.js @@ -27,7 +27,7 @@ const waitForUnpack = async fileUrl => { } while (fileExists && counter < 90); }; -const deployAssets = async gateway => { +const deployAssets = async (gateway, { releaseId } = {}) => { logger.Debug('Generating and uploading new assets manifest...'); const assetsArchiveName = './tmp/assets.zip'; const instance = await gateway.getInstance(); @@ -45,7 +45,7 @@ const deployAssets = async gateway => { const manifest = await manifestGenerate(); logger.Debug(manifest); files.writeJSON('tmp/assets_manifest.json', manifest); - const response = await gateway.sendManifest(manifest); + const response = await gateway.sendManifest(manifest, releaseId); logger.Debug('Uploading assets'); return response; } catch (e) { diff --git a/lib/deploy/defaultStrategy.js b/lib/deploy/defaultStrategy.js index 8b9c525c..b08cd057 100644 --- a/lib/deploy/defaultStrategy.js +++ b/lib/deploy/defaultStrategy.js @@ -1,6 +1,6 @@ import ora from 'ora'; import { makeArchive } from '../archive.js'; -import { push } from '../push.js'; +import { push, printDeployReport } from '../push.js'; import logger from '../logger.js'; import report from '../logger/report.js'; import ServerError from '../ServerError.js'; @@ -27,9 +27,12 @@ const strategy = async ({ env, _authData, _params }) => { const spinner = ora({ text: `Deploying to: ${url}`, stream: process.stdout }); spinner.start(); - const duration = await uploadArchive(env, { spinner }); + const result = await uploadArchive(env, { spinner }); - spinner.succeed(`Deploy succeeded after ${duration}`); + spinner.stop(); + const verbose = env.VERBOSE === true || env.VERBOSE === 'true'; + printDeployReport(result.report, { verbose }); + logger.Success(`Deploy succeeded after ${result.duration}`, { hideTimestamp: true }); report('[OK] Deploy: Default Strategy'); } catch (e) { if (ServerError.isNetworkError(e)) { diff --git a/lib/deploy/directAssetsUploadStrategy.js b/lib/deploy/directAssetsUploadStrategy.js index 55bc5fe2..31f3c5ba 100644 --- a/lib/deploy/directAssetsUploadStrategy.js +++ b/lib/deploy/directAssetsUploadStrategy.js @@ -5,7 +5,7 @@ import { makeArchive } from '../archive.js'; import { deployAssets } from '../assets.js'; import duration from '../duration.js'; import files from '../files.js'; -import { push } from '../push.js'; +import { push, printDeployReport } from '../push.js'; import logger from '../logger.js'; import report from '../logger/report.js'; import ServerError from '../ServerError.js'; @@ -13,14 +13,39 @@ import ServerError from '../ServerError.js'; const createArchive = (env) => makeArchive(env, { withoutAssets: true }); const uploadArchive = (env, { spinner } = {}) => push(env, { spinner }); -const deployAndUploadAssets = async (authData) => { +const deployAndUploadAssets = async (authData, { releaseId } = {}) => { const assetsToDeploy = await files.getAssets(); if (assetsToDeploy.length === 0) { logger.Warn('There are no assets to deploy, skipping.'); return; } - await deployAssets(new Gateway(authData)); - logger.Success('Assets deploy has been successfully scheduled.', { hideTimestamp: true }); + await deployAssets(new Gateway(authData), { releaseId }); +}; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const COMPLETED_STATUSES = new Set(['success', 'done', 'error']); + +const waitForAssetReport = async (gateway, releaseId) => { + if (!gateway || !releaseId) return null; + + const maxAttempts = 600; // up to 10 minutes (600 × 1s) + try { + for (let i = 0; i < maxAttempts; i++) { + const response = await gateway.getStatus(releaseId); + if (response && response.asset_report) return response.asset_report; + // Server finished but doesn't support asset_report yet — stop polling + if (response && COMPLETED_STATUSES.has(response.status)) { + logger.Debug('Deploy completed without asset_report field, skipping.'); + return null; + } + await sleep(1000); + } + logger.Debug('Asset report not available after timeout.'); + } catch (e) { + logger.Debug(`Could not fetch asset report: ${e.message}`); + } + return null; }; const strategy = async ({ env, authData, _params }) => { @@ -33,15 +58,34 @@ const strategy = async ({ env, authData, _params }) => { spinner.start(); const t0 = performance.now(); + let releaseId, gateway, deployReport; if (numberOfFiles > 0) { - await uploadArchive(env, { spinner }); + const result = await uploadArchive(env, { spinner }); + releaseId = result.releaseId; + gateway = result.gateway; + deployReport = result.report; } else { logger.Warn('There are no files in release file, skipping.'); } + const archiveDuration = duration(t0, performance.now()); + + const t1 = performance.now(); + await deployAndUploadAssets(authData, { releaseId }); + + spinner.text = 'Waiting for asset processing…'; + const assetReport = await waitForAssetReport(gateway, releaseId); + const assetDuration = duration(t1, performance.now()); + + // Merge asset report into deploy report for unified display + const mergedReport = Object.assign({}, deployReport || {}); + if (assetReport) mergedReport.Asset = assetReport; - await deployAndUploadAssets(authData); + const verbose = env.VERBOSE === true || env.VERBOSE === 'true'; + const totalDuration = duration(t0, performance.now()); - spinner.succeed(`Deploy succeeded after ${duration(t0, performance.now())}`); + spinner.stop(); + printDeployReport(mergedReport, { verbose }); + logger.Info(`Deploy succeeded after ${totalDuration} (archive: ${archiveDuration}, assets: ${assetDuration})`, { hideTimestamp: true }); } catch (e) { if (ServerError.isNetworkError(e)) { await logger.Error('Deploy failed.', { exit: false }); diff --git a/lib/deploy/dryRunStrategy.js b/lib/deploy/dryRunStrategy.js index 45170b11..75b99915 100644 --- a/lib/deploy/dryRunStrategy.js +++ b/lib/deploy/dryRunStrategy.js @@ -1,8 +1,11 @@ import { performance } from 'perf_hooks'; import ora from 'ora'; +import Gateway from '../proxy.js'; import { makeArchive } from '../archive.js'; -import { push } from '../push.js'; +import { push, printDeployReport } from '../push.js'; +import { manifestGenerate } from '../assets/manifest.js'; import duration from '../duration.js'; +import files from '../files.js'; import logger from '../logger.js'; import report from '../logger/report.js'; import ServerError from '../ServerError.js'; @@ -10,7 +13,26 @@ import ServerError from '../ServerError.js'; const createArchive = (env) => makeArchive(env, { withoutAssets: true }); const uploadArchive = (env, { spinner } = {}) => push(env, { spinner }); -const strategy = async ({ env, _authData, _params }) => { +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const waitForAssetReport = async (gateway, releaseId) => { + if (!gateway || !releaseId) return null; + + const maxAttempts = 600; + try { + for (let i = 0; i < maxAttempts; i++) { + const response = await gateway.getStatus(releaseId); + if (response && response.asset_report) return response.asset_report; + await sleep(1000); + } + logger.Debug('Asset report not available after timeout.'); + } catch (e) { + logger.Debug(`Could not fetch asset report: ${e.message}`); + } + return null; +}; + +const strategy = async ({ env, authData, _params }) => { env.DRY_RUN = 'true'; try { @@ -23,13 +45,38 @@ const strategy = async ({ env, _authData, _params }) => { const spinner = ora({ text: `[DRY RUN] Deploying to: ${url}`, stream: process.stdout }); spinner.start(); + let releaseId, gateway, deployReport; if (numberOfFiles > 0) { - await uploadArchive(env, { spinner }); + const result = await uploadArchive(env, { spinner }); + releaseId = result.releaseId; + gateway = result.gateway; + deployReport = result.report; } else { logger.Warn('There are no files in release file, skipping.'); } - spinner.succeed(`Dry run completed after ${duration(t0, performance.now())} - no changes were applied`); + // Generate and send asset manifest (no S3 upload) — the worker reads + // dry_run from the MarketplaceRelease record via marketplace_release_id + const assetsToDeploy = await files.getAssets(); + if (assetsToDeploy.length > 0) { + spinner.text = '[DRY RUN] Validating assets…'; + const manifestGateway = gateway || new Gateway(authData); + const manifest = await manifestGenerate(); + await manifestGateway.sendManifest(manifest, releaseId); + + spinner.text = '[DRY RUN] Waiting for asset report…'; + const assetReport = await waitForAssetReport(manifestGateway, releaseId); + if (assetReport) { + const mergedReport = Object.assign({}, deployReport || {}); + mergedReport.Asset = assetReport; + deployReport = mergedReport; + } + } + + spinner.stop(); + const verbose = env.VERBOSE === true || env.VERBOSE === 'true'; + printDeployReport(deployReport, { verbose }); + logger.Success(`Dry run completed after ${duration(t0, performance.now())} - no changes were applied`, { hideTimestamp: true }); } catch (e) { if (ServerError.isNetworkError(e)) { await logger.Error('Dry run failed.', { exit: false }); diff --git a/lib/proxy.js b/lib/proxy.js index 245c458a..1f5002d8 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -150,8 +150,10 @@ class Gateway { return apiRequest({ method: 'POST', uri: `${this.api_url}/migrations/run`, formData, headers: this.defaultHeaders }); } - sendManifest(manifest) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/assets_manifest`, json: { manifest }, headers: this.defaultHeaders }); + sendManifest(manifest, releaseId) { + const json = { manifest }; + if (releaseId) json.marketplace_release_id = releaseId; + return apiRequest({ method: 'POST', uri: `${this.api_url}/assets_manifest`, json, headers: this.defaultHeaders }); } sync(formData) { diff --git a/lib/push.js b/lib/push.js index a221dd47..810fb22f 100644 --- a/lib/push.js +++ b/lib/push.js @@ -81,6 +81,9 @@ const getDeploymentStatus = ({ id }, { spinner } = {}) => { if (body.details.file_path) { message += `\n${body.details.file_path}`; } + if (body.warnings && body.warnings.length > 0) { + logger.Warn(body.warnings.join('\n')); + } return logger.Error(message, { exit: true }); } else { resolve(response); @@ -123,12 +126,11 @@ const push = async (env, { spinner } = {}) => { .then(res => getDeploymentStatus(res, { spinner })) .then((response) => { logger.Debug('Release deployed'); - printDeployReport(response.report, { verbose: env.VERBOSE === true || env.VERBOSE === 'true' }); if (response.warning) { logger.Warn(response.warning); } const t1 = performance.now(); - return duration(t0, t1); + return { duration: duration(t0, t1), releaseId: response.id, gateway, report: response.report }; }); }; diff --git a/test/unit/deploy.test.js b/test/unit/deploy.test.js index 5d501fa1..2e5ee64f 100644 --- a/test/unit/deploy.test.js +++ b/test/unit/deploy.test.js @@ -317,10 +317,13 @@ describe('Deploy - Unit Tests', () => { MARKETPLACE_EMAIL: TEST_EMAIL }; - const duration = await push(env); + const result = await push(env); - expect(typeof duration).toBe('string'); - expect(duration).toMatch(/\d+:\d{2}/); // e.g., "0:00" (MM:SS format) + expect(typeof result).toBe('object'); + expect(typeof result.duration).toBe('string'); + expect(result.duration).toMatch(/\d+:\d{2}/); // e.g., "0:00" (MM:SS format) + expect(result.releaseId).toBeDefined(); + expect(result.gateway).toBeDefined(); } finally { process.chdir(originalCwd); } @@ -391,16 +394,32 @@ describe.skip('Dry Run', () => { expect(formDataArg['marketplace_builder[dry_run]']).toBeUndefined(); }); - test('dryRunStrategy never uploads assets', async () => { + test('dryRunStrategy never uploads assets to S3 but sends manifest', async () => { const archiveModule = await import('#lib/archive.js'); vi.spyOn(archiveModule, 'makeArchive').mockResolvedValue(5); + const mockGateway = { + getStatus: vi.fn().mockResolvedValue({ asset_report: { upserted: 2, deleted: 0, skipped: 0 } }), + sendManifest: vi.fn().mockResolvedValue({}) + }; + const pushModule = await import('#lib/push.js'); - vi.spyOn(pushModule, 'push').mockResolvedValue('0:05'); + vi.spyOn(pushModule, 'push').mockResolvedValue({ + duration: '0:05', releaseId: 12345, gateway: mockGateway, report: {} + }); const assetsModule = await import('#lib/assets.js'); const deployAssetsSpy = vi.spyOn(assetsModule, 'deployAssets').mockResolvedValue(); + const filesModule = await import('#lib/files.js'); + vi.spyOn(filesModule.default, 'getAssets').mockResolvedValue(['app/assets/app.css', 'app/assets/app.js']); + + const manifestModule = await import('#lib/assets/manifest.js'); + const manifestSpy = vi.spyOn(manifestModule, 'manifestGenerate').mockResolvedValue({ + 'app.css': { physical_file_path: 'assets/app.css', updated_at: 123 }, + 'app.js': { physical_file_path: 'assets/app.js', updated_at: 456 } + }); + const strategy = (await import('#lib/deploy/dryRunStrategy.js')).default; await strategy({ env: { @@ -414,7 +433,97 @@ describe.skip('Dry Run', () => { params: {} }); + // Should NOT upload assets to S3 expect(deployAssetsSpy).not.toHaveBeenCalled(); + + // Should generate and send manifest for dry-run validation + expect(manifestSpy).toHaveBeenCalled(); + expect(mockGateway.sendManifest).toHaveBeenCalledWith( + expect.objectContaining({ 'app.css': expect.any(Object) }), + 12345 + ); + }); + + test('dryRunStrategy skips manifest when no assets exist', async () => { + const archiveModule = await import('#lib/archive.js'); + vi.spyOn(archiveModule, 'makeArchive').mockResolvedValue(5); + + const pushModule = await import('#lib/push.js'); + vi.spyOn(pushModule, 'push').mockResolvedValue({ + duration: '0:05', releaseId: 12345, gateway: { getStatus: vi.fn() }, report: {} + }); + + const filesModule = await import('#lib/files.js'); + vi.spyOn(filesModule.default, 'getAssets').mockResolvedValue([]); + + const manifestModule = await import('#lib/assets/manifest.js'); + const manifestSpy = vi.spyOn(manifestModule, 'manifestGenerate').mockResolvedValue({}); + + const strategy = (await import('#lib/deploy/dryRunStrategy.js')).default; + await strategy({ + env: { + MARKETPLACE_URL: TEST_URL, + MARKETPLACE_TOKEN: TEST_TOKEN, + MARKETPLACE_EMAIL: TEST_EMAIL, + DRY_RUN: 'true', + PARTIAL_DEPLOY: 'false' + }, + authData: { url: TEST_URL, token: TEST_TOKEN, email: TEST_EMAIL }, + params: {} + }); + + // Should NOT generate manifest when there are no assets + expect(manifestSpy).not.toHaveBeenCalled(); + }); + + test('dryRunStrategy merges asset report into deploy report', async () => { + const archiveModule = await import('#lib/archive.js'); + vi.spyOn(archiveModule, 'makeArchive').mockResolvedValue(5); + + const deployReport = { + Page: { upserted: ['pages/index.liquid'], deleted: [], skipped: [] } + }; + const mockGateway = { + getStatus: vi.fn().mockResolvedValue({ asset_report: { upserted: 1, deleted: 0, skipped: 0 } }), + sendManifest: vi.fn().mockResolvedValue({}) + }; + const pushModule = await import('#lib/push.js'); + vi.spyOn(pushModule, 'push').mockResolvedValue({ + duration: '0:05', releaseId: 12345, + gateway: mockGateway, + report: deployReport + }); + const printSpy = vi.spyOn(pushModule, 'printDeployReport'); + + const filesModule = await import('#lib/files.js'); + vi.spyOn(filesModule.default, 'getAssets').mockResolvedValue(['app/assets/app.css']); + + const manifestModule = await import('#lib/assets/manifest.js'); + vi.spyOn(manifestModule, 'manifestGenerate').mockResolvedValue({ + 'app.css': { physical_file_path: 'assets/app.css', updated_at: 123 } + }); + + const strategy = (await import('#lib/deploy/dryRunStrategy.js')).default; + await strategy({ + env: { + MARKETPLACE_URL: TEST_URL, + MARKETPLACE_TOKEN: TEST_TOKEN, + MARKETPLACE_EMAIL: TEST_EMAIL, + DRY_RUN: 'true', + PARTIAL_DEPLOY: 'false' + }, + authData: { url: TEST_URL, token: TEST_TOKEN, email: TEST_EMAIL }, + params: {} + }); + + // printDeployReport should receive merged report with both Page and Asset + expect(printSpy).toHaveBeenCalledWith( + expect.objectContaining({ + Page: expect.any(Object), + Asset: { upserted: 1, deleted: 0, skipped: 0 } + }), + expect.any(Object) + ); }); test('push() sends both dry_run and partial_deploy when both flags are set', async () => {