From 7831b97451498f86ccd0d3102830de72aa8949cd Mon Sep 17 00:00:00 2001 From: DuncanAForbes Date: Thu, 16 Jul 2026 22:27:08 +0100 Subject: [PATCH] feat: app publisher identity in the manifest + CLI pipeline [BDOK-678] Phase 2 of app identity (BDOK-678). Adds optional publisher identity to bagdock.json and wires it through the CLI. Publisher fields inherit from the owning org's profile by default and override per field, so the existing first-party apps need no manifest edits. - config.ts: `BagdockJson` gains `publisher` (company/website/supportEmail/ docsUrl/privacyPolicy), `icon` (repo-relative path to a square PNG/SVG), and `description`. Field names follow the BDOK-560 camelCase-manifest convention. - validate: dependency-free icon checks (square, PNG/SVG, >=128px, <=256KB via PNG IHDR / SVG width|height|viewBox parsing) + publisher field validation. A public app with no publisher block WARNs (the offline CLI cannot see the org profile that may complete it), never fails. - init: inherit-by-default (no publisher block scaffolded); interactive, TTY-gated overrides. New `--yes` skips the prompts for scripted use. - deploy: ships `publisher` + `description` in metadata and the icon bytes as an `icon` file part; surfaces the server's new `publisher_warning`. - submit: advisory publisher-completeness pre-check for public apps. Requires the server-side deploy handler that consumes `metadata.publisher` and the `icon` part (ships first). Bumps to 0.10.0. --- CHANGELOG.md | 10 +++ bin/bagdock.ts | 1 + package.json | 2 +- src/config.ts | 31 ++++++++ src/deploy.ts | 29 +++++++ src/init.ts | 45 ++++++++++- src/submit.ts | 20 +++++ src/validate.ts | 170 ++++++++++++++++++++++++++++++++++++++++- tests/validate.test.ts | 153 +++++++++++++++++++++++++++++++++++++ 9 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 tests/validate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a25da0e..1d177fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-07-16 + +### Added + +- App publisher identity in `bagdock.json` (BDOK-678): an optional `publisher` block (`company`, `website`, `supportEmail`, `docsUrl`, `privacyPolicy`), an `icon` path, and a `description`. Publisher fields inherit from your org profile by default and override per field, so most apps need no `publisher` block at all. +- `bagdock validate` now checks the app icon (square PNG or SVG, at least 128px, up to 256KB) and any publisher fields. A public app with no publisher block gets a warning, not a failure, since its identity inherits the org profile. +- `bagdock init` offers interactive publisher overrides and inherits from the org profile by default. `--yes` (or a non-interactive shell) skips the prompts. +- `bagdock deploy` ships the publisher block, description, and icon bytes. The platform hosts the icon and mirrors publisher identity into the dashboard. +- `bagdock submit` runs an advisory publisher-completeness check for public apps before submitting for review. + ## [0.5.0] - 2026-04-05 ### Added diff --git a/bin/bagdock.ts b/bin/bagdock.ts index 5eee0bd..cc3b51d 100644 --- a/bin/bagdock.ts +++ b/bin/bagdock.ts @@ -141,6 +141,7 @@ program .option('-c, --category ', 'Category') .option('-s, --slug ', 'Unique project slug') .option('-n, --name ', 'Display name') + .option('-y, --yes', 'Skip interactive prompts (inherit publisher identity from the org profile)') .action((dir: string | undefined, opts: Record) => init(dir ?? '.', opts)) // ---------- Dev ---------- diff --git a/package.json b/package.json index 13d6839..15c7a9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bagdock/cli", - "version": "0.9.1", + "version": "0.10.0", "description": "Bagdock developer CLI — build, test, and deploy apps and edges on the Bagdock platform", "keywords": [ "bagdock", diff --git a/src/config.ts b/src/config.ts index 7004dbe..49ebb14 100644 --- a/src/config.ts +++ b/src/config.ts @@ -308,6 +308,27 @@ export interface DisplayDeclaration { copyable?: boolean } +/** + * Optional publisher-identity override (BDOK-678). Publisher identity normally + * derives from the owning org's profile at render time; this block lets an app + * override individual fields from the code that ships it (HubSpot-projects + * style). It is a PER-FIELD raw-config override — the platform mirrors it into + * the owning operator's regional `publisher_configs` on deploy, where the + * dashboard resolves each field as `manifest override → org profile default`. + * + * Every field is optional: an omitted field falls through to the org profile, + * so a public app with no `publisher` block still renders its org's identity + * (the derivation law — no N stale copies of one org fact). Field names follow + * the BDOK-560 camelCase-manifest → mirrored-config convention. + */ +export interface PublisherDeclaration { + company?: string + website?: string + supportEmail?: string + docsUrl?: string + privacyPolicy?: string +} + export interface BagdockJson { name: string slug: string @@ -319,6 +340,16 @@ export interface BagdockJson { visibility: 'public' | 'private' main: string compatibilityDate?: string + /** One-line marketplace description. Mirrored into control-plane config. */ + description?: string + /** + * Repo-relative path to the app icon (square PNG or SVG). Per-app by nature, + * so it is always authored in the manifest rather than derived from the org + * profile. Validated by `bagdock validate` (square, PNG/SVG, ≥128px, ≤256KB). + */ + icon?: string + /** Per-field publisher-identity override. See {@link PublisherDeclaration}. */ + publisher?: PublisherDeclaration env?: Record kv?: Record webhooks?: WebhookDeclaration[] diff --git a/src/deploy.ts b/src/deploy.ts index 6927c81..dc5d36d 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -128,8 +128,28 @@ export async function deploy(opts: DeployOptions) { ...(config.webhooks ? { webhooks: config.webhooks } : {}), ...(config.inputs ? { inputs: config.inputs } : {}), ...(config.displays ? { displays: config.displays } : {}), + // BDOK-678: publisher identity override + marketplace description. The icon + // ships as a separate `icon` file part below. Only non-preview deploys + // persist these server-side. + ...(config.description ? { description: config.description } : {}), + ...(config.publisher ? { publisher: config.publisher } : {}), })) + // App icon (BDOK-678): ship the bytes so the platform can host it and mirror a + // stable icon_url. `bagdock validate` has already checked it is a square + // PNG/SVG within the size cap; a missing file is skipped (validate warns). + if (config.icon) { + const iconPath = join(cwd, config.icon) + if (existsSync(iconPath)) { + const iconBytes = readFileSync(iconPath) + const ext = config.icon.toLowerCase().slice(config.icon.lastIndexOf('.')) + const iconType = ext === '.svg' ? 'image/svg+xml' : ext === '.png' ? 'image/png' : 'application/octet-stream' + formData.append('icon', new Blob([iconBytes], { type: iconType }), `icon${ext}`) + } else { + console.log(chalk.yellow(` Icon not found at ${config.icon}, skipping upload.`)) + } + } + try { const res = await fetch(`${getApiBase()}/api/v1/developer/apps/${config.slug}/deploy`, { method: 'POST', @@ -168,6 +188,8 @@ export async function deploy(opts: DeployOptions) { workerUrl: string namespace: string previewHash?: string + publisher_warning?: string + secrets_warning?: string } } @@ -180,6 +202,13 @@ export async function deploy(opts: DeployOptions) { } console.log(` Namespace: ${chalk.dim(result.data.namespace)}`) + if (result.data.secrets_warning) { + console.log(chalk.yellow(`\n ⚠ ${result.data.secrets_warning}`)) + } + if (result.data.publisher_warning) { + console.log(chalk.yellow(`\n ⚠ ${result.data.publisher_warning}`)) + } + if (environment === 'preview') { console.log(chalk.dim('\n This is an ephemeral preview deploy. It will not replace the stable staging URL.')) } diff --git a/src/init.ts b/src/init.ts index 208dfa4..ae49be8 100644 --- a/src/init.ts +++ b/src/init.ts @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'fs' import { join, basename } from 'path' import chalk from 'chalk' -import type { BagdockJson, ProjectType, ProjectKind } from './config' +import type { BagdockJson, ProjectType, ProjectKind, PublisherDeclaration } from './config' interface InitOptions { type?: string @@ -13,6 +13,7 @@ interface InitOptions { category?: string slug?: string name?: string + yes?: boolean } const EDGE_KINDS = ['adapter', 'comms', 'webhook'] as const @@ -57,6 +58,8 @@ export async function init(dir: string, opts: InitOptions) { }, } + await promptPublisherOverrides(config, opts) + writeFileSync(join(projectDir, 'bagdock.json'), JSON.stringify(config, null, 2)) const srcDir = join(projectDir, 'src') @@ -121,6 +124,46 @@ export async function init(dir: string, opts: InitOptions) { console.log(` 3. ${chalk.cyan('bagdock deploy')} — deploy to Bagdock platform`) } +/** + * Interactive, opt-in publisher-identity overrides (BDOK-678 Phase 2). + * Apps inherit publisher identity from the owning operator by default; + * this only runs in a TTY and lets the author override individual fields. + */ +async function promptPublisherOverrides(config: BagdockJson, opts: InitOptions): Promise { + if (opts.yes === true || !process.stdin.isTTY) return + + const readline = await import('readline') + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + const ask = (q: string): Promise => new Promise((res) => rl.question(q, (a) => res(a.trim()))) + + try { + const wantsOverrides = await ask('Add publisher identity overrides now? Most apps inherit from your org profile. [y/N] ') + if (!/^y(es)?$/i.test(wantsOverrides)) return + + const company = await ask('Company name (optional): ') + const website = await ask('Website URL (optional): ') + const supportEmail = await ask('Support email (optional): ') + const docsUrl = await ask('Docs URL (optional): ') + const privacyPolicy = await ask('Privacy policy URL (optional): ') + + const publisher: PublisherDeclaration = {} + if (company) publisher.company = company + if (website) publisher.website = website + if (supportEmail) publisher.supportEmail = supportEmail + if (docsUrl) publisher.docsUrl = docsUrl + if (privacyPolicy) publisher.privacyPolicy = privacyPolicy + if (Object.keys(publisher).length > 0) config.publisher = publisher + + const description = await ask('One-line description (optional): ') + if (description) config.description = description + + const icon = await ask('Icon path, square PNG/SVG >=128px (optional): ') + if (icon) config.icon = icon + } finally { + rl.close() + } +} + function resolveKind(type: ProjectType, kindOpt?: string): ProjectKind { if (kindOpt) return kindOpt as ProjectKind return type === 'app' ? 'ui-extension' : 'adapter' diff --git a/src/submit.ts b/src/submit.ts index b5f47e8..e855f0d 100644 --- a/src/submit.ts +++ b/src/submit.ts @@ -26,6 +26,26 @@ export async function submit() { process.exit(1) } + // Advisory publisher-identity pre-check (BDOK-678). This is a heads-up, not a + // gate: the authoritative completeness check runs server-side at review, where + // your org profile can complete fields the manifest omits (which this offline + // CLI cannot see). So we only nudge on likely gaps for public apps. + if (config.visibility === 'public') { + const pub = config.publisher ?? {} + const recommended: Array<[keyof typeof pub, string]> = [ + ['company', 'company'], + ['website', 'website'], + ['supportEmail', 'supportEmail'], + ['privacyPolicy', 'privacyPolicy'], + ] + const missing = recommended.filter(([k]) => !pub[k]).map(([, label]) => label) + if (missing.length) { + console.log(chalk.yellow(` Heads-up: publisher ${missing.join(', ')} not set in bagdock.json.`)) + console.log(chalk.dim(` Reviewers need a complete publisher identity. These fields fall back to your org`)) + console.log(chalk.dim(` profile — add a "publisher" block to override them per app. Submitting anyway.\n`)) + } + } + console.log(chalk.cyan(`\nSubmitting ${chalk.bold(config.slug)} for marketplace review...\n`)) try { diff --git a/src/validate.ts b/src/validate.ts index 455fcc7..94e2408 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -6,9 +6,9 @@ */ import chalk from 'chalk' -import { existsSync, statSync } from 'fs' +import { existsSync, readFileSync, statSync } from 'fs' import { join } from 'path' -import { loadBagdockJson, type ProjectType, type ProjectKind } from './config' +import { loadBagdockJson, type BagdockJson, type ProjectType, type ProjectKind } from './config' import { isJsonMode, outputSuccess, outputError } from './output' import { resolveSlug } from './link' @@ -22,6 +22,11 @@ const VALID_TYPES: ProjectType[] = ['edge', 'app'] const VALID_KINDS: ProjectKind[] = ['adapter', 'comms', 'webhook', 'ui-extension', 'microfrontend'] const MAX_BUNDLE_BYTES = 10 * 1024 * 1024 // 10 MB +// App-icon spec (BDOK-678): square PNG/SVG, ≥128px, ≤256KB. +const ICON_MIN_PX = 128 +const ICON_MAX_BYTES = 256 * 1024 +const PUBLISHER_FIELDS = ['company', 'website', 'supportEmail', 'docsUrl', 'privacyPolicy'] as const + export async function validate() { const checks: Check[] = [] const dir = process.cwd() @@ -147,7 +152,20 @@ export async function validate() { } } - // 9 — Slug matches linked project (if linked) + // 9 — App icon (BDOK-678, optional). If declared, the file must be a square + // PNG/SVG within the size bounds; if not declared, the app renders the + // initial-tile fallback, so absence is at most a public-app nudge (check 10). + if (config.icon !== undefined) { + checks.push(checkIcon(dir, config.icon)) + } + + // 10 — Publisher identity (BDOK-678, optional override). A public app with no + // publisher block validly inherits its org profile at render time (the CLI + // cannot see that profile offline), so a missing block is a WARN nudge, never + // a failure. A present block must be well-formed. + for (const c of checkPublisher(config)) checks.push(c) + + // 11 — Slug matches linked project (if linked) const linked = resolveSlug() if (linked && linked !== config.slug) { checks.push({ name: 'Project link', status: 'warn', message: `bagdock.json slug "${config.slug}" differs from linked project "${linked}"` }) @@ -156,6 +174,152 @@ export async function validate() { return finish(checks) } +// ============================================================================ +// ICON + PUBLISHER CHECKS (BDOK-678) +// ============================================================================ + +/** + * Validate the declared app icon: square, PNG or SVG, ≥128px, ≤256KB. Reads + * image dimensions with zero dependencies — the PNG IHDR header for rasters and + * the width/height/viewBox attributes for SVG. SVG is vector, so the ≥128px + * floor is advisory there; a non-square SVG is still flagged. + */ +export function checkIcon(dir: string, iconPath: string): Check { + if (typeof iconPath !== 'string' || !iconPath.trim()) { + return { name: 'Icon', status: 'fail', message: '"icon" must be a non-empty path' } + } + const abs = join(dir, iconPath) + if (!existsSync(abs)) { + return { name: 'Icon', status: 'fail', message: `File not found: ${iconPath}` } + } + + const size = statSync(abs).size + if (size > ICON_MAX_BYTES) { + return { name: 'Icon', status: 'fail', message: `${(size / 1024).toFixed(0)} KB exceeds the ${ICON_MAX_BYTES / 1024} KB limit` } + } + + const ext = iconPath.toLowerCase().slice(iconPath.lastIndexOf('.')) + const buf = readFileSync(abs) + + if (ext === '.png' || isPng(buf)) { + const dims = pngDimensions(buf) + if (!dims) { + return { name: 'Icon', status: 'fail', message: `${iconPath} is not a valid PNG` } + } + if (dims.width !== dims.height) { + return { name: 'Icon', status: 'fail', message: `${iconPath} must be square (got ${dims.width}×${dims.height})` } + } + if (dims.width < ICON_MIN_PX) { + return { name: 'Icon', status: 'fail', message: `${iconPath} is ${dims.width}px — must be at least ${ICON_MIN_PX}px` } + } + return { name: 'Icon', status: 'pass', message: `${iconPath} (${dims.width}×${dims.height} PNG, ${(size / 1024).toFixed(0)} KB)` } + } + + if (ext === '.svg' || isSvg(buf)) { + const dims = svgDimensions(buf.toString('utf-8')) + if (dims && dims.width > 0 && dims.height > 0) { + const ratio = dims.width / dims.height + if (ratio < 0.98 || ratio > 1.02) { + return { name: 'Icon', status: 'fail', message: `${iconPath} must be square (viewBox/size is ${dims.width}×${dims.height})` } + } + } + return { name: 'Icon', status: 'pass', message: `${iconPath} (SVG, ${(size / 1024).toFixed(0)} KB)` } + } + + return { name: 'Icon', status: 'fail', message: `${iconPath} must be a PNG or SVG` } +} + +/** + * Validate the optional publisher-identity override. Present fields must be + * well-formed (http(s) URLs, an email for supportEmail). A public app with no + * publisher block gets a WARN — its identity inherits the org profile, which the + * offline CLI cannot confirm, so we nudge rather than block. + */ +export function checkPublisher(config: BagdockJson): Check[] { + const checks: Check[] = [] + const pub = config.publisher + + if (pub === undefined) { + if (config.visibility === 'public') { + checks.push({ + name: 'Publisher', + status: 'warn', + message: 'No publisher block — identity will inherit your org profile. Add a "publisher" block to override per app.', + }) + } + return checks + } + + if (typeof pub !== 'object' || Array.isArray(pub)) { + checks.push({ name: 'Publisher', status: 'fail', message: '"publisher" must be an object of { company?, website?, supportEmail?, docsUrl?, privacyPolicy? }' }) + return checks + } + + const problems: string[] = [] + const unknown = Object.keys(pub).filter((k) => !PUBLISHER_FIELDS.includes(k as any)) + if (unknown.length) problems.push(`unknown field(s): ${unknown.join(', ')}`) + + for (const [field, val] of Object.entries(pub)) { + if (val === undefined || val === null) continue + if (typeof val !== 'string' || !val.trim()) { problems.push(`"${field}" must be a non-empty string`); continue } + if ((field === 'website' || field === 'docsUrl' || field === 'privacyPolicy') && !isHttpUrl(val)) { + problems.push(`"${field}" must be an http(s) URL (got "${val}")`) + } + if (field === 'supportEmail' && !isEmail(val)) { + problems.push(`"supportEmail" must be an email address (got "${val}")`) + } + } + + if (problems.length) { + checks.push({ name: 'Publisher', status: 'fail', message: problems.join('; ') }) + } else { + const provided = PUBLISHER_FIELDS.filter((f) => (pub as any)[f]) + checks.push({ name: 'Publisher', status: 'pass', message: `override: ${provided.join(', ') || '(empty)'}` }) + } + return checks +} + +function isPng(buf: Buffer): boolean { + const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + return buf.length >= 8 && sig.every((b, i) => buf[i] === b) +} + +/** PNG width/height live big-endian in the IHDR chunk at byte offsets 16 and 20. */ +function pngDimensions(buf: Buffer): { width: number; height: number } | null { + if (!isPng(buf) || buf.length < 24) return null + // Bytes 12–15 must spell "IHDR" for the dimensions to be at 16/20. + if (buf.toString('ascii', 12, 16) !== 'IHDR') return null + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) } +} + +function isSvg(buf: Buffer): boolean { + const head = buf.toString('utf-8', 0, Math.min(buf.length, 512)).trimStart() + return head.startsWith(' c.status === 'fail') const hasWarn = checks.some((c) => c.status === 'warn') diff --git a/tests/validate.test.ts b/tests/validate.test.ts new file mode 100644 index 0000000..de0bf0c --- /dev/null +++ b/tests/validate.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdirSync, writeFileSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { checkIcon, checkPublisher } from '../src/validate' +import type { BagdockJson } from '../src/config' + +const TEST_DIR = join(tmpdir(), `bagdock-test-validate-${Date.now()}`) + +function makePng(w: number, h: number): Buffer { + const b = Buffer.alloc(24) + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(b, 0) // signature + b.write('IHDR', 12, 'ascii') + b.writeUInt32BE(w, 16) + b.writeUInt32BE(h, 20) + return b +} + +function baseConfig(overrides: Partial = {}): BagdockJson { + return { + name: 'Test App', + slug: 'test-app', + version: '1.0.0', + type: 'edge', + category: 'utility', + main: 'index.js', + ...overrides, + } as BagdockJson +} + +describe('validate — checkIcon', () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) + }) + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) + }) + + it('passes for a square PNG under the size limit', () => { + writeFileSync(join(TEST_DIR, 'icon.png'), makePng(256, 256)) + const result = checkIcon(TEST_DIR, 'icon.png') + expect(result.status).toBe('pass') + }) + + it('fails for a non-square PNG', () => { + writeFileSync(join(TEST_DIR, 'icon.png'), makePng(256, 128)) + const result = checkIcon(TEST_DIR, 'icon.png') + expect(result.status).toBe('fail') + expect(result.message).toMatch(/square/) + }) + + it('fails for a PNG smaller than 128px', () => { + writeFileSync(join(TEST_DIR, 'icon.png'), makePng(64, 64)) + const result = checkIcon(TEST_DIR, 'icon.png') + expect(result.status).toBe('fail') + expect(result.message).toMatch(/128/) + }) + + it('fails for a file exceeding the 256KB limit', () => { + const oversized = Buffer.concat([makePng(256, 256), Buffer.alloc(300 * 1024)]) + writeFileSync(join(TEST_DIR, 'icon.png'), oversized) + const result = checkIcon(TEST_DIR, 'icon.png') + expect(result.status).toBe('fail') + expect(result.message).toMatch(/KB/) + }) + + it('fails for a missing file', () => { + const result = checkIcon(TEST_DIR, 'does-not-exist.png') + expect(result.status).toBe('fail') + expect(result.message).toMatch(/not found/) + }) + + it('fails for a non-PNG/SVG file', () => { + writeFileSync(join(TEST_DIR, 'icon.txt'), 'not an image') + const result = checkIcon(TEST_DIR, 'icon.txt') + expect(result.status).toBe('fail') + }) + + it('passes for a square SVG with width/height', () => { + writeFileSync(join(TEST_DIR, 'icon.svg'), '') + const result = checkIcon(TEST_DIR, 'icon.svg') + expect(result.status).toBe('pass') + }) + + it('fails for a non-square SVG', () => { + writeFileSync(join(TEST_DIR, 'icon.svg'), '') + const result = checkIcon(TEST_DIR, 'icon.svg') + expect(result.status).toBe('fail') + expect(result.message).toMatch(/square/) + }) + + it('passes for a square SVG defined via viewBox', () => { + writeFileSync(join(TEST_DIR, 'icon.svg'), '') + const result = checkIcon(TEST_DIR, 'icon.svg') + expect(result.status).toBe('pass') + }) +}) + +describe('validate — checkPublisher', () => { + it('warns when visibility is public and no publisher block is present', () => { + const checks = checkPublisher(baseConfig({ visibility: 'public' })) + expect(checks).toHaveLength(1) + expect(checks[0].status).toBe('warn') + }) + + it('returns no checks when visibility is private and no publisher block is present', () => { + const checks = checkPublisher(baseConfig({ visibility: 'private' })) + expect(checks).toEqual([]) + }) + + it('passes for a well-formed publisher block', () => { + const checks = checkPublisher( + baseConfig({ + visibility: 'public', + publisher: { + company: 'Acme', + website: 'https://acme.com', + supportEmail: 'help@acme.com', + docsUrl: 'https://acme.com/docs', + privacyPolicy: 'https://acme.com/privacy', + }, + }) + ) + expect(checks).toHaveLength(1) + expect(checks[0].status).toBe('pass') + }) + + it('fails for a bad website URL', () => { + const checks = checkPublisher( + baseConfig({ visibility: 'public', publisher: { company: 'Acme', website: 'acme.com' } }) + ) + expect(checks).toHaveLength(1) + expect(checks[0].status).toBe('fail') + }) + + it('fails for a bad support email', () => { + const checks = checkPublisher( + baseConfig({ visibility: 'public', publisher: { company: 'Acme', supportEmail: 'notanemail' } }) + ) + expect(checks).toHaveLength(1) + expect(checks[0].status).toBe('fail') + }) + + it('fails for an unknown publisher field', () => { + const checks = checkPublisher( + baseConfig({ visibility: 'public', publisher: { foo: 'bar' } as any }) + ) + expect(checks).toHaveLength(1) + expect(checks[0].status).toBe('fail') + expect(checks[0].message).toMatch(/unknown/) + }) +})