From 77d9a3c0e8ab437324988c499e3cc2c2a3b382c7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 11 May 2026 18:13:35 +0800 Subject: [PATCH] feat(sourcemaps): add upload-miniprogram command New `flashcat-cli sourcemaps upload-miniprogram` for WeChat miniprogram sourcemap.zip uploads. - Multipart payload: event (application/json, no filename) carrying type=miniprogram_sourcemap + service/version/appid/cli_version, plus sourcemap_archive (the zip file). - Header DD-EVP-ORIGIN=flashcat-cli_miniprogram routes the upload to fc-rum's MiniProgramProcessor. - Options: --service, --release-version, --sourcemap-zip, --appid, --dry-run, --quiet. appid is optional (omitted from event when not set). - Tests cover happy path, omitted-appid, and missing-zip error paths. --- .../sourcemaps/__tests__/miniprogram.test.ts | 162 ++++++++++++++++ src/commands/sourcemaps/cli.ts | 3 +- src/commands/sourcemaps/miniprogram.ts | 182 ++++++++++++++++++ 3 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 src/commands/sourcemaps/__tests__/miniprogram.test.ts create mode 100644 src/commands/sourcemaps/miniprogram.ts diff --git a/src/commands/sourcemaps/__tests__/miniprogram.test.ts b/src/commands/sourcemaps/__tests__/miniprogram.test.ts new file mode 100644 index 0000000..47d5f7b --- /dev/null +++ b/src/commands/sourcemaps/__tests__/miniprogram.test.ts @@ -0,0 +1,162 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +import {Cli} from 'clipanion/lib/advanced' + +import {UploadStatus, upload} from '../../../helpers/upload' +import {version} from '../../../helpers/version' +import {UploadMiniprogramCommand} from '../miniprogram' + +jest.mock('../../../helpers/upload', () => { + const actual = jest.requireActual('../../../helpers/upload') + + return { + ...actual, + upload: jest.fn(), + } +}) + +describe('UploadMiniprogramCommand', () => { + const mockedUpload = upload as jest.MockedFunction + + beforeEach(() => { + mockedUpload.mockReset() + process.env = {FLASHCAT_API_KEY: 'PLACEHOLDER'} + }) + + test('uploads multipart event metadata and sourcemap archive', async () => { + const zipPath = createZipFixture() + const uploadImplementation = jest.fn().mockResolvedValue(UploadStatus.Success) + mockedUpload.mockReturnValue(uploadImplementation) + + const {code} = await runCLI([ + 'sourcemaps', + 'upload-miniprogram', + '--service', + 'my-mp', + '--release-version', + '1.2.3', + '--sourcemap-zip', + zipPath, + '--appid', + 'wxbad3e0a65782821c', + ]) + + expect(code).toBe(0) + expect(uploadImplementation).toHaveBeenCalledTimes(1) + const [payload, options] = uploadImplementation.mock.calls[0] + const event = payload.content.get('event') + const archive = payload.content.get('sourcemap_archive') + + if (event?.type !== 'string') { + throw new Error('event should be a string multipart value') + } + expect(event.options).toEqual({contentType: 'application/json'}) + expect(JSON.parse(event.value)).toEqual({ + type: 'miniprogram_sourcemap', + service: 'my-mp', + version: '1.2.3', + appid: 'wxbad3e0a65782821c', + cli_version: version, + }) + expect(archive).toEqual({ + type: 'file', + path: path.resolve(zipPath), + options: {filename: 'sourcemap.zip'}, + }) + expect(options).toMatchObject({ + retries: 5, + useGzip: true, + }) + }) + + test('omits appid from event metadata when not provided', async () => { + const zipPath = createZipFixture() + const uploadImplementation = jest.fn().mockResolvedValue(UploadStatus.Success) + mockedUpload.mockReturnValue(uploadImplementation) + + const {code} = await runCLI([ + 'sourcemaps', + 'upload-miniprogram', + '--service', + 'my-mp', + '--release-version', + '1.2.3', + '--sourcemap-zip', + zipPath, + ]) + + expect(code).toBe(0) + const [payload] = uploadImplementation.mock.calls[0] + const event = payload.content.get('event') + + expect(event?.type).toBe('string') + if (event?.type !== 'string') { + throw new Error('event should be a string multipart value') + } + expect(JSON.parse(event.value)).toEqual({ + type: 'miniprogram_sourcemap', + service: 'my-mp', + version: '1.2.3', + cli_version: version, + }) + expect(JSON.parse(event.value)).not.toHaveProperty('appid') + }) + + test("returns non-zero when zip path doesn't exist", async () => { + const missingZipPath = path.join(os.tmpdir(), `missing-sourcemap-${Date.now()}.zip`) + + const {code, context} = await runCLI([ + 'sourcemaps', + 'upload-miniprogram', + '--service', + 'my-mp', + '--release-version', + '1.2.3', + '--sourcemap-zip', + missingZipPath, + ]) + + expect(code).toBe(1) + expect(context.stderr.toString()).toContain(`File not found: ${path.resolve(missingZipPath)}`) + expect(mockedUpload).not.toHaveBeenCalled() + }) +}) + +const runCLI = async (args: string[]) => { + const cli = new Cli() + cli.register(UploadMiniprogramCommand) + const context = createMockContext() as any + const code = await cli.run(args, context) + + return {code, context} +} + +const createMockContext = () => { + let stdout = '' + let stderr = '' + + return { + stderr: { + toString: () => stderr, + write: (input: string) => { + stderr += input + }, + }, + stdout: { + toString: () => stdout, + write: (input: string) => { + stdout += input + }, + }, + } +} + +const createZipFixture = () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'flashcat-miniprogram-test-')) + const zipPath = path.join(directory, 'sourcemap.zip') + fs.writeFileSync(zipPath, 'zip-content') + + return zipPath +} diff --git a/src/commands/sourcemaps/cli.ts b/src/commands/sourcemaps/cli.ts index 1a0522e..f4da61a 100644 --- a/src/commands/sourcemaps/cli.ts +++ b/src/commands/sourcemaps/cli.ts @@ -1,3 +1,4 @@ import {UploadCommand} from './upload' +import {UploadMiniprogramCommand} from './miniprogram' -module.exports = [UploadCommand] +module.exports = [UploadCommand, UploadMiniprogramCommand] diff --git a/src/commands/sourcemaps/miniprogram.ts b/src/commands/sourcemaps/miniprogram.ts new file mode 100644 index 0000000..d8f5078 --- /dev/null +++ b/src/commands/sourcemaps/miniprogram.ts @@ -0,0 +1,182 @@ +import fs from 'fs' +import path from 'path' + +import chalk from 'chalk' +import {Command, Option} from 'clipanion' + +import {ApiKeyValidator, newApiKeyValidator} from '../../helpers/apikey' +import {getBaseSourcemapIntakeUrl} from '../../helpers/base-intake-url' +import {InvalidConfigurationError} from '../../helpers/errors' +import {RequestBuilder} from '../../helpers/interfaces' +import {MultipartPayload, MultipartValue, UploadStatus, upload} from '../../helpers/upload' +import {getRequestBuilder} from '../../helpers/utils' +import {version} from '../../helpers/version' + +export class UploadMiniprogramCommand extends Command { + public static paths = [['sourcemaps', 'upload-miniprogram']] + + public static usage = Command.Usage({ + category: 'RUM', + description: 'Upload a WeChat miniprogram sourcemap zip (from miniprogram-ci getDevSourceMap) to Flashcat.', + details: ` + Provide the sourcemap.zip produced by 'miniprogram-ci getDevSourceMap'. + The whole zip is sent as one multipart request; the server unzips and indexes each .js.map entry. + `, + examples: [ + [ + 'Upload a miniprogram sourcemap', + 'flashcat-cli sourcemaps upload-miniprogram --service my-mp --release-version 1.2.3 --sourcemap-zip ./sourcemap.zip', + ], + ], + }) + + private service = Option.String('--service') + private releaseVersion = Option.String('--release-version') + private sourcemapZip = Option.String('--sourcemap-zip') + private appid = Option.String('--appid') + private dryRun = Option.Boolean('--dry-run', false) + private quiet = Option.Boolean('--quiet', false) + + private cliVersion = version + + private config = { + apiKey: process.env.FLASHCAT_API_KEY, + flashcatSite: process.env.FLASHCAT_SITE || 'flashcat.cloud', + } + + public async execute() { + if (!this.service) { + this.context.stderr.write('Missing --service\n') + + return 1 + } + if (!this.releaseVersion) { + this.context.stderr.write('Missing --release-version\n') + + return 1 + } + if (!this.sourcemapZip) { + this.context.stderr.write('Missing --sourcemap-zip\n') + + return 1 + } + + const zipPath = path.resolve(this.sourcemapZip) + if (!fs.existsSync(zipPath)) { + this.context.stderr.write(`File not found: ${zipPath}\n`) + + return 1 + } + + const apiKeyValidator = newApiKeyValidator({ + apiKey: this.config.apiKey, + flashcatSite: this.config.flashcatSite, + }) + const requestBuilder = this.getRequestBuilder() + const payload = this.asMultipartPayload(zipPath) + + if (this.dryRun) { + this.context.stdout.write( + `[DRYRUN] would upload ${zipPath} (service=${this.service}, version=${this.releaseVersion})\n` + ) + + return 0 + } + + try { + const result = await this.uploadMiniprogramArchive(requestBuilder, apiKeyValidator)(payload, zipPath) + + if (result === UploadStatus.Success) { + if (!this.quiet) { + this.context.stdout.write('Upload succeeded.\n') + } + + return 0 + } + + return 1 + } catch (error) { + if (error instanceof InvalidConfigurationError) { + this.context.stderr.write(`${error.message}\n`) + + return 1 + } + + throw error + } + } + + private asMultipartPayload(zipPath: string): MultipartPayload { + const eventMeta: Record = { + type: 'miniprogram_sourcemap', + service: this.service!, + version: this.releaseVersion!, + cli_version: this.cliVersion, + } + if (this.appid) { + eventMeta.appid = this.appid + } + + const content = new Map([ + [ + 'event', + { + type: 'string', + options: {contentType: 'application/json'}, + value: JSON.stringify(eventMeta), + }, + ], + [ + 'sourcemap_archive', + { + type: 'file', + path: zipPath, + options: {filename: 'sourcemap.zip'}, + }, + ], + ]) + + return {content} + } + + private getRequestBuilder(): RequestBuilder { + if (!this.config.apiKey) { + throw new InvalidConfigurationError(`Missing ${chalk.bold('FLASHCAT_API_KEY')} in your environment.`) + } + + return getRequestBuilder({ + apiKey: this.config.apiKey, + baseUrl: getBaseSourcemapIntakeUrl(this.config.flashcatSite), + headers: new Map([ + ['DD-EVP-ORIGIN', 'flashcat-cli_miniprogram'], + ['DD-EVP-ORIGIN-VERSION', this.cliVersion], + ]), + overrideUrl: '/sourcemap/upload', + }) + } + + private uploadMiniprogramArchive( + requestBuilder: RequestBuilder, + apiKeyValidator: ApiKeyValidator + ): (payload: MultipartPayload, zipPath: string) => Promise { + return async (payload: MultipartPayload, zipPath: string) => + upload(requestBuilder)(payload, { + apiKeyValidator, + onError: (e) => { + this.context.stderr.write(`Upload failed: ${e.message}\n`) + }, + onRetry: (e, attempts) => { + if (!this.quiet) { + this.context.stdout.write(`Retry ${attempts}: ${e.message}\n`) + } + }, + onUpload: () => { + if (!this.quiet) { + this.context.stdout.write(`Uploading ${path.basename(zipPath)}...\n`) + } + }, + retries: 5, + useGzip: true, + }) + } +}