diff --git a/scripts/deploy/publish-npm.spec.ts b/scripts/deploy/publish-npm.spec.ts new file mode 100644 index 0000000000..bcab79a19f --- /dev/null +++ b/scripts/deploy/publish-npm.spec.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { before, beforeEach, describe, it, mock } from 'node:test' +import { mockCommandImplementation, mockModule } from './lib/testHelpers.ts' + +interface CommandChain { + withEnvironment: (env: Record) => CommandChain + withLogs: () => CommandChain + run: () => string | undefined +} + +describe('publish-npm', () => { + const commandMock = mock.fn<(template: TemplateStringsArray, ...values: any[]) => CommandChain>() + const getNpmTokenMock = mock.fn<() => string>() + + let main: (args?: string[]) => void + let publishError: Error + + before(async () => { + await mockModule(path.resolve(import.meta.dirname, '../lib/command.ts'), { command: commandMock }) + await mockModule(path.resolve(import.meta.dirname, '../lib/secrets.ts'), { getNpmToken: getNpmTokenMock }) + + const publishNpmModule: { main: (args?: string[]) => void } = await import('./publish-npm.ts') + ;({ main } = publishNpmModule) + }) + + beforeEach(() => { + publishError = new Error('publish failed') + getNpmTokenMock.mock.mockImplementation(() => 'fake-token') + + const baseCommandMock = mock.fn<(template: TemplateStringsArray, ...values: any[]) => CommandChain>() + mockCommandImplementation(baseCommandMock) + + commandMock.mock.mockImplementation((template: TemplateStringsArray, ...values: any[]): CommandChain => { + const chain = baseCommandMock(template, ...values) + + if (isNpmPublishCommand(template, ...values)) { + const throwingChain: CommandChain = { + withEnvironment: () => throwingChain, + withLogs: () => throwingChain, + run: () => { + throw publishError + }, + } + + return { + ...chain, + withEnvironment: () => throwingChain, + withLogs: () => throwingChain, + run: throwingChain.run, + } + } + return chain + }) + }) + + it('should suggest renewing npm token when npm publish fails', () => { + assert.throws( + () => main([]), + (error: Error) => { + assert.match(error.message, /scripts\/release\/renew-token\.ts/) + assert.equal(error.cause, publishError) + return true + } + ) + }) + + it('should not mask npm token lookup failures as publish failures', () => { + const tokenError = new Error('failed to fetch npm token') + getNpmTokenMock.mock.mockImplementation(() => { + throw tokenError + }) + + assert.throws( + () => main([]), + (error: Error) => { + assert.equal(error, tokenError) + assert.doesNotMatch(error.message, /scripts\/release\/renew-token\.ts/) + return true + } + ) + }) +}) + +function isNpmPublishCommand(template: TemplateStringsArray, ...values: any[]): boolean { + const command = template + .reduce((acc, part, index) => `${acc}${part}${values[index] || ''}`, '') + .replace(/\s+/g, ' ') + .trim() + return command.includes('npm publish') +} diff --git a/scripts/deploy/publish-npm.ts b/scripts/deploy/publish-npm.ts index 676ae827c4..5d63bb1c2f 100644 --- a/scripts/deploy/publish-npm.ts +++ b/scripts/deploy/publish-npm.ts @@ -3,10 +3,15 @@ import { printLog, runMain } from '../lib/executionUtils.ts' import { command } from '../lib/command.ts' import { getNpmToken } from '../lib/secrets.ts' -runMain(() => { +if (!process.env.NODE_TEST_CONTEXT) { + runMain(() => main()) +} + +export function main(args = process.argv.slice(2)): void { const { values: { 'dry-run': dryRun }, } = parseArgs({ + args, options: { 'dry-run': { type: 'boolean', default: false }, }, @@ -22,11 +27,19 @@ runMain(() => { command`yarn build`.withEnvironment({ BUILD_MODE: 'release' }).run() printLog(dryRun ? 'Publishing (dry run)' : 'Publishing') - command`yarn workspaces foreach --verbose --all --topological --no-private npm publish --tolerate-republish --access public ${dryRun ? ['--dry-run'] : []}` - .withEnvironment({ - YARN_NPM_AUTH_TOKEN: dryRun ? '' : getNpmToken(), - BUILD_MODE: 'release', + const npmToken = dryRun ? '' : getNpmToken() + + try { + command`yarn workspaces foreach --verbose --all --topological --no-private npm publish --tolerate-republish --access public ${dryRun ? ['--dry-run'] : []}` + .withEnvironment({ + YARN_NPM_AUTH_TOKEN: npmToken, + BUILD_MODE: 'release', + }) + .withLogs() + .run() + } catch (error) { + throw new Error('NPM publish failed. Run `node ./scripts/release/renew-token.ts` and retry the job.', { + cause: error as Error, }) - .withLogs() - .run() -}) + } +}