diff --git a/package.json b/package.json index d4d930b..6bfc294 100644 --- a/package.json +++ b/package.json @@ -152,10 +152,6 @@ { "from": "src/main/engine/templates", "to": "templates" - }, - { - "from": "src/main/engine/mcp-views", - "to": "mcp-views" } ], "protocols": [ diff --git a/src/main/engine/mcp-views/review-metadata.html b/src/main/engine/mcp-view-builder.ts similarity index 60% rename from src/main/engine/mcp-views/review-metadata.html rename to src/main/engine/mcp-view-builder.ts index 701bd4d..5461109 100644 --- a/src/main/engine/mcp-views/review-metadata.html +++ b/src/main/engine/mcp-view-builder.ts @@ -1,7 +1,41 @@ - - -Review Metadata - - - -
-

Waiting for metadata...

-
- - +`; +} diff --git a/src/main/engine/mcp-views.ts b/src/main/engine/mcp-views.ts index 9200263..066a54e 100644 --- a/src/main/engine/mcp-views.ts +++ b/src/main/engine/mcp-views.ts @@ -1,5 +1,6 @@ /** - * MCP App Review Views — loaded from HTML files in the `mcp-views/` directory. + * MCP App Review Views — generated at runtime from shared boilerplate + * + per-view configuration via `buildMcpView()`. * * Each function returns a self-contained HTML page that uses the * `App` class from `@modelcontextprotocol/ext-apps` (loaded via @@ -9,77 +10,120 @@ * in MCP hosts that support MCP Apps (Claude, ChatGPT, VS Code, etc.). */ -import { readFileSync } from 'fs'; -import path from 'path'; +import { buildMcpView } from './mcp-view-builder'; -/** - * Resolve candidate directories for MCP view HTML files. - * Checks `__dirname/mcp-views`, `dist/main/engine/mcp-views`, - * `src/main/engine/mcp-views`, and `process.resourcesPath/mcp-views`. - */ -export function resolveMcpViewsDirs(options?: { - moduleDir?: string; - cwd?: string; - resourcesPath?: string; -}): string[] { - const moduleDir = options?.moduleDir ?? __dirname; - const cwd = options?.cwd ?? process.cwd(); - const resourcesPath = options?.resourcesPath ?? process.resourcesPath; +/* ── Review Post ────────────────────────────────────────────────────── */ - const dirs = [ - path.resolve(moduleDir, 'mcp-views'), - path.resolve(cwd, 'dist', 'main', 'engine', 'mcp-views'), - path.resolve(cwd, 'src', 'main', 'engine', 'mcp-views'), - ]; - - if (typeof resourcesPath === 'string' && resourcesPath.length > 0) { - dirs.unshift(path.resolve(resourcesPath, 'mcp-views')); - } - - return Array.from(new Set(dirs)); +export function reviewPostHtml(): string { + return buildMcpView({ + title: 'Review Post', + waitingMessage: 'Waiting for post data...', + acceptLabel: 'Publish', + discardLabel: 'Discard Draft', + renderBody: `\ + const post = data.post || {}; + const wc = (post.content || "").split(/\\s+/).filter(Boolean).length; + document.getElementById("review").innerHTML = \` +

\${esc(post.title || "Untitled")}

+

+ Draft + \${wc} words +

+ \${post.categories?.length ? '

Categories: ' + post.categories.map(c => esc(c)).join(", ") + '

' : ''} + \${post.tags?.length ? '

Tags: ' + post.tags.map(t => esc(t)).join(", ") + '

' : ''} + \${post.excerpt ? '

Excerpt

' + esc(post.excerpt) + '

' : ''} +

Content

+
\${esc(post.content || "")}
+
+ + +
+ \`;`, + }); } -/** - * Load an MCP view HTML file by name, searching candidate directories. - * Throws if the file cannot be found in any candidate directory. - */ -export function loadViewHtml( - filename: string, - options?: Parameters[0], -): string { - const dirs = resolveMcpViewsDirs(options); - for (const dir of dirs) { - try { - return readFileSync(path.join(dir, filename), 'utf-8'); - } catch { - // try next directory - } - } - throw new Error( - `MCP view "${filename}" not found in any of: ${dirs.join(', ')}`, - ); +/* ── Review Script ──────────────────────────────────────────────────── */ + +export function reviewScriptHtml(): string { + return buildMcpView({ + title: 'Review Script', + waitingMessage: 'Waiting for script data...', + acceptLabel: 'Create Script', + discardLabel: 'Discard', + renderBody: `\ + const p = data.preview || data; + document.getElementById("review").innerHTML = \` +

\${esc(p.title || "Untitled Script")}

+

\${esc(p.kind || "script")}

+

Python Code

+
\${esc(p.content || "(code not included in preview)")}
+
+ + +
+ \`;`, + }); } -export function reviewPostHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-post.html', options); +/* ── Review Template ────────────────────────────────────────────────── */ + +export function reviewTemplateHtml(): string { + return buildMcpView({ + title: 'Review Template', + waitingMessage: 'Waiting for template data...', + acceptLabel: 'Create Template', + discardLabel: 'Discard', + renderBody: `\ + const p = data.preview || data; + document.getElementById("review").innerHTML = \` +

