run-next-build.test.mjs
1 import assert from 'node:assert/strict' 2 import fs from 'node:fs' 3 import os from 'node:os' 4 import path from 'node:path' 5 import { describe, it } from 'node:test' 6 7 import { BUILD_BOOTSTRAP_ROOT_NAME } from './build-bootstrap-env.mjs' 8 import { 9 BUILD_MAX_OLD_SPACE_SIZE_ENV, 10 DEFAULT_MAX_OLD_SPACE_SIZE_MB, 11 NEXT_STANDALONE_METADATA_RELATIVE_DIR, 12 REQUIRED_NEXT_METADATA_FILES, 13 REQUIRED_STANDALONE_BROWSER_PACKAGES, 14 buildNextBuildEnv, 15 deriveMaxOldSpaceSizeMb, 16 hasTraceCopyWarning, 17 mergeNodeOptions, 18 repairStandaloneBrowserMcpRuntime, 19 readCgroupMemoryLimitBytes, 20 repairStandaloneCssTreeData, 21 repairStandaloneNextMetadata, 22 resolveNextBuildMaxOldSpaceSizeMb, 23 } from './run-next-build.mjs' 24 25 describe('run-next-build', () => { 26 it('adds a default heap limit when NODE_OPTIONS is empty', () => { 27 assert.equal( 28 mergeNodeOptions(''), 29 `--max-old-space-size=${DEFAULT_MAX_OLD_SPACE_SIZE_MB}`, 30 ) 31 }) 32 33 it('appends the default heap limit to unrelated NODE_OPTIONS flags', () => { 34 assert.equal( 35 mergeNodeOptions('--trace-warnings'), 36 `--trace-warnings --max-old-space-size=${DEFAULT_MAX_OLD_SPACE_SIZE_MB}`, 37 ) 38 }) 39 40 it('preserves an explicit heap limit', () => { 41 assert.equal( 42 mergeNodeOptions('--trace-warnings --max-old-space-size=4096'), 43 '--trace-warnings --max-old-space-size=4096', 44 ) 45 }) 46 47 it('derives a lower heap cap for constrained Docker-style memory limits', () => { 48 assert.equal( 49 deriveMaxOldSpaceSizeMb(4 * 1024 * 1024 * 1024), 50 '3072', 51 ) 52 assert.equal( 53 deriveMaxOldSpaceSizeMb(2 * 1024 * 1024 * 1024), 54 '1280', 55 ) 56 }) 57 58 it('reads cgroup memory limits and skips unbounded sentinels', () => { 59 const files = new Map([ 60 ['/sys/fs/cgroup/memory.max', `${4n * 1024n * 1024n * 1024n}`], 61 ]) 62 const existsSync = (filePath) => files.has(filePath) 63 const readFileSync = (filePath) => files.get(filePath) 64 65 assert.equal( 66 readCgroupMemoryLimitBytes(undefined, existsSync, readFileSync), 67 4 * 1024 * 1024 * 1024, 68 ) 69 70 files.set('/sys/fs/cgroup/memory.max', `${(1n << 60n) + 1n}`) 71 assert.equal( 72 readCgroupMemoryLimitBytes(undefined, existsSync, readFileSync), 73 null, 74 ) 75 }) 76 77 it('prefers an explicit build heap override', () => { 78 assert.equal( 79 resolveNextBuildMaxOldSpaceSizeMb({ [BUILD_MAX_OLD_SPACE_SIZE_ENV]: '2048' }), 80 '2048', 81 ) 82 }) 83 84 it('falls back to detected memory when no build heap override is set', () => { 85 assert.equal( 86 resolveNextBuildMaxOldSpaceSizeMb( 87 {}, 88 { 89 readCgroupMemoryLimitBytes: () => 4 * 1024 * 1024 * 1024, 90 totalMem: () => 16 * 1024 * 1024 * 1024, 91 }, 92 ), 93 '3072', 94 ) 95 }) 96 97 it('buildNextBuildEnv keeps other environment variables intact', () => { 98 const env = buildNextBuildEnv({ FOO: 'bar', NODE_OPTIONS: '' }) 99 assert.equal(env.FOO, 'bar') 100 assert.equal(env.NODE_OPTIONS, `--max-old-space-size=${DEFAULT_MAX_OLD_SPACE_SIZE_MB}`) 101 assert.equal(env.SWARMCLAW_BUILD_MODE, '1') 102 assert.equal(env.DATA_DIR?.endsWith(path.join(BUILD_BOOTSTRAP_ROOT_NAME, 'data')), true) 103 assert.equal(env.WORKSPACE_DIR?.endsWith(path.join(BUILD_BOOTSTRAP_ROOT_NAME, 'workspace')), true) 104 assert.equal( 105 env.BROWSER_PROFILES_DIR?.endsWith(path.join(BUILD_BOOTSTRAP_ROOT_NAME, 'browser-profiles')), 106 true, 107 ) 108 }) 109 110 it('buildNextBuildEnv preserves an explicit build mode', () => { 111 const env = buildNextBuildEnv({ SWARMCLAW_BUILD_MODE: 'custom', NODE_OPTIONS: '' }) 112 assert.equal(env.SWARMCLAW_BUILD_MODE, 'custom') 113 }) 114 115 it('detects standalone trace copy warnings in build output', () => { 116 assert.equal(hasTraceCopyWarning('all good'), false) 117 assert.equal( 118 hasTraceCopyWarning('Warning: Failed to copy traced files for /tmp/app.js'), 119 true, 120 ) 121 }) 122 123 it('repairStandaloneNextMetadata copies required Next metadata files into standalone output', () => { 124 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-next-build-')) 125 try { 126 const sourceDir = path.join(tempDir, 'node_modules', 'next', 'dist', 'lib', 'metadata') 127 fs.mkdirSync(path.join(tempDir, '.next', 'standalone'), { recursive: true }) 128 fs.mkdirSync(sourceDir, { recursive: true }) 129 for (const fileName of REQUIRED_NEXT_METADATA_FILES) { 130 fs.writeFileSync(path.join(sourceDir, fileName), `export const fileName = '${fileName}'\n`) 131 } 132 133 const repaired = repairStandaloneNextMetadata(tempDir) 134 assert.equal(repaired, true) 135 136 const targetDir = path.join(tempDir, '.next', 'standalone', NEXT_STANDALONE_METADATA_RELATIVE_DIR) 137 for (const fileName of REQUIRED_NEXT_METADATA_FILES) { 138 assert.equal(fs.existsSync(path.join(targetDir, fileName)), true) 139 } 140 } finally { 141 fs.rmSync(tempDir, { recursive: true, force: true }) 142 } 143 }) 144 145 it('repairStandaloneNextMetadata is a no-op when standalone metadata files already exist', () => { 146 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-next-build-')) 147 try { 148 const sourceDir = path.join(tempDir, 'node_modules', 'next', 'dist', 'lib', 'metadata') 149 const targetDir = path.join(tempDir, '.next', 'standalone', NEXT_STANDALONE_METADATA_RELATIVE_DIR) 150 fs.mkdirSync(sourceDir, { recursive: true }) 151 fs.mkdirSync(targetDir, { recursive: true }) 152 for (const fileName of REQUIRED_NEXT_METADATA_FILES) { 153 fs.writeFileSync(path.join(sourceDir, fileName), `source:${fileName}\n`) 154 fs.writeFileSync(path.join(targetDir, fileName), `target:${fileName}\n`) 155 } 156 157 const repaired = repairStandaloneNextMetadata(tempDir) 158 assert.equal(repaired, false) 159 160 for (const fileName of REQUIRED_NEXT_METADATA_FILES) { 161 assert.equal(fs.readFileSync(path.join(targetDir, fileName), 'utf8'), `target:${fileName}\n`) 162 } 163 } finally { 164 fs.rmSync(tempDir, { recursive: true, force: true }) 165 } 166 }) 167 168 it('repairStandaloneNextMetadata fails fast when the installed Next package is missing required files', () => { 169 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-next-build-')) 170 try { 171 fs.mkdirSync(path.join(tempDir, '.next', 'standalone'), { recursive: true }) 172 const sourceDir = path.join(tempDir, 'node_modules', 'next', 'dist', 'lib', 'metadata') 173 fs.mkdirSync(sourceDir, { recursive: true }) 174 fs.writeFileSync(path.join(sourceDir, REQUIRED_NEXT_METADATA_FILES[0]), 'export {}\n') 175 176 assert.throws( 177 () => repairStandaloneNextMetadata(tempDir), 178 /Missing required Next metadata runtime files/, 179 ) 180 } finally { 181 fs.rmSync(tempDir, { recursive: true, force: true }) 182 } 183 }) 184 185 it('repairStandaloneCssTreeData copies mdn-data JSON files into standalone output', () => { 186 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-css-tree-')) 187 try { 188 fs.mkdirSync(path.join(tempDir, '.next', 'standalone'), { recursive: true }) 189 const cssTreeSrc = path.join(tempDir, 'node_modules', 'css-tree', 'data') 190 const mdnDataSrc = path.join(tempDir, 'node_modules', 'mdn-data', 'css') 191 fs.mkdirSync(cssTreeSrc, { recursive: true }) 192 fs.mkdirSync(mdnDataSrc, { recursive: true }) 193 fs.writeFileSync(path.join(cssTreeSrc, 'patch.json'), '{}') 194 fs.writeFileSync(path.join(mdnDataSrc, 'at-rules.json'), '{"@media":{}}') 195 196 const repaired = repairStandaloneCssTreeData(tempDir) 197 assert.equal(repaired, true) 198 199 const standaloneNm = path.join(tempDir, '.next', 'standalone', 'node_modules') 200 assert.equal(fs.existsSync(path.join(standaloneNm, 'css-tree', 'data', 'patch.json')), true) 201 assert.equal(fs.existsSync(path.join(standaloneNm, 'mdn-data', 'css', 'at-rules.json')), true) 202 } finally { 203 fs.rmSync(tempDir, { recursive: true, force: true }) 204 } 205 }) 206 207 it('repairStandaloneBrowserMcpRuntime copies Playwright MCP runtime packages into standalone output', () => { 208 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-browser-mcp-')) 209 try { 210 fs.mkdirSync(path.join(tempDir, '.next', 'standalone'), { recursive: true }) 211 for (const packageName of REQUIRED_STANDALONE_BROWSER_PACKAGES) { 212 const packageDir = path.join(tempDir, 'node_modules', ...packageName.split('/')) 213 fs.mkdirSync(packageDir, { recursive: true }) 214 fs.writeFileSync(path.join(packageDir, 'package.json'), `{"name":${JSON.stringify(packageName)}}`) 215 } 216 fs.writeFileSync( 217 path.join(tempDir, 'node_modules', '@playwright', 'mcp', 'cli.js'), 218 '#!/usr/bin/env node\n', 219 ) 220 221 const repaired = repairStandaloneBrowserMcpRuntime(tempDir) 222 assert.equal(repaired, true) 223 224 for (const packageName of REQUIRED_STANDALONE_BROWSER_PACKAGES) { 225 const targetPackageJson = path.join( 226 tempDir, 227 '.next', 228 'standalone', 229 'node_modules', 230 ...packageName.split('/'), 231 'package.json', 232 ) 233 assert.equal(fs.existsSync(targetPackageJson), true) 234 } 235 assert.equal( 236 fs.existsSync(path.join(tempDir, '.next', 'standalone', 'node_modules', '@playwright', 'mcp', 'cli.js')), 237 true, 238 ) 239 } finally { 240 fs.rmSync(tempDir, { recursive: true, force: true }) 241 } 242 }) 243 244 it('repairStandaloneBrowserMcpRuntime fills partially traced browser MCP package directories', () => { 245 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-browser-mcp-partial-')) 246 try { 247 fs.mkdirSync(path.join(tempDir, '.next', 'standalone'), { recursive: true }) 248 for (const packageName of REQUIRED_STANDALONE_BROWSER_PACKAGES) { 249 const packageDir = path.join(tempDir, 'node_modules', ...packageName.split('/')) 250 fs.mkdirSync(packageDir, { recursive: true }) 251 fs.writeFileSync(path.join(packageDir, 'package.json'), `{"name":${JSON.stringify(packageName)}}`) 252 } 253 fs.writeFileSync( 254 path.join(tempDir, 'node_modules', '@playwright', 'mcp', 'cli.js'), 255 '#!/usr/bin/env node\n', 256 ) 257 fs.mkdirSync(path.join(tempDir, '.next', 'standalone', 'node_modules', '@playwright', 'mcp'), { recursive: true }) 258 259 const repaired = repairStandaloneBrowserMcpRuntime(tempDir) 260 assert.equal(repaired, true) 261 assert.equal( 262 fs.existsSync(path.join(tempDir, '.next', 'standalone', 'node_modules', '@playwright', 'mcp', 'cli.js')), 263 true, 264 ) 265 assert.equal( 266 fs.existsSync(path.join(tempDir, '.next', 'standalone', 'node_modules', '@playwright', 'mcp', 'package.json')), 267 true, 268 ) 269 } finally { 270 fs.rmSync(tempDir, { recursive: true, force: true }) 271 } 272 }) 273 })