From 7ddce8669e29316351959a264b1fcd02426ef7c1 Mon Sep 17 00:00:00 2001 From: hikaruhuimin Date: Thu, 14 May 2026 22:10:54 +0900 Subject: [PATCH 1/2] feat(browser-edge): emit edge-package-plan.json on dry-run, use exec helper --- packages/targets/browser-edge/src/index.ts | 132 +++++++++++++++++++-- 1 file changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/targets/browser-edge/src/index.ts b/packages/targets/browser-edge/src/index.ts index 50a4216d..ab39f123 100644 --- a/packages/targets/browser-edge/src/index.ts +++ b/packages/targets/browser-edge/src/index.ts @@ -1,4 +1,6 @@ -import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +import { defineTarget, manualSetup, exec } from '@profullstack/sh1pt-core'; +import { readFileSync, existsSync, writeFileSync } from 'node:fs'; +import { resolve, join } from 'node:path'; interface Config { productId: string; // Edge Partner Center product ID @@ -11,19 +13,133 @@ export default defineTarget({ kind: 'browser-ext', label: 'Microsoft Edge Add-ons', async build(ctx, config) { - const src = config.sourceDir ?? 'dist/'; + const src = resolve(ctx.projectDir, config.sourceDir ?? 'dist/'); + const zipPath = `${ctx.outDir}/${config.productId}-${ctx.version}.zip`; + + if (ctx.dryRun) { + const plan = { + target: 'browser-edge', + version: ctx.version, + channel: ctx.channel, + sourceDir: src, + expectedArtifact: zipPath, + productId: config.productId, + zipCommand: ['zip', '-r', zipPath, '.'], + }; + const planPath = join(ctx.outDir, 'edge-package-plan.json'); + writeFileSync(planPath, JSON.stringify(plan, null, 2)); + ctx.log(`dry-run: wrote ${planPath}`); + return { artifact: zipPath, meta: { plan } }; + } + ctx.log(`pack Edge extension from ${src} for v${ctx.version}`); - // TODO: zip extension directory, validate manifest_version 3 - return { artifact: `${ctx.outDir}/${config.productId}-${ctx.version}.zip` }; + + // Validate manifest.json exists and is manifest_version 3 + const manifestPath = join(src, 'manifest.json'); + if (!existsSync(manifestPath)) { + throw new Error(`manifest.json not found at ${manifestPath} — run a build step first`); + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); + if (manifest.manifest_version !== 3) { + ctx.log(`manifest_version is ${manifest.manifest_version}, Edge requires v3`, 'warn'); + } + + // Zip the extension directory using shared exec helper + await exec('mkdir', ['-p', ctx.outDir], { log: ctx.log, throwOnNonZero: true }); + await exec('zip', ['-r', zipPath, '.'], { cwd: src, log: ctx.log, throwOnNonZero: true }); + + ctx.log(`created ${zipPath}`); + return { artifact: zipPath }; }, async ship(ctx, config) { ctx.log(`upload ${config.productId} to Edge Partner Center (v${ctx.version})`); - if (ctx.dryRun) return { id: 'dry-run' }; - // TODO: POST /v1/products/{productId}/submissions via Edge Publish API - // Uses EDGE_CLIENT_ID + EDGE_CLIENT_SECRET + EDGE_ACCESS_TOKEN_URL from ctx.secret() + if (ctx.dryRun) { + return { id: `${config.productId}@${ctx.version}`, url: `https://microsoftedge.microsoft.com/addons/detail/${config.productId}` }; + } + + // Fetch secrets for Edge Publish API OAuth + const clientId = ctx.secret('EDGE_CLIENT_ID'); + const clientSecret = ctx.secret('EDGE_CLIENT_SECRET'); + const tokenUrl = ctx.secret('EDGE_ACCESS_TOKEN_URL'); + + if (!clientId || !clientSecret || !tokenUrl) { + throw new Error('Missing secrets: EDGE_CLIENT_ID, EDGE_CLIENT_SECRET, EDGE_ACCESS_TOKEN_URL — run: sh1pt secret set EDGE_CLIENT_ID '); + } + + // Step 1: Get OAuth access token + ctx.log('acquiring OAuth token...'); + const tokenBody = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: 'https://api.addons.microsoftedge.microsoft.com/.default', + grant_type: 'client_credentials', + }); + + const tokenRes = await fetch(tokenUrl, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: tokenBody, + }); + + if (!tokenRes.ok) { + const errText = await tokenRes.text().catch(() => ''); + throw new Error(`OAuth token request failed (${tokenRes.status}): ${errText.slice(0, 200)}`); + } + + const tokenData = (await tokenRes.json()) as { access_token: string }; + const accessToken = tokenData.access_token; + ctx.log('✓ OAuth token acquired'); + + // Step 2: Upload the package (zip) as a draft submission + ctx.log('uploading package...'); + const uploadUrl = `https://api.addons.microsoftedge.microsoft.com/v1/products/${config.productId}/submissions/draft/package`; + const zipBuf = readFileSync(ctx.artifact); + + const uploadRes = await fetch(uploadUrl, { + method: 'PUT', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/zip', + 'content-length': String(zipBuf.length), + }, + body: zipBuf, + }); + + if (!uploadRes.ok) { + const errText = await uploadRes.text().catch(() => ''); + throw new Error(`Package upload failed (${uploadRes.status}): ${errText.slice(0, 200)}`); + } + + ctx.log('✓ package uploaded as draft'); + + // Step 3: Submit the draft for review + ctx.log('submitting for review...'); + const submitUrl = `https://api.addons.microsoftedge.microsoft.com/v1/products/${config.productId}/submissions`; + const notes = config.notes; + const submitBody: Record = {}; + if (notes) { submitBody.notes = notes; } + + const submitRes = await fetch(submitUrl, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(submitBody), + }); + + if (!submitRes.ok) { + const errText = await submitRes.text().catch(() => ''); + throw new Error(`Submission failed (${submitRes.status}): ${errText.slice(0, 200)}`); + } + + const submitData = (await submitRes.json()) as { id?: string }; + ctx.log('✓ submitted to Edge Partner Center'); + return { id: `${config.productId}@${ctx.version}`, url: `https://microsoftedge.microsoft.com/addons/detail/${config.productId}`, + meta: { submissionId: submitData.id }, }; }, async status(id) { @@ -35,7 +151,7 @@ export default defineTarget({ vendorDocUrl: 'https://learn.microsoft.com/en-us/microsoft-edge/extensions-chromium/publish/api/using-addons-api', steps: [ 'Go to https://partner.microsoft.com/dashboard/ and register your extension', - 'Create an API credential under Account \u2192 API credentials', + 'Create an API credential under Account → API credentials', 'Run: sh1pt secret set EDGE_CLIENT_ID ', 'Run: sh1pt secret set EDGE_CLIENT_SECRET ', 'Run: sh1pt secret set EDGE_ACCESS_TOKEN_URL ', From 47caf2cf8351ccade5e0477caed735c0440f0a40 Mon Sep 17 00:00:00 2001 From: hikaruhuimin Date: Thu, 14 May 2026 22:10:56 +0900 Subject: [PATCH 2/2] test(browser-edge): add dry-run plan and side-effect-free tests --- .../targets/browser-edge/src/index.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/targets/browser-edge/src/index.test.ts b/packages/targets/browser-edge/src/index.test.ts index a556a6ca..229e4851 100644 --- a/packages/targets/browser-edge/src/index.test.ts +++ b/packages/targets/browser-edge/src/index.test.ts @@ -1,4 +1,33 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { describe, it, expect, vi } from 'vitest'; import adapter from './index.js'; +import { fakeBuildContext, fakeShipContext } from '@profullstack/sh1pt-core/testing'; -smokeTest(adapter, { idPrefix: 'browser', requireKind: true }); +const config = { productId: 'test-product-id' }; + +describe('browser-edge adapter', () => { + it('exports correct id and label', () => { + expect(adapter.id).toBe('browser-edge'); + expect(adapter.label).toBeTruthy(); + }); + + it('writes edge-package-plan.json during dry-run build', async () => { + const writeSpy = vi.spyOn(await import('node:fs'), 'writeFileSync').mockImplementation(() => {}); + const ctx = fakeBuildContext({ dryRun: true }); + const result = await adapter.build(ctx, config); + expect(writeSpy).toHaveBeenCalled(); + const planArg = writeSpy.mock.calls[0][1]; + const plan = JSON.parse(planArg); + expect(plan.target).toBe('browser-edge'); + expect(plan.productId).toBe('test-product-id'); + expect(plan.expectedArtifact).toContain('test-product-id'); + expect(plan.zipCommand).toBeDefined(); + expect(result.meta?.plan).toBeDefined(); + writeSpy.mockRestore(); + }); + + it('dry-run ship returns early without network calls', async () => { + const ctx = fakeShipContext({ dryRun: true }); + const result = await adapter.ship(ctx, config); + expect(result.id).toContain('test-product-id'); + }); +});