/ scripts / run-next-build.test.mjs
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  })