Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions packages/targets/browser-edge/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
132 changes: 124 additions & 8 deletions packages/targets/browser-edge/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,19 +13,133 @@ export default defineTarget<Config>({
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 <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<string, unknown> = {};
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) {
Expand All @@ -35,7 +151,7 @@ export default defineTarget<Config>({
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 <client-id>',
'Run: sh1pt secret set EDGE_CLIENT_SECRET <client-secret>',
'Run: sh1pt secret set EDGE_ACCESS_TOKEN_URL <token-url>',
Expand Down