\${esc(p.title || "Untitled Template")}

+

\${esc(p.kind || "template")}

+

Liquid Template

+
\${esc(p.content || "(template not included in preview)")}
+
+ + +
+ \`;`, + }); } -export function reviewScriptHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-script.html', options); -} +/* ── Review Metadata ────────────────────────────────────────────────── */ -export function reviewTemplateHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-template.html', options); -} - -export function reviewMetadataHtml( - options?: Parameters[0], -): string { - return loadViewHtml('review-metadata.html', options); +export function reviewMetadataHtml(): string { + return buildMcpView({ + title: 'Review Metadata', + waitingMessage: 'Waiting for metadata...', + acceptLabel: 'Apply Changes', + discardLabel: 'Discard', + extraCss: `\ + .diff-table { width: 100%; border-collapse: collapse; margin: 8px 0; } + .diff-table th, .diff-table td { padding: 6px 10px; border: 1px solid #dee2e6; text-align: left; font-size: 0.85rem; } + .diff-table th { background: #f1f3f5; font-weight: 600; } + .diff-old { background: #ffeef0; } + .diff-new { background: #e6ffed; }`, + extraJsHelpers: `function fmt(v) { if (v == null) return "(empty)"; if (Array.isArray(v)) return v.join(", "); return String(v); }`, + renderBody: `\ + const current = data.current || {}; + const proposed = data.proposed || {}; + const fields = Object.keys(proposed); + let rows = fields.map(f => \` + + \${esc(f)} + \${esc(fmt(current[f]))} + \${esc(fmt(proposed[f]))} + + \`).join(""); + document.getElementById("review").innerHTML = \` +

Metadata Changes

+ + + \${rows} +
FieldCurrentProposed
+
+ + +
+ \`;`, + }); } diff --git a/src/main/engine/mcp-views/review-post.html b/src/main/engine/mcp-views/review-post.html deleted file mode 100644 index d661b87..0000000 --- a/src/main/engine/mcp-views/review-post.html +++ /dev/null @@ -1,124 +0,0 @@ - - -Review Post - - - -
-

Waiting for post data...

-
- - - - diff --git a/src/main/engine/mcp-views/review-script.html b/src/main/engine/mcp-views/review-script.html deleted file mode 100644 index f352bc5..0000000 --- a/src/main/engine/mcp-views/review-script.html +++ /dev/null @@ -1,116 +0,0 @@ - - -Review Script - - - -
-

Waiting for script data...

-
- - - - diff --git a/src/main/engine/mcp-views/review-template.html b/src/main/engine/mcp-views/review-template.html deleted file mode 100644 index 7aa7a0d..0000000 --- a/src/main/engine/mcp-views/review-template.html +++ /dev/null @@ -1,116 +0,0 @@ - - -Review Template - - - -
-

Waiting for template data...

-
- - - - diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d703616..8c5cc0e 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1612,10 +1612,15 @@ export function registerIpcHandlers(): void { return null; } }); +} - // ============ Event Forwarding ============ - - // Forward engine events to renderer +/** + * Register event forwarding from engine EventEmitters to the renderer via IPC. + * Must be called after the database is initialized (engines require DB access). + * Separated from registerIpcHandlers() so that handler registration can happen + * synchronously before any async work, eliminating startup race conditions. + */ +export function registerEventForwarding(): void { const postEngine = getPostEngine(); const mediaEngine = getMediaEngine(); const projectEngine = getProjectEngine(); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 1fc06f9..63d5753 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,2 +1,2 @@ -export { registerIpcHandlers } from './handlers'; +export { registerIpcHandlers, registerEventForwarding } from './handlers'; export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers'; diff --git a/src/main/main.ts b/src/main/main.ts index 69c3a3c..2ef5730 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol import * as path from 'path'; import * as fs from 'fs'; import { getDatabase } from './database'; -import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc'; +import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc'; import { media } from './database/schema'; import { eq } from 'drizzle-orm'; import { getMediaEngine } from './engine/MediaEngine'; @@ -714,10 +714,19 @@ function createApplicationMenu(): Menu { } async function initialize(): Promise { + // Register IPC handlers immediately (synchronous) so they are available + // before any async work. This eliminates race conditions where the renderer + // calls handlers before the database is ready. + registerIpcHandlers(); + // Initialize database const db = getDatabase(); await db.initializeLocal(); + // Now that the database is ready, register event forwarding from engines + // to the renderer (engines need DB access at registration time). + registerEventForwarding(); + // Register custom protocol for serving media files // URLs like bds-media://media-id will be resolved to the actual file protocol.handle('bds-media', async (request) => { @@ -816,9 +825,6 @@ async function initialize(): Promise { } }); - // Register IPC handlers - registerIpcHandlers(); - ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => { activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null; setPreviewPostMenuEnabled(Boolean(activePreviewPostId)); diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts index 80e56f5..8bb4e62 100644 --- a/tests/engine/mainStartup.test.ts +++ b/tests/engine/mainStartup.test.ts @@ -91,6 +91,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -209,6 +210,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -356,6 +358,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -498,6 +501,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -628,6 +632,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -780,6 +785,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -961,6 +967,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), @@ -1145,6 +1152,7 @@ describe('main bootstrap preview behavior', () => { vi.doMock('../../src/main/ipc', () => ({ registerIpcHandlers: vi.fn(), + registerEventForwarding: vi.fn(), registerChatHandlers: vi.fn(), initializeChatHandlers: vi.fn(), cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), diff --git a/tests/engine/mcp-view-builder.test.ts b/tests/engine/mcp-view-builder.test.ts new file mode 100644 index 0000000..be52622 --- /dev/null +++ b/tests/engine/mcp-view-builder.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { buildMcpView, type McpViewConfig } from '../../src/main/engine/mcp-view-builder'; + +describe('mcp-view-builder', () => { + describe('buildMcpView', () => { + const minimalConfig: McpViewConfig = { + title: 'Test View', + waitingMessage: 'Waiting for data...', + renderBody: ` + document.getElementById("review").innerHTML = "

