diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 8134fe9..eb89c92 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -6,6 +6,12 @@ name: Node.js CI on: push: pull_request: + types: [opened] + +# Define permissions needed for the GITHUB_TOKEN +permissions: + contents: read + actions: read jobs: build: @@ -27,3 +33,40 @@ jobs: name: subspace_storage path: ./dist if-no-files-found: error + + test: + needs: build + runs-on: ubuntu-latest + strategy: + matrix: + factorio-version: ['0.17.79', '1.0.0', '1.1.110', '2.0.39'] + fail-fast: false + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 8 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + + # Install xz-utils for tar.xz extraction + - name: Install xz-utils + run: sudo apt-get update && sudo apt-get install -y xz-utils + + # Download the built artifacts + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: subspace_storage + path: ./dist + + - name: Install dependencies + run: pnpm i --no-frozen-lockfile + + - name: Run integration test + env: + FACTORIO_VERSION: ${{ matrix.factorio-version }} + GITHUB_TOKEN: ${{ secrets.GH_PAT }} + run: npm test diff --git a/.gitignore b/.gitignore index 0f6e932..d9ab34c 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,7 @@ node_modules/ package-lock.json # Blender save versions -*.blend[1-9] \ No newline at end of file +*.blend[1-9] + +# Factorio test files +test/factorio* diff --git a/README.md b/README.md index dabfb45..f4dab5d 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,32 @@ Build Instructions Install Node.js version 10 or later and run `npm install` then run `node build`. It will output the built mod into the `dist` folder by default. See `node build --help` for options. + +Testing +------------------ + +To run the integration test: + +```bash +# Basic test using a dummy clusterio_lib mod +npm test + +# Test with the latest clusterio_lib from GitHub Actions +GITHUB_TOKEN=your_github_token npm test +``` + +The integration test will: +1. Download and set up a Factorio headless server +2. Build and install the Subspace Storage mod +3. When provided with a GitHub token, download the latest clusterio_lib from GitHub Actions +4. Run Factorio to verify that the mod loads correctly + +Note: To download the clusterio_lib mod from GitHub Actions, you need a GitHub personal access token with the `public_repo` scope. If not provided, the test will fall back to using a dummy clusterio_lib mod. + +### CI Configuration + +For CI environments, you should store the GitHub token as a secret: + +1. Go to your repository Settings → Secrets and variables → Actions +2. Add a new repository secret named `GH_PAT` with your GitHub personal access token +3. The CI workflow is already configured to use this secret when running tests diff --git a/package.json b/package.json index 0f7b4ed..0494a3a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "repository": "https://github.com/clusterio/subspace_storage", "license": "MIT", "scripts": { - "build": "node build.js" + "build": "node build.js", + "test": "node test/integration.test.js && node test/lua_commands.test.js" }, "engines": { "node": ">=10" @@ -13,6 +14,11 @@ "jszip": "^3.10.1", "klaw": "^4.1.0", "sharp": "^0.33.5", + "unzipper": "^0.10.14", "yargs": "^17.7.2" + }, + "devDependencies": { + "rcon-client": "^4.2.5", + "tar": "^6.2.0" } } diff --git a/src/control.lua b/src/control.lua index 863127b..2bf1313 100644 --- a/src/control.lua +++ b/src/control.lua @@ -5,7 +5,7 @@ local mod_gui = require("mod-gui") local clusterio_api = require("__clusterio_lib__/api") local lib_compat = require("__clusterio_lib__/compat") -local compat = require("compat") +local compat = require("lib_compat") -- Entities which are not allowed to be placed outside the restriction zone @@ -204,6 +204,9 @@ script.on_event(defines.events.on_robot_built_entity, function(event) OnBuiltEntity(event) end) +script.on_event(defines.events.script_raised_built, function(event) + OnBuiltEntity(event) +end) ---------------------------- --[[Thing killing events]]-- diff --git a/src/info.json b/src/info.json index 00307c1..2a47de1 100644 --- a/src/info.json +++ b/src/info.json @@ -4,22 +4,22 @@ { "version": "2.1.17", "factorio_version": "0.17", - "additional_files": { "compat.lua": ["compat", "factorio_0.17.lua"] } + "additional_files": { "lib_compat.lua": ["compat", "factorio_0.17.lua"] } }, { "version": "2.1.10", "factorio_version": "1.0", - "additional_files": { "compat.lua": ["compat", "factorio_1.0.lua"] } + "additional_files": { "lib_compat.lua": ["compat", "factorio_1.0.lua"] } }, { "version": "2.1.11", "factorio_version": "1.1", - "additional_files": { "compat.lua": ["compat", "factorio_1.1.lua"] } + "additional_files": { "lib_compat.lua": ["compat", "factorio_1.1.lua"] } }, { "version": "2.1.20", "factorio_version": "2.0", - "additional_files": { "compat.lua": ["compat", "factorio_2.0.lua"] } + "additional_files": { "lib_compat.lua": ["compat", "factorio_2.0.lua"] } } ], "title": "Subspace Storage (Alpha)", diff --git a/src/prototypes/entities.lua b/src/prototypes/entities.lua index 33d09d3..85220e2 100644 --- a/src/prototypes/entities.lua +++ b/src/prototypes/entities.lua @@ -1,4 +1,4 @@ -local compat = require("compat") +local compat = require("lib_compat") local icons = require("entity_icons") local pictures = require("entity_pictures") diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..9c7bfab --- /dev/null +++ b/test/README.md @@ -0,0 +1,62 @@ +# Subspace Storage Integration Tests + +This directory contains integration tests for the Subspace Storage mod. These tests verify that the mod loads correctly and that its Lua commands interact with the game as expected. + +## Test Files + +- `integration.test.js`: Basic test that verifies the mod loads correctly +- `lua_commands_test.js`: Tests that verify the Lua commands interact with the game correctly + +## Running the Tests + +### Environment Variables + +The tests use the following environment variables: + +- `FACTORIO_VERSION`: The version of Factorio to test with (optional, defaults to 1.1.110) +- `MOD_VERSION`: The version of the mod to test with (optional) +- `GITHUB_TOKEN`: The GitHub token to use for downloading clusterio_lib from GitHub Actions (required for CI) + +### Running the Basic Integration Test + +```bash +node integration.test.js +``` + +This test verifies that the mod loads correctly without any errors. + +### Running the Lua Commands Test + +```bash +node lua_commands_test.js +``` + +This test verifies that the Lua commands interact with the game correctly. + +## How the Tests Work + +The tests work by: + +1. Downloading a headless version of Factorio +2. Setting up a mod directory with the Subspace Storage mod and its dependencies +3. Creating a test save file +4. Running Factorio with Lua scripts that test specific functionality +5. Analyzing the output to determine if the tests passed or failed + +## Adding New Tests + +To add a new test: + +1. Create a new Lua script in the `createTestScripts` function in `lua_commands_test.js` +2. The script should use `game.print("SUCCESS: ...")` to indicate success and `game.print("ERROR: ...")` to indicate failure +3. The test runner will count the number of successes and errors to determine if the test passed + +## Troubleshooting + +If the tests fail, check the output for error messages. Common issues include: + +- Missing dependencies +- Incompatible Factorio version +- Errors in the Lua scripts + +If you're having trouble with the tests, try running Factorio manually with the mod installed to see if there are any issues. diff --git a/test/downloadFactorio.js b/test/downloadFactorio.js new file mode 100644 index 0000000..fa349ae --- /dev/null +++ b/test/downloadFactorio.js @@ -0,0 +1,80 @@ +"use strict"; +const { spawn } = require('child_process'); +const { createWriteStream } = require('fs'); +const fs = require('fs-extra'); +const https = require('https'); +const path = require('path'); + +// Factorio version to test with +const factorioVersion = process.env.FACTORIO_VERSION || '1.1.110'; + +const downloadFactorio = async () => { + const baseDir = path.join(__dirname, `factorio_${factorioVersion}`); + const tarballPath = path.join(__dirname, `factorio_${factorioVersion}.tar.xz`); + const downloadUrl = `https://factorio.com/get-download/${factorioVersion}/headless/linux64`; + + console.log(`Downloading Factorio ${factorioVersion} from ${downloadUrl}`); + + // The factorio executable will be in baseDir/factorio/bin/... + const factorioDir = path.join(baseDir, 'factorio'); + + if (await fs.pathExists(factorioDir)) { + console.log('Factorio directory already exists, skipping download'); + return factorioDir; + } + + await fs.ensureDir(baseDir); + + // Download the tarball + await new Promise((resolve, reject) => { + const file = createWriteStream(tarballPath); + https.get(downloadUrl, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Follow redirect + https.get(response.headers.location, (redirectResponse) => { + redirectResponse.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + }).on('error', reject); + } else { + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + } + }).on('error', reject); + }); + + console.log('Download complete, extracting...'); + + // Extract to the base directory - the archive already contains a 'factorio' folder + await new Promise((resolve, reject) => { + const extract = spawn('tar', ['xf', tarballPath, '-C', baseDir]); + + extract.stdout?.on('data', (data) => { + console.log(`stdout: ${data}`); + }); + + extract.stderr?.on('data', (data) => { + console.error(`stderr: ${data}`); + }); + + extract.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`tar extraction failed with code ${code}`)); + } + }); + }); + + // Clean up the tarball + await fs.unlink(tarballPath); + + console.log('Extraction complete'); + return factorioDir; +}; +exports.downloadFactorio = downloadFactorio; diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 0000000..1cca9e6 --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require('fs-extra'); +const path = require('path'); +const { spawn } = require('child_process'); +const { downloadFactorio } = require('./downloadFactorio'); +const { setupModDirectory } = require('./setupModDirectory'); + +/* +Required environment variables: +- FACTORIO_VERSION: The version of Factorio to test with (optional, defaults to 1.1.110) +- MOD_VERSION: The version of the mod to test with (optional) +- GITHUB_TOKEN: The GitHub token to use for downloading clusterio_lib from GitHub Actions +*/ + +const runFactorio = async (factorioDir) => { + console.log('Starting Factorio to test mod loading...'); + + // Get the actual executable path - factorioDir is already the 'factorio' subdirectory + const factorioBin = path.join(factorioDir, 'bin', 'x64', 'factorio'); + console.log(`Factorio executable path: ${factorioBin}`); + + // Mods: + console.log("Mods:", await fs.readdir(path.join(factorioDir, 'mods'))); + + return new Promise((resolve, reject) => { + const factorio = spawn(factorioBin, [ + '--create', './test-map', + '--mod-directory', path.join(factorioDir, 'mods'), + '--benchmark', '1', // Run for a short time then exit + '--benchmark-ticks', '1', + '--no-log-rotation' + ]); + + let output = ''; + let errorOutput = ''; + + factorio.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + process.stdout.write(text); + }); + + factorio.stderr.on('data', (data) => { + const text = data.toString(); + errorOutput += text; + process.stderr.write(text); + }); + + factorio.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Factorio exited with code ${code}: ${errorOutput}`)); + return; + } + + // Check for critical errors in the output + if (output.includes('Error') || output.includes('EXCEPTION')) { + if ( + output.includes('Error while loading mods') || + output.includes('subspace_storage') + ) { + reject(new Error(`Mod loading failed: ${output}`)); + return; + } + } + + // Check that the mod was loaded correctly + if (!output.includes('Loading mod subspace_storage')) { + reject(new Error('Could not find "Loading mod subspace_storage" in the output. The mod may not have been loaded correctly.')); + return; + } + + resolve(); + }); + }); +}; + +const main = async () => { + try { + const factorioDir = await downloadFactorio(); + await setupModDirectory(factorioDir); + await runFactorio(factorioDir); + console.log('Integration test passed successfully!'); + process.exit(0); + } catch (error) { + console.error('Integration test failed:', error); + process.exit(1); + } +}; + +main(); diff --git a/test/lua_commands.test.js b/test/lua_commands.test.js new file mode 100644 index 0000000..e5a0bc2 --- /dev/null +++ b/test/lua_commands.test.js @@ -0,0 +1,306 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require('fs-extra'); +const path = require('path'); +const { spawn } = require('child_process'); +const net = require('net'); +const crypto = require('crypto'); +const Rcon = require('rcon-client').Rcon; +const { downloadFactorio } = require('./downloadFactorio'); +const { setupModDirectory } = require('./setupModDirectory'); + +/* +Required environment variables: +- FACTORIO_VERSION: The version of Factorio to test with (optional, defaults to 1.1.110) +- MOD_VERSION: The version of the mod to test with (optional) +- GITHUB_TOKEN: The GitHub token to use for downloading clusterio_lib from GitHub Actions +*/ + +const FACTORIO_VERSION = process.env.FACTORIO_VERSION || "1.1.110"; + +// Create a temporary directory for test scripts +const createTestScripts = async (tempDir) => { + const tests = { + test_inventory_combinator: [` + local surface = game.get_surface(1) + local position = {x = 0, y = 0} + local combinator = surface.create_entity{ + name = "subspace-resource-combinator", + position = position, + force = game.forces.player, + raise_built = true + } + + if not combinator then + rcon.print("ERROR: Failed to create inventory combinator") + return + else + global._test_combinator = combinator + end`, + `--[[ Set a simulated inventory ]] + local itemsJson = '[["iron-plate",100,"normal"],["copper-plate",50,"normal"]]' + UpdateInvData(itemsJson, true) + `, + `--[[ Verify the combinator state ]] + local combinator = global._test_combinator + local control = combinator.get_control_behavior() + + local success = true + local function verify_signal(index, item_name, expected_count) + if ${FACTORIO_VERSION.split(".")[0]} < 2 then + local param = control.get_signal(index) + if not param or param.signal.name ~= item_name or param.count ~= expected_count then + rcon.print(string.format( + "ERROR: Signal mismatch at index %d - Expected %s: %d, Got: %s: %d", + index, + item_name, + expected_count, + param and param.signal.name or "nil", + param and param.count or 0 + )) + success = false + end + else + local section = control.get_section(1) + if not section or section.filters[index].value.name ~= item_name or section.filters[index].min ~= expected_count then + rcon.print(string.format( + "ERROR: Signal mismatch at index %d - Expected %s: %d, Got: %s: %d", + index, + item_name, + expected_count, + section and section.filters[index].value.name or "nil", + section and section.filters[index].min or 0 + )) + success = false + end + end + end + + verify_signal(1, "iron-plate", 100) + verify_signal(2, "copper-plate", 50) + + if success then + rcon.print("SUCCESS: Inventory combinator test passed") + end + `], + }; + + return tests; +}; + +// Function to create a save file +const createSaveFile = async (factorioDir) => { + console.log('Creating initial save file...'); + + const factorioBin = path.join(factorioDir, 'bin', 'x64', 'factorio'); + const savePath = path.join(factorioDir, 'test-save.zip'); + + return new Promise((resolve, reject) => { + const createSave = spawn(factorioBin, [ + '--create', savePath, + '--mod-directory', path.join(factorioDir, 'mods'), + '--map-gen-seed', '12345' // Fixed seed for deterministic tests + ]); + + createSave.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Failed to create save file, exit code: ${code}`)); + return; + } + + console.log('Save file created successfully'); + resolve(savePath); + }); + }); +}; + +// Function to start Factorio server with RCON enabled +const startFactorioServer = async (factorioDir, savePath) => { + console.log('Starting Factorio server with RCON...'); + + const factorioBin = path.join(factorioDir, 'bin', 'x64', 'factorio'); + const rconPassword = crypto.randomBytes(16).toString('hex'); + const rconPort = 27015; + + const factorio = spawn(factorioBin, [ + '--start-server', savePath, + '--mod-directory', path.join(factorioDir, 'mods'), + '--rcon-port', rconPort.toString(), + '--rcon-password', rconPassword + ]); + + let output = ''; + let serverStarted = false; + + factorio.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + process.stdout.write(text); + + if (text.includes('Starting RCON interface')) { + serverStarted = true; + } + }); + + factorio.stderr.on('data', (data) => { + const text = data.toString(); + process.stderr.write(text); + }); + + // Wait for server to start + return new Promise((resolve, reject) => { + const checkStarted = () => { + if (serverStarted) { + resolve({ + process: factorio, + rconPort, + rconPassword, + output: () => output + }); + } else if (factorio.exitCode !== null) { + reject(new Error(`Factorio server exited with code ${factorio.exitCode}`)); + } else { + setTimeout(checkStarted, 100); + } + }; + + checkStarted(); + }); +}; + +// Function to run tests using RCON +const runTestsWithRcon = async (server, tests) => { + console.log('Connecting to RCON...'); + + const rcon = await Rcon.connect({ + host: 'localhost', + port: server.rconPort, + password: server.rconPassword, + timeout: 5000 + }); + + try { + console.log('RCON authenticated, running tests...'); + + const results = { + success: 0, + error: 0, + details: [] + }; + + // Run 2 commands to prime rcon + await rcon.send('/c game.print("Hello, world!")'); + await rcon.send('/c game.print("Hello, world!")'); + + // Run each test + for (const [testName, testScripts] of Object.entries(tests)) { + console.log(`Running test: ${testName}`); + + let combinedResponse = ''; + let hasError = false; + let hasExecutionError = false; + + // Run each script in sequence with delay + for (const script of testScripts) { + // Send the Lua command via RCON + const response = await rcon.send(`/c __subspace_storage__ ${script.replace(/\n/g, ' ')}`); + combinedResponse += response + '\n'; + + // Check for execution errors after each script + const serverOutput = server.output(); + if (serverOutput.includes('Cannot execute command')) { + hasExecutionError = true; + break; + } + + // Wait ~1 second between scripts + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + // Parse final results + const successCount = (combinedResponse.match(/SUCCESS:/g) || []).length; + const errorCount = hasExecutionError ? 1 : (combinedResponse.match(/ERROR:/g) || []).length; + + console.log(`Test results for ${testName}:`); + console.log(`- Successes: ${successCount}`); + console.log(`- Errors: ${errorCount}`); + + results.success += successCount; + results.error += errorCount; + + results.details.push({ + name: testName, + success: successCount, + error: errorCount, + output: combinedResponse + }); + + if (errorCount > 0 || hasExecutionError) { + console.error(`❌ Test failed: ${testName}`); + console.error(combinedResponse); + if (hasExecutionError) { + console.error('Server reported command execution error'); + console.error(server.output()); + } + } else { + console.log(`✅ Test passed: ${testName}`); + } + } + + return results; + } finally { + await rcon.end(); + } +}; + +const main = async () => { + try { + const factorioDir = await downloadFactorio(); + await setupModDirectory(factorioDir); + + // Always create a fresh save file + const savePath = path.join(factorioDir, 'test-save.zip'); + await createSaveFile(factorioDir); + + // Create test scripts + const tests = await createTestScripts(factorioDir); + + // Start Factorio server with RCON + const server = await startFactorioServer(factorioDir, savePath); + + try { + // Run tests + const results = await runTestsWithRcon(server, tests); + + // Output summary + console.log('\nTest Summary:'); + console.log(`- Total tests: ${Object.keys(tests).length}`); + console.log(`- Total successes: ${results.success}`); + console.log(`- Total errors: ${results.error}`); + + if (results.error > 0) { + console.error('\nSome tests failed. Check the output above for details.'); + process.exit(1); + } else { + console.log('\nAll Lua command tests passed successfully!'); + process.exit(0); + } + } finally { + // Kill the Factorio server + server.process.kill(); + // Clean up the save file + try { + await fs.remove(savePath); + console.log('Test save file cleaned up'); + } catch (err) { + console.warn('Failed to clean up test save file:', err); + } + } + } catch (error) { + console.error('Integration test failed:', error); + process.exit(1); + } +}; + +main(); diff --git a/test/setupModDirectory.js b/test/setupModDirectory.js new file mode 100644 index 0000000..082b5b5 --- /dev/null +++ b/test/setupModDirectory.js @@ -0,0 +1,278 @@ +"use strict"; +const fs = require('fs-extra'); +const path = require('path'); +const { createWriteStream } = require('fs'); +const https = require('https'); +const { pipeline } = require('stream/promises'); +const { Extract } = require('unzipper'); + +// Factorio version to test with +const factorioVersion = process.env.FACTORIO_VERSION || '1.1.110'; +const modVersion = process.env.MOD_VERSION; +// GitHub token for API access (can be passed as environment variable) +const githubToken = process.env.GITHUB_TOKEN; + +const setupModDirectory = async (factorioDir) => { + const modDir = path.join(factorioDir, 'mods'); + await fs.ensureDir(modDir); + + // Find the mod file + const modFile = await findModFile(); + console.log(`Using mod file: ${modFile}`); + + // Copy the mod file to the mods directory + await fs.copy(modFile, path.join(modDir, path.basename(modFile))); + + // Download and install the real clusterio_lib from GitHub Actions + if (!githubToken) { + console.warn('GITHUB_TOKEN not provided, unable to download clusterio_lib from GitHub Actions'); + throw new Error('GITHUB_TOKEN not provided'); + } + + await downloadClusterioLib(modDir); + + console.log(`Mods directory set up at: ${modDir}`); + return modDir; +}; +exports.setupModDirectory = setupModDirectory;const downloadClusterioLib = async (modDir) => { + console.log('Downloading clusterio_lib from GitHub Actions...'); + + if (!githubToken) { + throw new Error('GITHUB_TOKEN environment variable is required to download artifacts from GitHub Actions'); + } + + // First, get the latest workflow run ID + const getLatestRunId = async () => { + return new Promise((resolve, reject) => { + const options = { + hostname: 'api.github.com', + path: '/repos/clusterio/clusterio/actions/runs?status=success&per_page=1', + method: 'GET', + headers: { + 'User-Agent': 'subspace-storage-integration-test', + 'Authorization': `token ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json' + } + }; + + const req = https.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to get latest workflow run: ${res.statusCode}, ${data}`)); + return; + } + + try { + const response = JSON.parse(data); + if (!response.workflow_runs || response.workflow_runs.length === 0) { + reject(new Error('No workflow runs found')); + return; + } + + resolve(response.workflow_runs[0].id); + } catch (error) { + reject(error); + } + }); + }); + + req.on('error', reject); + req.end(); + }); + }; + + // Get the artifact ID for clusterio_lib + const getArtifactId = async (runId) => { + return new Promise((resolve, reject) => { + const options = { + hostname: 'api.github.com', + path: `/repos/clusterio/clusterio/actions/runs/${runId}/artifacts`, + method: 'GET', + headers: { + 'User-Agent': 'subspace-storage-integration-test', + 'Authorization': `token ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json' + } + }; + + const req = https.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to get artifacts: ${res.statusCode}, ${data}`)); + return; + } + + try { + const response = JSON.parse(data); + const artifact = response.artifacts.find(a => a.name === 'clusterio_lib'); + if (!artifact) { + reject(new Error('No clusterio_lib artifact found')); + return; + } + + resolve(artifact.id); + } catch (error) { + reject(error); + } + }); + }); + + req.on('error', reject); + req.end(); + }); + }; + + // Download the artifact + const downloadArtifact = async (artifactId) => { + const artifactZipPath = path.join(__dirname, 'clusterio_lib_artifact.zip'); + + return new Promise((resolve, reject) => { + const options = { + hostname: 'api.github.com', + path: `/repos/clusterio/clusterio/actions/artifacts/${artifactId}/zip`, + method: 'GET', + headers: { + 'User-Agent': 'subspace-storage-integration-test', + 'Authorization': `token ${githubToken}`, + 'Accept': 'application/vnd.github.v3+json' + } + }; + + const req = https.request(options, (res) => { + if (res.statusCode === 302) { + // Follow redirect + const file = createWriteStream(artifactZipPath); + https.get(res.headers.location, (redirectRes) => { + redirectRes.pipe(file); + file.on('finish', () => { + file.close(); + resolve(artifactZipPath); + }); + }).on('error', (err) => { + fs.unlink(artifactZipPath, () => { }); + reject(err); + }); + } else { + reject(new Error(`Expected redirect, got ${res.statusCode}`)); + } + }); + + req.on('error', reject); + req.end(); + }); + }; + + try { + // Step 1: Get the latest workflow run ID + const runId = await getLatestRunId(); + console.log(`Found latest successful workflow run: ${runId}`); + + // Step 2: Get the artifact ID + const artifactId = await getArtifactId(runId); + console.log(`Found clusterio_lib artifact: ${artifactId}`); + + // Step 3: Download the artifact + const artifactZipPath = await downloadArtifact(artifactId); + console.log(`Downloaded artifact to: ${artifactZipPath}`); + + // Step 4: Extract the artifact + const extractDir = path.join(__dirname, 'clusterio_lib_extract'); + await fs.ensureDir(extractDir); + + await pipeline( + fs.createReadStream(artifactZipPath), + Extract({ path: extractDir }) + ); + + // Add delay to allow extraction to properly complete before reading the directory + await new Promise(r => setTimeout(r, 500)); + + // Step 5: Extract all files starting with clusterio_lib_ (older versions just won't be loaded by factorio) + const files = await fs.readdir(extractDir); + const modFiles = files.filter(file => file.startsWith('clusterio_lib_') && file.endsWith('.zip')); + console.log("Mods in artifact:", modFiles); + const validModFiles = modFiles.filter(file => checkModVersionAgainstFactorioVersion( + file + .replace('clusterio_lib_', '') + .replace('.zip', ''), + factorioVersion + )); + + if (validModFiles.length === 0) { + throw new Error('Could not find clusterio_lib mod file in downloaded artifact'); + } + + // Copy the mod file to the mods directory + for (const modFile of validModFiles) { + await fs.copy( + path.join(extractDir, modFile), + path.join(modDir, modFile) + ); + } + + // Clean up + await fs.remove(artifactZipPath); + await fs.remove(extractDir); + + console.log(`Successfully installed clusterio_lib mod:`, validModFiles); + } catch (error) { + throw new Error(`Failed to download clusterio_lib: ${error.message}`); + } +}; +const findModFile = async () => { + const distDir = path.join(__dirname, '..', 'dist'); + const files = await fs.readdir(distDir); + + let modFile; + if (modVersion) { + // Look for a specific version if specified + modFile = files.find(file => file === `subspace_storage_${modVersion}.zip`); + } else { + // Get info.json to find all versions + const infoJson = JSON.parse(await fs.readFile(path.join(__dirname, '..', 'src', 'info.json'))); + + // Find the variant that corresponds to our factorio version + const variant = infoJson.variants.find(v => v.factorio_version === factorioVersion.split('.').slice(0, 2).join('.')); + + if (!variant) { + throw new Error(`No mod variant found for Factorio ${factorioVersion}`); + } + + modFile = files.find(file => file === `subspace_storage_${variant.version}.zip`); + } + + if (!modFile) { + throw new Error('Could not find mod file in dist directory'); + } + + return path.join(distDir, modFile); +}; +const checkModVersionAgainstFactorioVersion = (modVersion, factorioVersion) => { + const modVersionParts = modVersion.split('.'); + const factorioVersionParts = factorioVersion.split('.'); + + switch (modVersionParts[2]) { + case '20': + return factorioVersionParts[0] === '2'; + case '11': + return factorioVersionParts[0] === '1' && factorioVersionParts[1] === '1'; + case ('10', '18'): + return factorioVersionParts[0] === '1' && factorioVersionParts[1] === '0'; + case '17': + return factorioVersionParts[0] === '0' && factorioVersionParts[1] === '17'; + default: + return false; + } +};