diff --git a/package.json b/package.json index 30c3e20a..96d7d2ae 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,15 @@ "scripts": { "commit": "commit", "test": "jest", + "test:package-install": "node ./scripts/test-package-install.js", "typescript": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", "reset": "git clean -xdfe .vscode && yarn && yarn prepare", "markbuild": "tsx ./scripts/example-dot-env.ts", "prepare": "bob build && yarn markbuild", "postinstall": "husky", + "prepack": "pinst --disable", + "postpack": "pinst --enable", "example": "yarn --cwd example", "pods": "cd example && pod-install --quiet", "bootstrap": "yarn && yarn pods && yarn markbuild", @@ -93,6 +96,7 @@ "@types/react-test-renderer": "^19.1.0", "chalk": "^5.3.0", "commitlint": "^19.8.1", + "cross-spawn": "^7.0.6", "detox": "^20.45.1", "eslint": "^9.35.0", "eslint-config-prettier": "^10.1.8", @@ -103,6 +107,7 @@ "husky": "^9.1.7", "inquirer": "^9.2.7", "jest": "^29.7.0", + "pinst": "^3.0.0", "pod-install": "^1.0.7", "prettier": "^3.6.2", "react": "19.1.1", diff --git a/scripts/test-package-install.js b/scripts/test-package-install.js new file mode 100644 index 00000000..d41eea73 --- /dev/null +++ b/scripts/test-package-install.js @@ -0,0 +1,202 @@ +const assert = require('node:assert/strict'); +const crossSpawn = require('cross-spawn'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const packageRoot = path.resolve(__dirname, '..'); +const sourceManifestPath = path.join(packageRoot, 'package.json'); +const sourceManifestText = fs.readFileSync(sourceManifestPath, 'utf8'); +const sourceManifest = JSON.parse(sourceManifestText); +const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'amazon-ivs-package-install-') +); + +function run(command, args, options) { + return crossSpawn.sync(command, args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + ...options, + }); +} + +function commandOutput(result) { + return [ + `status: ${result.status}`, + `signal: ${result.signal}`, + `stdout:\n${result.stdout || ''}`, + `stderr:\n${result.stderr || ''}`, + ].join('\n'); +} + +function assertCommandStarted(result, description) { + assert.equal( + result.error == null, + true, + `${description} could not start: ${result.error}` + ); + assert.equal( + result.signal, + null, + `${description} was terminated by ${result.signal}\n${commandOutput( + result + )}` + ); +} + +try { + assert.equal( + sourceManifest.scripts.postinstall, + 'husky', + 'the source manifest must keep installing contributor Git hooks' + ); + assert.equal( + sourceManifest.scripts._postinstall, + undefined, + 'the source manifest started with a disabled postinstall script' + ); + + const tarballPath = path.join(tempRoot, 'amazon-ivs-react-native-player.tgz'); + const packResult = run('corepack', ['yarn', 'pack', '--out', tarballPath], { + cwd: packageRoot, + }); + + assertCommandStarted(packResult, 'yarn pack'); + assert.equal( + packResult.status, + 0, + `yarn pack failed\n${commandOutput(packResult)}` + ); + assert.ok( + fs.statSync(tarballPath).size > 0, + 'yarn pack created an empty archive' + ); + + const restoredManifest = JSON.parse( + fs.readFileSync(sourceManifestPath, 'utf8') + ); + assert.equal( + restoredManifest.scripts.postinstall, + sourceManifest.scripts.postinstall, + 'yarn pack did not restore the source postinstall script' + ); + assert.equal( + restoredManifest.scripts._postinstall, + undefined, + 'yarn pack left a disabled postinstall script in the source manifest' + ); + + const consumerRoot = path.join(tempRoot, 'consumer'); + const cacheRoot = path.join(tempRoot, 'npm-cache'); + const guardBin = path.join(tempRoot, 'guard-bin'); + const guardMarker = path.join(tempRoot, 'husky-called'); + const userConfig = path.join(tempRoot, 'npmrc'); + + fs.mkdirSync(consumerRoot); + fs.mkdirSync(cacheRoot); + fs.mkdirSync(guardBin); + fs.writeFileSync( + path.join(consumerRoot, 'package.json'), + `${JSON.stringify( + { + name: 'package-install-smoke-test', + version: '1.0.0', + private: true, + }, + null, + 2 + )}\n` + ); + fs.writeFileSync(userConfig, ''); + + const posixGuard = path.join(guardBin, 'husky'); + fs.writeFileSync( + posixGuard, + '#!/bin/sh\n: > "$HUSKY_GUARD_MARKER"\nexit 86\n' + ); + fs.chmodSync(posixGuard, 0o755); + fs.writeFileSync( + path.join(guardBin, 'husky.cmd'), + '@echo off\r\ntype nul > "%HUSKY_GUARD_MARKER%"\r\nexit /b 86\r\n' + ); + + const pathKey = + Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || + 'PATH'; + const installEnvironment = { + ...process.env, + HUSKY_GUARD_MARKER: guardMarker, + npm_config_ignore_scripts: 'false', + npm_config_update_notifier: 'false', + npm_config_userconfig: userConfig, + }; + installEnvironment[pathKey] = `${guardBin}${path.delimiter}${ + process.env[pathKey] || '' + }`; + + const installResult = run( + 'npm', + [ + 'install', + '--offline', + '--legacy-peer-deps', + '--omit=dev', + '--no-save', + '--no-package-lock', + '--no-audit', + '--no-fund', + '--foreground-scripts', + '--ignore-scripts=false', + '--cache', + cacheRoot, + tarballPath, + ], + { + cwd: consumerRoot, + env: installEnvironment, + } + ); + + assertCommandStarted(installResult, 'consumer npm install'); + assert.equal( + fs.existsSync(guardMarker), + false, + `the packed dependency invoked Husky during consumer installation\n${commandOutput( + installResult + )}` + ); + assert.equal( + installResult.status, + 0, + `consumer npm install failed\n${commandOutput(installResult)}` + ); + + const installedManifest = JSON.parse( + fs.readFileSync( + path.join( + consumerRoot, + 'node_modules', + sourceManifest.name, + 'package.json' + ), + 'utf8' + ) + ); + assert.equal(installedManifest.name, sourceManifest.name); + assert.equal(installedManifest.version, sourceManifest.version); + assert.equal( + installedManifest.scripts.postinstall, + undefined, + 'the packed dependency retained an active postinstall script' + ); +} finally { + if (fs.readFileSync(sourceManifestPath, 'utf8') !== sourceManifestText) { + fs.writeFileSync(sourceManifestPath, sourceManifestText); + } + fs.rmSync(tempRoot, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); +} diff --git a/yarn.lock b/yarn.lock index c0d56780..07873044 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3921,6 +3921,7 @@ __metadata: "@types/react-test-renderer": ^19.1.0 chalk: ^5.3.0 commitlint: ^19.8.1 + cross-spawn: ^7.0.6 detox: ^20.45.1 eslint: ^9.35.0 eslint-config-prettier: ^10.1.8 @@ -3931,6 +3932,7 @@ __metadata: husky: ^9.1.7 inquirer: ^9.2.7 jest: ^29.7.0 + pinst: ^3.0.0 pod-install: ^1.0.7 prettier: ^3.6.2 react: 19.1.1 @@ -9717,6 +9719,15 @@ __metadata: languageName: node linkType: hard +"pinst@npm:^3.0.0": + version: 3.0.0 + resolution: "pinst@npm:3.0.0" + bin: + pinst: bin.js + checksum: 4ae48a6a60f79c37071233af51b4d91bfc85cfa3c12b66ccda60cdb642b4d14a4ab0cb3587afc55b1f6192cea1772a5e4822026a0d0d3528296edef00cc2d61f + languageName: node + linkType: hard + "pirates@npm:^4.0.4": version: 4.0.7 resolution: "pirates@npm:4.0.7"