Hello

"; + `, + acceptLabel: 'Accept', + discardLabel: 'Discard', + }; + + it('returns a valid HTML document', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('sets the page title', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('Test View'); + }); + + it('contains the waiting message', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('Waiting for data...'); + }); + + it('contains the App import from ext-apps', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); + expect(html).toContain('new App('); + }); + + it('contains accept and discard proposal handlers', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('window.acceptProposal'); + expect(html).toContain('window.discardProposal'); + }); + + it('uses custom button labels in renderBody', () => { + const config: McpViewConfig = { + ...minimalConfig, + renderBody: ` + document.getElementById("review").innerHTML = \` +
+ + +
+ \`;`, + }; + const html = buildMcpView(config); + expect(html).toContain('onclick="acceptProposal()"'); + expect(html).toContain('onclick="discardProposal()"'); + }); + + it('calls accept_proposal and discard_proposal tools via app bridge', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('app.callServerTool'); + expect(html).toContain('"accept_proposal"'); + expect(html).toContain('"discard_proposal"'); + }); + + it('contains the custom renderBody code', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('document.getElementById("review").innerHTML = "

Hello

"'); + }); + + it('uses module script type', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('type="module"'); + }); + + it('connects the App on load', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('app.connect()'); + }); + + it('has a status display element', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('id="status"'); + expect(html).toContain('showStatus'); + }); + + it('disables buttons during action', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('setButtonsDisabled(true)'); + }); + + it('uses XSS-safe escaping function', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('function esc('); + expect(html).toContain('document.createElement("div")'); + }); + + it('renders tool result data via ontoolresult handler', () => { + const html = buildMcpView(minimalConfig); + expect(html).toContain('app.ontoolresult'); + expect(html).toContain('renderReview'); + }); + + it('includes extra CSS when provided', () => { + const config: McpViewConfig = { + ...minimalConfig, + extraCss: '.custom-class { color: red; }', + }; + const html = buildMcpView(config); + expect(html).toContain('.custom-class { color: red; }'); + }); + + it('includes extra JS helpers when provided', () => { + const config: McpViewConfig = { + ...minimalConfig, + extraJsHelpers: 'function fmt(v) { return String(v); }', + }; + const html = buildMcpView(config); + expect(html).toContain('function fmt(v) { return String(v); }'); + }); + + it('does not include extra CSS/JS when not provided', () => { + const html = buildMcpView(minimalConfig); + // The shared CSS is always present; just ensure no extra markers + expect(html).not.toContain('function fmt('); + }); + }); +}); diff --git a/tests/engine/mcp-views.test.ts b/tests/engine/mcp-views.test.ts index 3fb95af..bfdc9c4 100644 --- a/tests/engine/mcp-views.test.ts +++ b/tests/engine/mcp-views.test.ts @@ -1,68 +1,40 @@ import { describe, it, expect } from 'vitest'; -import path from 'path'; import { reviewPostHtml, reviewScriptHtml, reviewTemplateHtml, reviewMetadataHtml, - resolveMcpViewsDirs, - loadViewHtml, } from '../../src/main/engine/mcp-views'; -const viewOpts = { - moduleDir: path.resolve(__dirname, '../../src/main/engine'), -}; - describe('mcp-views', () => { - describe('resolveMcpViewsDirs', () => { - it('returns candidate directories', () => { - const dirs = resolveMcpViewsDirs(viewOpts); - expect(dirs.length).toBeGreaterThanOrEqual(2); - expect(dirs.some(d => d.includes('mcp-views'))).toBe(true); - }); - }); - - describe('loadViewHtml', () => { - it('loads an existing view file', () => { - const html = loadViewHtml('review-post.html', viewOpts); - expect(html).toContain(''); - }); - - it('throws for a non-existent view', () => { - expect(() => loadViewHtml('does-not-exist.html', viewOpts)).toThrow( - /not found/, - ); - }); - }); - describe('reviewPostHtml', () => { it('returns valid HTML document', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); expect(html).toContain('new App('); }); it('contains accept and discard buttons', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('calls accept_proposal and discard_proposal tools via app bridge', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('app.callServerTool'); expect(html).toContain('"accept_proposal"'); expect(html).toContain('"discard_proposal"'); }); it('contains post-specific UI elements', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('Review Post'); expect(html).toContain('Publish'); expect(html).toContain('badge-draft'); @@ -70,13 +42,13 @@ describe('mcp-views', () => { }); it('renders tool result data via ontoolresult handler', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('app.ontoolresult'); expect(html).toContain('renderReview'); }); it('uses XSS-safe escaping function', () => { - const html = reviewPostHtml(viewOpts); + const html = reviewPostHtml(); expect(html).toContain('function esc('); expect(html).toContain('document.createElement("div")'); }); @@ -84,24 +56,24 @@ describe('mcp-views', () => { describe('reviewScriptHtml', () => { it('returns valid HTML document', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); }); it('contains accept and discard buttons', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('contains script-specific UI elements', () => { - const html = reviewScriptHtml(viewOpts); + const html = reviewScriptHtml(); expect(html).toContain('Review Script'); expect(html).toContain('Create Script'); expect(html).toContain('Python Code'); @@ -110,24 +82,24 @@ describe('mcp-views', () => { describe('reviewTemplateHtml', () => { it('returns valid HTML document', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); }); it('contains accept and discard buttons', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('contains template-specific UI elements', () => { - const html = reviewTemplateHtml(viewOpts); + const html = reviewTemplateHtml(); expect(html).toContain('Review Template'); expect(html).toContain('Create Template'); expect(html).toContain('Liquid Template'); @@ -136,24 +108,24 @@ describe('mcp-views', () => { describe('reviewMetadataHtml', () => { it('returns valid HTML document', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain(''); expect(html).toContain(''); }); it('contains App import from ext-apps', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('@modelcontextprotocol/ext-apps/app-with-deps'); }); it('contains accept and discard buttons', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('acceptProposal()'); expect(html).toContain('discardProposal()'); }); it('contains metadata-diff UI elements', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('Metadata Changes'); expect(html).toContain('Apply Changes'); expect(html).toContain('diff-table'); @@ -162,7 +134,7 @@ describe('mcp-views', () => { }); it('contains diff formatting function', () => { - const html = reviewMetadataHtml(viewOpts); + const html = reviewMetadataHtml(); expect(html).toContain('function fmt('); expect(html).toContain('diff-old'); expect(html).toContain('diff-new'); @@ -171,10 +143,10 @@ describe('mcp-views', () => { describe('shared behavior', () => { const allViews = [ - { name: 'reviewPostHtml', fn: () => reviewPostHtml(viewOpts) }, - { name: 'reviewScriptHtml', fn: () => reviewScriptHtml(viewOpts) }, - { name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml(viewOpts) }, - { name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml(viewOpts) }, + { name: 'reviewPostHtml', fn: () => reviewPostHtml() }, + { name: 'reviewScriptHtml', fn: () => reviewScriptHtml() }, + { name: 'reviewTemplateHtml', fn: () => reviewTemplateHtml() }, + { name: 'reviewMetadataHtml', fn: () => reviewMetadataHtml() }, ]; it.each(allViews)('$name connects the App on load', ({ fn }) => {