diff --git a/.changeset/bounded-agent-team-waits.md b/.changeset/bounded-agent-team-waits.md new file mode 100644 index 00000000000..4a9524c315a --- /dev/null +++ b/.changeset/bounded-agent-team-waits.md @@ -0,0 +1,7 @@ +--- +"@agent-native/core": patch +--- + +Allow bounded, abortable waits for agent task status and results, and keep volatile read-only task checks fresh throughout an agent turn. + +Save sub-agent results within their active task transaction while retaining execution lease checks and atomic rollback. diff --git a/packages/core/src/a2a/task-store-lifecycle.spec.ts b/packages/core/src/a2a/task-store-lifecycle.spec.ts index 98c6937e5e5..bffad24f9c8 100644 --- a/packages/core/src/a2a/task-store-lifecycle.spec.ts +++ b/packages/core/src/a2a/task-store-lifecycle.spec.ts @@ -26,6 +26,7 @@ const dbExec: DbExec = { vi.mock("../db/client.js", () => ({ getDbExec: () => dbExec, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, })); diff --git a/packages/core/src/agent/execution-fence.ts b/packages/core/src/agent/execution-fence.ts index d6da4c1fbff..f85cea11a30 100644 --- a/packages/core/src/agent/execution-fence.ts +++ b/packages/core/src/agent/execution-fence.ts @@ -1,6 +1,10 @@ import { AsyncLocalStorage } from "node:async_hooks"; -import { getDbExec, type DbExec } from "../db/client.js"; +import { + getDbExec, + getScopedTransactionDbExec, + type DbExec, +} from "../db/client.js"; export interface ExecutionFence { threadId: string; @@ -161,6 +165,10 @@ export async function withFencedExecutionWrite( if (!scopedFence) { throw new StaleExecutionFenceError(fence); } + if (getScopedTransactionDbExec() === client) { + await lockAndAssertCurrentExecutionFences(client); + return write(client); + } if (!client.transaction) { throw new Error("Fenced execution writes require transaction support"); } diff --git a/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts b/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts index edf6e8dbc62..1ccd4b9adba 100644 --- a/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts +++ b/packages/core/src/agent/production-agent.approved-tool-gone.spec.ts @@ -1,7 +1,14 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { mockEvent } from "h3"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { closeDbExec } from "../db/client.js"; import { withMigrationRuntime } from "../db/migration-runtime.js"; +import { SCHEMA_SNAPSHOT } from "../db/schema-snapshot.generated.js"; +import { __setSchemaSnapshotForTests } from "../db/store-registry.js"; import { runWithRequestContext } from "../server/request-context.js"; import type { AgentEngine, @@ -25,7 +32,36 @@ vi.mock("./tool-approval-store.js", async (importOriginal) => ({ const { createProductionAgentHandler, toolCallCacheKey } = await import("./production-agent.js"); -beforeAll(() => withMigrationRuntime(ensureRunTables)); +const previousDatabaseUrl = process.env.DATABASE_URL; +let databaseDirectory: string; + +beforeAll(async () => { + await closeDbExec(); + databaseDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "approved-tool-gone-"), + ); + process.env.DATABASE_URL = `pglite:${databaseDirectory}`; + // Retain release-owned inbox schema while leaving the thread store cold. + __setSchemaSnapshotForTests({ + ...SCHEMA_SNAPSHOT, + statements: SCHEMA_SNAPSHOT.statements.filter( + (statement) => !/\bchat_threads\b/.test(statement), + ), + tables: SCHEMA_SNAPSHOT.tables.filter((table) => table !== "chat_threads"), + migrations: SCHEMA_SNAPSHOT.migrations.filter( + ([storeId]) => storeId !== "chat_threads", + ), + }); + await withMigrationRuntime(ensureRunTables); +}); +afterAll(async () => { + __setSchemaSnapshotForTests(undefined); + await closeDbExec(); + if (previousDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = previousDatabaseUrl; + if (databaseDirectory) + fs.rmSync(databaseDirectory, { recursive: true, force: true }); +}); const EMAIL = { to: "customer@example.com", body: "Your refund is approved." }; diff --git a/packages/core/src/agent/production-agent.spec.ts b/packages/core/src/agent/production-agent.spec.ts index 20d1b232635..67fe2e432e0 100644 --- a/packages/core/src/agent/production-agent.spec.ts +++ b/packages/core/src/agent/production-agent.spec.ts @@ -12169,71 +12169,165 @@ describe("runAgentLoop", () => { ); }); - it("does not cache repeated reads when the action opts out of deduping", async () => { - let streamCalls = 0; - const engine: AgentEngine = { - name: "test", - label: "Test", - defaultModel: "test-model", - supportedModels: ["test-model"], - capabilities: { - thinking: false, - promptCaching: false, - vision: false, - computerUse: false, - parallelToolCalls: true, - }, - async *stream(): AsyncIterable { - streamCalls += 1; - if (streamCalls <= 2) { + it.each(["status", "read-result"])( + "keeps bounded task %s waits fresh past the repeat threshold", + async (action) => { + let streamCalls = 0; + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: true, + }, + async *stream(): AsyncIterable { + streamCalls += 1; + if (streamCalls <= 9) { + yield { + type: "assistant-content", + parts: [ + { + type: "tool-call" as const, + id: `poll-${streamCalls}`, + name: "agent-teams", + input: { action, taskId: "task-1", waitMs: 20_000 }, + }, + ], + }; + yield { type: "stop", reason: "tool_use" }; + return; + } + yield { + type: "assistant-content", + parts: [{ type: "text" as const, text: "complete" }], + }; + yield { type: "stop", reason: "end_turn" }; + }, + }; + let polls = 0; + const pollAction = vi.fn(async () => ({ + status: ++polls === 9 ? "complete" : "running", + })); + const events: any[] = []; + + await runAgentLoop({ + engine, + model: "test-model", + systemPrompt: "system", + tools: [], + messages: [{ role: "user", content: [{ type: "text", text: "wait" }] }], + actions: { + "agent-teams": { + ...actionEntry({ + planMode: { + effect: (args) => + args.action === "status" || args.action === "read-result" + ? "read" + : "write", + }, + }), + dedupe: false, + run: pollAction, + }, + }, + send: (event) => events.push(event), + signal: new AbortController().signal, + }); + + expect(pollAction).toHaveBeenCalledTimes(9); + expect(JSON.stringify(events)).not.toContain( + "Skipped duplicate read-only", + ); + const completedPoll = events.find( + (event) => event.type === "tool_done" && event.id === "poll-9", + ); + expect(completedPoll).toBeDefined(); + expect(JSON.parse(completedPoll.result)).toEqual({ status: "complete" }); + expect(events).toContainEqual({ type: "text", text: "complete" }); + }, + ); + + it.each([ + ["export-hubspot-html", { designId: "design-1", frameId: "frame-1" }], + ["agent-teams", { action: "status", taskId: "task-1", waitMs: 0 }], + ["agent-teams", { action: "read-result", taskId: "task-1" }], + ["agent-teams", { action: "list", waitMs: 20_000 }], + ["agent-teams", { action: "status", taskId: "task-1", waitMs: 20_001 }], + ])( + "stops repeated uncached %s reads without a bounded task wait (%j)", + async (name, input) => { + let streamCalls = 0; + const run = vi.fn(async () => "fresh result"); + const events: any[] = []; + const engine: AgentEngine = { + name: "test", + label: "Test", + defaultModel: "test-model", + supportedModels: ["test-model"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + async *stream(options): AsyncIterable { + streamCalls += 1; + if (isLoopBreakerCloseout(options) || streamCalls > 9) { + yield { + type: "assistant-content", + parts: [{ type: "text" as const, text: "Finished." }], + }; + yield { type: "stop", reason: "end_turn" }; + return; + } yield { type: "assistant-content", parts: [ { type: "tool-call" as const, - id: `poll-${streamCalls}`, - name: "poll-run", - input: { id: "run-1" }, + id: `read-${streamCalls}`, + name, + input, }, ], }; yield { type: "stop", reason: "tool_use" }; - return; - } - yield { - type: "assistant-content", - parts: [{ type: "text" as const, text: "complete" }], - }; - yield { type: "stop", reason: "end_turn" }; - }, - }; - const pollAction = vi - .fn() - .mockResolvedValueOnce({ status: "running" }) - .mockResolvedValueOnce({ status: "complete" }); - const events: any[] = []; + }, + }; - await runAgentLoop({ - engine, - model: "test-model", - systemPrompt: "system", - tools: [], - messages: [{ role: "user", content: [{ type: "text", text: "wait" }] }], - actions: { - "poll-run": { - ...actionEntry({ readOnly: true }), - dedupe: false, - run: pollAction, + await runAgentLoop({ + engine, + model: "test-model", + systemPrompt: "system", + tools: [], + messages: [ + { role: "user", content: [{ type: "text", text: "check" }] }, + ], + actions: { + [name]: { ...actionEntry({ readOnly: true }), dedupe: false, run }, }, - }, - send: (event) => events.push(event), - signal: new AbortController().signal, - }); + send: (event) => events.push(event), + signal: new AbortController().signal, + }); - expect(pollAction).toHaveBeenCalledTimes(2); - expect(JSON.stringify(events)).not.toContain("Skipped duplicate read-only"); - expect(events).toContainEqual({ type: "text", text: "complete" }); - }); + expect(run).toHaveBeenCalledTimes(MAX_IDENTICAL_TOOL_CALLS); + expect(events.filter((event) => event.type === "tool_done")).toHaveLength( + MAX_IDENTICAL_TOOL_CALLS, + ); + expect(events).toContainEqual( + expect.objectContaining({ type: "done", reason: "loop_breaker" }), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ type: "error" }), + ); + }, + ); it("does not treat a suffix collision as the visible cached read result", () => { const contextMessages: EngineMessage[] = [ @@ -13459,96 +13553,6 @@ describe("runAgentLoop", () => { ); }); - it("stops a turn that keeps issuing the same successful tool call with identical arguments", async () => { - let streamCalls = 0; - const run = vi.fn(async () => "same answer every time"); - const engine: AgentEngine = { - name: "test", - label: "Test", - defaultModel: "test-model", - supportedModels: ["test-model"], - capabilities: { - thinking: false, - promptCaching: false, - vision: false, - computerUse: false, - parallelToolCalls: false, - }, - async *stream(options: EngineStreamOptions): AsyncIterable { - streamCalls += 1; - if (isLoopBreakerCloseout(options)) { - yield { - type: "assistant-content", - parts: [ - { - type: "text" as const, - text: "Stopped after eight repeated reads; nothing remains.", - }, - ], - }; - yield { type: "stop", reason: "end_turn" }; - return; - } - yield { - type: "assistant-content", - parts: [ - { - type: "tool-call" as const, - id: `tool-loop-${streamCalls}`, - name: "spinning-read", - input: { q: "same" }, - }, - ], - }; - yield { type: "stop", reason: "tool_use" }; - }, - }; - const events: any[] = []; - - await runAgentLoop({ - engine, - model: "test-model", - systemPrompt: "system", - tools: [], - messages: [{ role: "user", content: [{ type: "text", text: "go" }] }], - actions: { - "spinning-read": { - ...actionEntry({ readOnly: true }), - dedupe: false, - run, - }, - }, - send: (event) => events.push(event), - signal: new AbortController().signal, - maxIterations: MAX_IDENTICAL_TOOL_CALLS, - }); - - expect(streamCalls).toBe(MAX_IDENTICAL_TOOL_CALLS + 1); - expect(run).toHaveBeenCalledTimes(MAX_IDENTICAL_TOOL_CALLS); - expect(events).toContainEqual( - expect.objectContaining({ - type: "tool_done", - tool: "spinning-read", - result: "same answer every time", - }), - ); - expect(events).toContainEqual( - expect.objectContaining({ - type: "text", - text: "Stopped after eight repeated reads; nothing remains.", - }), - ); - expect(events).not.toContainEqual( - expect.objectContaining({ type: "error" }), - ); - expect(events).toContainEqual( - expect.objectContaining({ type: "done", reason: "loop_breaker" }), - ); - expect(events).not.toContainEqual( - expect.objectContaining({ type: "loop_limit" }), - ); - }); - it("allows nine identical reads when each successful write changes the deck", async () => { let streamCalls = 0; let reads = 0; diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 5f46e735e86..68cfbf91765 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -6713,6 +6713,23 @@ function intentIsReadOnly(intent: ActionCallIntent | undefined): boolean { return intent?.valid === true && intent.readOnly === true; } +function isBoundedTaskWait(toolName: string, input: unknown): boolean { + if (toolName !== "agent-teams" || !input || typeof input !== "object") { + return false; + } + const args = input as Record; + // Only these operations honor the task reader's bounded wait contract. + return ( + (args.action === "status" || args.action === "read-result") && + typeof args.taskId === "string" && + args.taskId.trim().length > 0 && + typeof args.waitMs === "number" && + Number.isInteger(args.waitMs) && + args.waitMs > 0 && + args.waitMs <= 20_000 + ); +} + function normalizeOptionalToolPlaceholders( schema: RawJsonSchema | undefined, input: unknown, @@ -8721,11 +8738,13 @@ export async function runAgentLoop(opts: { actionEntry && intent?.valid ? actionCallIsRetrySafe(actionEntry, preparedCallArguments(intent)) : false; - const repeatGuardStopFromThisCall = noteRepeatedToolCall( - toolCall.name, - toolCall.input, - toolCall.id, - ); + const repeatGuardStopFromThisCall = + actionIsReadOnly && + actionEntry?.dedupe === false && + intent?.valid === true && + isBoundedTaskWait(toolCall.name, preparedCallArguments(intent)) + ? null + : noteRepeatedToolCall(toolCall.name, toolCall.input, toolCall.id); const rollbackRepeatedToolCall = () => { const repeatKey = toolCallCacheKey(toolCall.name, toolCall.input); const repeatCall = inFlightRepeatToolCalls.get(toolCall.id); @@ -14860,6 +14879,11 @@ export function createProductionAgentHandler( !queuedMessageClaimId && !isBackgroundWorker, ); + const queuedSubmissionStore = shouldPreserveQueuedOrder + ? await import("../chat-threads/store.js") + : undefined; + // The inbox read runs under the slot lock, but its schema must be ready first. + await queuedSubmissionStore?.ensureChatThreadTables(); slot = await tryClaimRunSlot(threadId, runId, undefined, { turnId: effectiveTurnId, ...(turnInitiator ? { turnInitiator } : {}), @@ -14876,13 +14900,13 @@ export function createProductionAgentHandler( ? { dispatchPayload: durableDispatchPayload } : {}), ...(continueOf ? { continueOf } : {}), - ...(shouldPreserveQueuedOrder + ...(queuedSubmissionStore ? { - hasPendingQueuedSubmission: async (tx) => { - const { hasPendingQueuedSubmissionReceipt } = - await import("../chat-threads/store.js"); - return hasPendingQueuedSubmissionReceipt(tx, runThreadId!); - }, + hasPendingQueuedSubmission: (tx) => + queuedSubmissionStore.hasPendingQueuedSubmissionReceipt( + tx, + runThreadId, + ), } : {}), }); diff --git a/packages/core/src/agent/reaped-run-thread.spec.ts b/packages/core/src/agent/reaped-run-thread.spec.ts index 3822cd47cab..2565acaa5f9 100644 --- a/packages/core/src/agent/reaped-run-thread.spec.ts +++ b/packages/core/src/agent/reaped-run-thread.spec.ts @@ -27,6 +27,7 @@ const client = { vi.mock("../db/client.js", () => ({ getDbExec: () => client, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, retryOnDdlRace: (fn: () => any) => fn(), })); diff --git a/packages/core/src/agent/run-store.foreground-self-chain.spec.ts b/packages/core/src/agent/run-store.foreground-self-chain.spec.ts index b9a0ebfd259..7cf3af9a1b8 100644 --- a/packages/core/src/agent/run-store.foreground-self-chain.spec.ts +++ b/packages/core/src/agent/run-store.foreground-self-chain.spec.ts @@ -41,6 +41,7 @@ rawClient.transaction = async ( vi.mock("../db/client.js", () => ({ getDbExec: () => rawClient, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, retryOnDdlRace: (fn: () => any) => fn(), })); diff --git a/packages/core/src/agent/run-store.reconcile-convergence.spec.ts b/packages/core/src/agent/run-store.reconcile-convergence.spec.ts index 6518a071364..cfca04b2404 100644 --- a/packages/core/src/agent/run-store.reconcile-convergence.spec.ts +++ b/packages/core/src/agent/run-store.reconcile-convergence.spec.ts @@ -39,6 +39,7 @@ client.transaction = async (run: (tx: any) => Promise) => { vi.mock("../db/client.js", () => ({ getDbExec: () => client, + getScopedTransactionDbExec: () => undefined, isProductionServerlessFunctionRuntime: () => false, retryOnDdlRace: (fn: () => any) => fn(), })); diff --git a/packages/core/src/agent/run-store.spec.ts b/packages/core/src/agent/run-store.spec.ts index 43fd7f52881..85aab2763d0 100644 --- a/packages/core/src/agent/run-store.spec.ts +++ b/packages/core/src/agent/run-store.spec.ts @@ -330,6 +330,7 @@ const mockCaptureError = vi.fn(); vi.mock("../db/client.js", () => ({ getDbExec: () => mockDb, + getScopedTransactionDbExec: () => undefined, })); vi.mock("../db/ddl-guard.js", () => ({ diff --git a/packages/core/src/db/client.ts b/packages/core/src/db/client.ts index f4f03c95bc8..05aaefcae94 100644 --- a/packages/core/src/db/client.ts +++ b/packages/core/src/db/client.ts @@ -2310,16 +2310,32 @@ export function annotateMissingTable(err: unknown, sql: unknown): unknown { } const scopedDbExec = new AsyncLocalStorage(); -const transactionCallbacks = new AsyncLocalStorage<{ active: boolean }>(); +const transactionCallbacks = new AsyncLocalStorage<{ + active: boolean; + executors: WeakSet; +}>(); export function withDbExec(exec: DbExec, run: () => T): T { - return scopedDbExec.run(guardSchemaMutations(exec), run); + const guarded = guardSchemaMutations(exec); + const callback = transactionCallbacks.getStore(); + if (callback?.active && callback.executors.has(exec)) { + callback.executors.add(guarded); + } + return scopedDbExec.run(guarded, run); } export function getScopedDbExec(): DbExec | undefined { return scopedDbExec.getStore(); } +export function getScopedTransactionDbExec(): DbExec | undefined { + const scoped = getScopedDbExec(); + const callback = transactionCallbacks.getStore(); + return scoped && callback?.active && callback.executors.has(scoped) + ? scoped + : undefined; +} + /** * True while a `getDbExec().transaction()` callback runs. That callback leaves * `getScopedDbExec()` unset, so code that must not run inside a caller's @@ -2329,8 +2345,11 @@ export function insideDbTransactionCallback(): boolean { return transactionCallbacks.getStore()?.active === true; } -async function inTransactionCallback(run: () => Promise): Promise { - const callback = { active: true }; +async function inTransactionCallback( + exec: DbExec, + run: () => Promise, +): Promise { + const callback = { active: true, executors: new WeakSet([exec]) }; try { return await transactionCallbacks.run(callback, run); } finally { @@ -2405,21 +2424,27 @@ export function getDbExec(): DbExec { throw new Error("This database does not support transactions."); } return currentExec.transaction((tx) => - inTransactionCallback(() => - fn({ - execute: (s) => { - assertSchemaMutationAllowed(s); - return tx.execute(sanitize(s)); - }, - transaction: tx.transaction?.bind(tx), - }), - ), + runTransactionCallback(tx, fn), ); } : undefined, }; } + function runTransactionCallback( + tx: DbExec, + fn: (tx: DbExec) => Promise, + ): Promise { + const exec: DbExec = { + execute: (s) => { + assertSchemaMutationAllowed(s); + return tx.execute(sanitize(s)); + }, + transaction: tx.transaction?.bind(tx), + }; + return inTransactionCallback(exec, () => fn(exec)); + } + const proxy: DbExec = { async execute(sql) { assertSchemaMutationAllowed(sql); @@ -2431,17 +2456,7 @@ export function getDbExec(): DbExec { const exec = await initializeProxy(); Object.assign(proxy, createInitializedProxy(exec)); if (exec.transaction) { - return exec.transaction((tx) => - inTransactionCallback(() => - fn({ - execute: (s) => { - assertSchemaMutationAllowed(s); - return tx.execute(sanitize(s)); - }, - transaction: tx.transaction?.bind(tx), - }), - ), - ); + return exec.transaction((tx) => runTransactionCallback(tx, fn)); } if (exec.atomicBatch) { throw new Error( @@ -2450,7 +2465,7 @@ export function getDbExec(): DbExec { } const wrapper = createInitializedProxy(exec); return explicitTransaction(wrapper.execute.bind(wrapper))((tx) => - inTransactionCallback(() => fn(tx)), + inTransactionCallback(tx, () => fn(tx)), ); }, async atomicBatch(statements) { diff --git a/packages/core/src/jobs/run-history.spec.ts b/packages/core/src/jobs/run-history.spec.ts index 86293f1273b..791f25d867d 100644 --- a/packages/core/src/jobs/run-history.spec.ts +++ b/packages/core/src/jobs/run-history.spec.ts @@ -17,6 +17,7 @@ vi.mock("../db/client.js", async () => { }; return { getDbExec: () => db, + getScopedTransactionDbExec: () => undefined, }; }); diff --git a/packages/core/src/server/agent-chat/browser-team-tools.spec.ts b/packages/core/src/server/agent-chat/browser-team-tools.spec.ts index 20ae4953382..60a604ea138 100644 --- a/packages/core/src/server/agent-chat/browser-team-tools.spec.ts +++ b/packages/core/src/server/agent-chat/browser-team-tools.spec.ts @@ -1,7 +1,13 @@ import { createHash } from "node:crypto"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentEngine } from "../../agent/engine/types.js"; +import { + actionsToEngineTools, + runAgentLoop, +} from "../../agent/production-agent.js"; +import { runWithRequestContext } from "../request-context.js"; import { createBuilderBrowserTool, createTeamTools, @@ -233,6 +239,200 @@ describe("createTeamTools Plan-mode effects", () => { }); }); +describe("createTeamTools bounded task reads", () => { + const task = { + taskId: "task-1", + threadId: "child-1", + parentThreadId: "parent-1", + status: "running", + description: "Review email", + summary: "", + preview: "Inspecting", + }; + function teamTool() { + return createTeamTools({ + getOwner: () => "owner@example.test", + getSystemPrompt: () => "", + getActions: () => ({}), + getEngine: () => ({}) as AgentEngine, + getModel: () => "test-model", + getParentThreadId: () => "parent-1", + getSend: () => null, + })["agent-teams"]; + } + beforeEach(() => { + mocks.getTask.mockReset().mockResolvedValue(task); + mocks.getAgentTeamRunDispatchState.mockReset().mockResolvedValue(null); + }); + afterEach(() => { + mocks.getTask.mockReset(); + mocks.getAgentTeamRunDispatchState.mockReset(); + }); + + it("waits for a terminal result and keeps every read in the caller's scope", async () => { + mocks.getTask.mockResolvedValueOnce(task).mockResolvedValue({ + ...task, + status: "completed", + summary: "Reviewed unchanged hash; no issues.", + }); + const result = await runWithRequestContext( + { userEmail: "owner@example.test", orgId: "org-1" }, + () => + teamTool().run({ + action: "read-result", + taskId: "task-1", + waitMs: 1000, + }), + ); + expect(JSON.parse(result)).toMatchObject({ + status: "completed", + summary: "Reviewed unchanged hash; no issues.", + }); + expect(mocks.getTask.mock.calls).toEqual([ + ["task-1", { ownerEmail: "owner@example.test", orgId: "org-1" }], + ["task-1", { ownerEmail: "owner@example.test", orgId: "org-1" }], + ]); + }); + + it("returns an honest queued snapshot when the wait expires", async () => { + mocks.getAgentTeamRunDispatchState.mockResolvedValue({ status: "queued" }); + const result = JSON.parse( + await teamTool().run({ + action: "read-result", + taskId: "task-1", + waitMs: 20, + }), + ); + expect(result).toMatchObject({ status: "queued", taskId: "task-1" }); + expect(result).not.toHaveProperty("summary"); + expect(mocks.getTask).toHaveBeenCalledTimes(2); + }); + + it("does not wait or expose queue state for a foreign or missing task", async () => { + mocks.getTask.mockResolvedValue(undefined); + expect( + JSON.parse( + await teamTool().run({ + action: "status", + taskId: "foreign", + waitMs: 20000, + }), + ), + ).toEqual({ error: "Task not found" }); + expect(mocks.getTask).toHaveBeenCalledTimes(1); + expect(mocks.getAgentTeamRunDispatchState).not.toHaveBeenCalled(); + }); + + it("preserves an explicit personal scope instead of falling back to the ambient organization", async () => { + mocks.getTask.mockResolvedValue(undefined); + await runWithRequestContext( + { userEmail: "owner@example.test", orgId: "org-1" }, + () => + teamTool().run( + { action: "status", taskId: "task-1" }, + { caller: "agent", orgId: null }, + ), + ); + expect(mocks.getTask).toHaveBeenCalledWith("task-1", { + ownerEmail: "owner@example.test", + orgId: null, + }); + }); + + it("aborts an active wait without another task read", async () => { + const controller = new AbortController(); + const waiting = teamTool().run( + { action: "status", taskId: "task-1", waitMs: 1000 }, + { caller: "agent", signal: controller.signal }, + ); + const outcome = waiting.then( + () => null, + (error: unknown) => error, + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.abort(); + expect(await outcome).toBeInstanceOf(Error); + expect(mocks.getTask).toHaveBeenCalledTimes(1); + }); + + it.each([-1, 20001, 1.5, "1000"])( + "rejects invalid wait bounds (%s)", + async (waitMs) => { + await expect( + teamTool().run({ action: "status", taskId: "task-1", waitMs }), + ).rejects.toThrow("waitMs"); + expect(mocks.getTask).not.toHaveBeenCalled(); + }, + ); + + it("observes changed task status across identical bounded waits in one agent turn", async () => { + let calls = 0; + mocks.getTask.mockImplementation(async () => + calls < 10 ? task : { ...task, status: "completed", summary: "Clear" }, + ); + const entry = teamTool(); + const observed: string[] = []; + const engine: AgentEngine = { + name: "test", + label: "Test", + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + defaultModel: "test-model", + supportedModels: ["test-model"], + async *stream(opts) { + const last = opts.messages[opts.messages.length - 1]; + for (const part of last?.content ?? []) + if (part.type === "tool-result") observed.push(part.content); + if (++calls <= 10) { + yield { + type: "assistant-content", + parts: [ + { + type: "tool-call", + id: `status-${calls}`, + name: "agent-teams", + input: { action: "status", taskId: "task-1", waitMs: 1 }, + }, + ], + }; + yield { type: "stop", reason: "tool_use" }; + } else { + yield { + type: "assistant-content", + parts: [{ type: "text", text: "Review complete." }], + }; + yield { type: "stop", reason: "end_turn" }; + } + }, + }; + await runAgentLoop({ + engine, + model: "test-model", + systemPrompt: "Observe task state", + tools: actionsToEngineTools({ "agent-teams": entry }), + messages: [ + { + role: "user", + content: [{ type: "text", text: "Wait until the task completes" }], + }, + ], + actions: { "agent-teams": entry }, + send: () => {}, + signal: new AbortController().signal, + }); + expect(mocks.getTask.mock.calls.length).toBeGreaterThanOrEqual(10); + expect(observed.map((content) => JSON.parse(content).status)).toEqual([ + ...Array(9).fill("running"), + "completed", + ]); + }); +}); + describe("createTeamTools progress cards", () => { it("projects only task progress results from visible coordination actions", async () => { const entry = createTeamTools({ diff --git a/packages/core/src/server/agent-chat/browser-team-tools.ts b/packages/core/src/server/agent-chat/browser-team-tools.ts index 3692cba3a66..05775dd378a 100644 --- a/packages/core/src/server/agent-chat/browser-team-tools.ts +++ b/packages/core/src/server/agent-chat/browser-team-tools.ts @@ -1,9 +1,11 @@ import { createHash } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; import { ACTION_CHAT_UI_AGENT_TEAM_PROGRESS_RENDERER, normalizeAgentTeamProgressResult, } from "../../action-ui.js"; +import { fail, type ActionRunContext } from "../../action.js"; import type { AgentEngine } from "../../agent/engine/types.js"; import type { ActionEntry } from "../../agent/production-agent.js"; import { getActiveFileUploadProviderForRequest } from "../../file-upload/registry.js"; @@ -16,7 +18,7 @@ import { getBuilderBrowserConnectUrlForOwner, resolveBuilderBranchProjectId, } from "../builder-browser.js"; -import { getRequestUserEmail } from "../request-context.js"; +import { getRequestOrgId, getRequestUserEmail } from "../request-context.js"; import { getMcpManagerForCurrentRequest } from "./mcp-glue.js"; const MAX_EXTENSION_PROMOTION_CONTENT_CHARS = 200_000; @@ -494,8 +496,40 @@ export function createTeamTools(deps: { | ((event: import("../../agent/types.js").AgentChatEvent) => void) | null; }): Record { + const readTask = async ( + taskId: string, + waitMs: number, + context?: ActionRunContext, + ) => { + if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 20_000) + fail("waitMs must be an integer between 0 and 20000.", { + errorCode: "invalid_input", + statusCode: 422, + }); + const scope = { + ownerEmail: deps.getOwner(), + orgId: + context?.orgId !== undefined + ? context.orgId + : (getRequestOrgId() ?? null), + }; + const signal = context?.signal; + signal?.throwIfAborted(); + const { getTask } = await import("../agent-teams.js"); + const deadline = Date.now() + waitMs; + let task = await getTask(taskId, scope); + signal?.throwIfAborted(); + while (task?.status === "running" && Date.now() < deadline) { + await delay(Math.min(500, deadline - Date.now()), undefined, { signal }); + signal?.throwIfAborted(); + task = await getTask(taskId, scope); + signal?.throwIfAborted(); + } + return task; + }; return { "agent-teams": { + dedupe: false, tool: { description: "Manage background tasks that run in their own task thread. Use action 'spawn' to start one, 'status' to check progress, 'read-result' to get a finished task's output, 'send' to message a running task, or 'list' to see all tasks. A successful spawn only means the task started and is running; do not report it as finished until status/read-result shows a terminal status. This is a background task, not a source-control branch.", @@ -536,6 +570,13 @@ export function createTeamTools(deps: { type: "string", description: "(send) Message to send to the sub-agent", }, + waitMs: { + type: "integer", + minimum: 0, + maximum: 20000, + description: + "(status, read-result) Wait up to this many milliseconds for a terminal task. Defaults to 0 (immediate snapshot). A deadline can return queued/running; it does not mean completion or resume the parent automatically.", + }, }, required: ["action"], }, @@ -560,7 +601,19 @@ export function createTeamTools(deps: { description: "Plan mode allows listing tasks and reading their status or results.", }, - run: async (args: Record) => { + run: async ( + args: { + action: string; + task?: string; + instructions?: string; + name?: string; + agent?: string; + taskId?: string; + message?: string; + waitMs?: number; + }, + context?: ActionRunContext, + ) => { const action = args.action; if (action === "spawn") { @@ -627,8 +680,11 @@ export function createTeamTools(deps: { if (action === "status") { if (!args.taskId) throw new Error("'taskId' is required for status"); - const { getTask } = await import("../agent-teams.js"); - const task = await getTask(args.taskId); + const task = await readTask( + args.taskId, + args.waitMs === undefined ? 0 : args.waitMs, + context, + ); if (!task) return JSON.stringify({ error: "Task not found" }); return JSON.stringify({ taskId: task.taskId, @@ -646,8 +702,11 @@ export function createTeamTools(deps: { if (action === "read-result") { if (!args.taskId) throw new Error("'taskId' is required for read-result"); - const { getTask } = await import("../agent-teams.js"); - const task = await getTask(args.taskId); + const task = await readTask( + args.taskId, + args.waitMs === undefined ? 0 : args.waitMs, + context, + ); if (!task) return JSON.stringify({ error: "Task not found" }); if (task.status === "running") { return JSON.stringify({ diff --git a/packages/core/src/server/agent-teams-fenced-write.pglite.spec.ts b/packages/core/src/server/agent-teams-fenced-write.pglite.spec.ts new file mode 100644 index 00000000000..3404253d679 --- /dev/null +++ b/packages/core/src/server/agent-teams-fenced-write.pglite.spec.ts @@ -0,0 +1,178 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + StaleExecutionFenceError, + withExecutionFence, + withFencedExecutionWrite, + type ExecutionFence, +} from "../agent/execution-fence.js"; +import { claimTaskExecutionLease } from "../agent/run-store.js"; +import { closeDbExec, getDbExec, withDbExec } from "../db/client.js"; +import { + claimAgentTeamRun, + completeAgentTeamRun, + enqueueAgentTeamRun, + getAgentTeamRunDispatchState, + withCurrentAgentTeamRunAttempt, +} from "./agent-teams-run-queue.js"; + +let previousDatabaseUrl: string | undefined; + +beforeAll(async () => { + previousDatabaseUrl = process.env.DATABASE_URL; + process.env.DATABASE_URL = "pglite:memory"; + await closeDbExec(); + await getDbExec().execute( + "CREATE TABLE review_results (task_id TEXT PRIMARY KEY, result TEXT NOT NULL)", + ); +}); + +afterAll(async () => { + await closeDbExec(); + if (previousDatabaseUrl === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = previousDatabaseUrl; +}); + +async function taskAttempt(taskId: string) { + const threadId = `thread-${taskId}`; + const turnId = `turn-${taskId}`; + const runId = `run-${taskId}`; + await enqueueAgentTeamRun({ + taskId, + threadId, + runId, + payload: { description: "Read-only email review", turnId }, + }); + const claimed = await claimAgentTeamRun(taskId); + if (!claimed) throw new Error("Task attempt was not claimed"); + const fence = await claimTaskExecutionLease(threadId, turnId, runId); + return { attempts: claimed.attempts, fence }; +} + +function saveResult(taskId: string, fence: ExecutionFence) { + return withFencedExecutionWrite( + (tx) => + tx.execute({ + sql: "INSERT INTO review_results (task_id, result) VALUES (?, ?)", + args: [taskId, "Reviewed the current email"], + }), + { threadId: fence.threadId }, + ); +} + +async function results(taskId: string) { + return ( + await getDbExec().execute({ + sql: "SELECT result FROM review_results WHERE task_id = ?", + args: [taskId], + }) + ).rows; +} + +describe("fenced writes inside an Agent Teams attempt transaction", () => { + it("commits the result and terminal task state together", async () => { + const taskId = "review-commit"; + const { attempts, fence } = await taskAttempt(taskId); + const outcome = await withExecutionFence(fence, () => + withCurrentAgentTeamRunAttempt(taskId, attempts, async () => { + await saveResult(taskId, fence); + return completeAgentTeamRun(taskId, "done", attempts); + }), + ); + expect(outcome).toEqual({ current: true, value: true }); + expect(await results(taskId)).toEqual([ + { result: "Reviewed the current email" }, + ]); + expect((await getAgentTeamRunDispatchState(taskId))?.status).toBe("done"); + }); + + it("rolls back the result and terminal state when the caller fails", async () => { + const taskId = "review-rollback"; + const { attempts, fence } = await taskAttempt(taskId); + await expect( + withExecutionFence(fence, () => + withCurrentAgentTeamRunAttempt(taskId, attempts, async () => { + await saveResult(taskId, fence); + await completeAgentTeamRun(taskId, "done", attempts); + throw new Error("Result projection failed"); + }), + ), + ).rejects.toThrow("Result projection failed"); + expect(await results(taskId)).toEqual([]); + expect((await getAgentTeamRunDispatchState(taskId))?.status).toBe( + "running", + ); + }); + + it("checks every inherited fence before reusing the transaction", async () => { + const taskId = "review-stale-parent"; + const { attempts, fence } = await taskAttempt(taskId); + const parent = await claimTaskExecutionLease( + "parent-thread", + "parent-turn", + "parent-run", + ); + await getDbExec().execute({ + sql: "UPDATE agent_execution_leases SET lease_expires_at = clock_timestamp() - INTERVAL '1 millisecond' WHERE thread_id = ? AND turn_id = ?", + args: [parent.threadId, parent.turnId], + }); + await claimTaskExecutionLease( + "parent-thread", + "parent-turn", + "new-parent-run", + ); + await expect( + withExecutionFence(parent, () => + withExecutionFence(fence, () => + withCurrentAgentTeamRunAttempt(taskId, attempts, () => + saveResult(taskId, fence), + ), + ), + ), + ).rejects.toBeInstanceOf(StaleExecutionFenceError); + expect(await results(taskId)).toEqual([]); + }); + + it("does not borrow an active transaction for an unrelated scoped executor", async () => { + const taskId = "review-unrelated-scope"; + const { fence } = await taskAttempt(taskId); + let queries = 0; + await getDbExec().transaction!(async () => { + await expect( + withDbExec( + { + execute: async () => { + queries += 1; + return { rows: [], rowsAffected: 0 }; + }, + }, + () => withExecutionFence(fence, () => saveResult(taskId, fence)), + ), + ).rejects.toThrow("Fenced execution writes require transaction support"); + }); + expect(queries).toBe(0); + expect(await results(taskId)).toEqual([]); + }); + + it("does not reuse a scoped transaction after its callback has settled", async () => { + const taskId = "review-detached"; + const { fence } = await taskAttempt(taskId); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let detached!: Promise; + await getDbExec().transaction!(async (tx) => { + withDbExec(tx, () => { + detached = gate.then(() => + withExecutionFence(fence, () => saveResult(taskId, fence)), + ); + }); + }); + release(); + await expect(detached).rejects.toThrow( + "Fenced execution writes require transaction support", + ); + expect(await results(taskId)).toEqual([]); + }); +}); diff --git a/packages/core/src/server/agent-teams-run-queue.spec.ts b/packages/core/src/server/agent-teams-run-queue.spec.ts index 1e939767df8..ac58291c56b 100644 --- a/packages/core/src/server/agent-teams-run-queue.spec.ts +++ b/packages/core/src/server/agent-teams-run-queue.spec.ts @@ -309,6 +309,7 @@ const mockDb = { vi.mock("../db/client.js", () => ({ getDbExec: () => mockDb, + getScopedTransactionDbExec: () => undefined, withDbExec: (_exec: unknown, fn: () => unknown) => fn(), retryOnDdlRace: (fn: () => unknown) => fn(), })); diff --git a/packages/core/src/server/agents-bundle.spec.ts b/packages/core/src/server/agents-bundle.spec.ts index 1111a4732d4..782d87962d2 100644 --- a/packages/core/src/server/agents-bundle.spec.ts +++ b/packages/core/src/server/agents-bundle.spec.ts @@ -680,8 +680,11 @@ describe("readAgentsBundleFromFs", () => { "design-generation", "design-systems", "export-handoff", + "hubspot-email", + "hubspot-email-review", "visual-edit", ]; + const promptBlock = generateSkillsPromptBlock(bundle); for (const name of expected) { const skill = runtimeSkills.find((candidate) => { @@ -689,13 +692,9 @@ describe("readAgentsBundleFromFs", () => { }); expect(skill, `expected runtime skill ${name}`).toBeDefined(); expect(skill!.meta.description.trim()).toContain("Use when"); + expect(promptBlock).toContain(skill!.meta.description.trim()); + expect(promptBlock).toContain(`[skill-${name}]`); } - - const promptBlock = generateSkillsPromptBlock(bundle); - expect(promptBlock).toContain("Generate or refine complete interactive"); - expect(promptBlock).toContain("Export Design work"); - expect(promptBlock).toContain("[skill-design-generation]"); - expect(promptBlock).toContain("[skill-visual-edit]"); }); it("exposes build-an-app to the Chat runtime skill picker", () => { diff --git a/templates/design/.agents/skills/design-generation/SKILL.md b/templates/design/.agents/skills/design-generation/SKILL.md index 7418e45a671..1b3ff222497 100644 --- a/templates/design/.agents/skills/design-generation/SKILL.md +++ b/templates/design/.agents/skills/design-generation/SKILL.md @@ -1,18 +1,24 @@ --- name: design-generation description: >- - Generate or refine complete interactive HTML prototypes in Design. Use when - creating screens, variants, Alpine/Tailwind prototypes, tweaks, or visual - refinements from a prompt or selected design. + Generate or refine Design web prototypes. Use when creating screens, + variants, or applying visual refinements. For HubSpot email requests, + read hubspot-email instead. --- # Design Generation -How to generate complete, interactive HTML prototypes using Alpine.js + Tailwind CSS (via CDN). This is the core skill for the design agent. +Generate complete web prototypes through Design actions. + +## HubSpot emails + +For HubSpot email creation, alignment, token edits, conversion or HTML handoff, +read `hubspot-email` before applying the web workflow below. It owns email-safe +authoring and completion checks, which replace the web stack defaults. ## Technology Stack -Every generated design uses: +Web prototypes use: - **Tailwind CSS v4** — via `@tailwindcss/browser@4` CDN (NOT the old v3 CDN) - **Alpine.js 3.x** — via `alpinejs@3.15.11` CDN with `defer` attribute - **Google Fonts** — for distinctive typography (never Inter/Roboto/Arial) diff --git a/templates/design/.agents/skills/export-handoff/SKILL.md b/templates/design/.agents/skills/export-handoff/SKILL.md index 0ff637cbb67..4514ebadf50 100644 --- a/templates/design/.agents/skills/export-handoff/SKILL.md +++ b/templates/design/.agents/skills/export-handoff/SKILL.md @@ -2,7 +2,7 @@ name: export-handoff description: >- Export Design work and produce developer handoff materials. Use when the user - wants HTML, PNG, SVG, ZIP/code exports, implementation notes, or a coding + wants HubSpot email HTML, PNG, SVG, ZIP/code exports, implementation notes, or a coding handoff for a selected design. --- @@ -62,6 +62,17 @@ How to export designs and generate handoff documentation for developers converti ## Export Formats +### HubSpot email HTML + +`export-hubspot-html` validates any HTML frame and returns `{ html, filename, +imageCount }` for manual import; no special frame type is required and saved +source is unchanged. It inlines supported CSS and validates email markup, +HubL, visible footer text and public images. For target resolution, authoring, +conversion and completion checks, read `hubspot-email`; it is the single email +workflow owner. An `invalid_hubspot_email` result identifies content to repair, +not a missing frame category. Validation and sample browser previews do not +prove HubSpot compilation or inbox rendering. + ### HTML Export Bundles all design files into a single standalone HTML file with Tailwind CSS and Alpine.js CDN included. diff --git a/templates/design/.agents/skills/hubspot-email-review/SKILL.md b/templates/design/.agents/skills/hubspot-email-review/SKILL.md new file mode 100644 index 00000000000..f24c81ed342 --- /dev/null +++ b/templates/design/.agents/skills/hubspot-email-review/SKILL.md @@ -0,0 +1,84 @@ +--- +name: hubspot-email-review +description: >- + Independently review HubSpot email copy, brand, links and responsive previews. + Use when the user explicitly requests an independent review or delegates a + read-only review of converted frames or personalized content. +--- + +# HubSpot Email Review + +Return an independent review of the specified saved email. Read the +`hubspot-email` contract for email requirements, then perform this review-only +workflow. Do not spawn another reviewer. + +## Establish the reviewed source + +Read the assigned target, original content and brand references using +`get-design-snapshot`. Read fresh `get-design-surface-index` results for each +explicit `designId` and `fileId`, with `includeNodes: false` and +`includeReview: false`. Verify `index.source.sourceRef` and compare +`index.source.contentHash` against the expected fingerprints. This value is an +opaque source fingerprint, not a SHA-256 digest or `versionHash`. Missing targets, +missing campaign facts that prevent a judgment, or changed hashes require an +incomplete result, not clearance. + +This assignment authorizes reads and in-memory previews only. Do not edit, +create or delete frames, change settings, publish, send messages, or run a +conversion. Snapshot `nextRequiredAction` or edit hints describe capabilities; +they do not authorize writes. Tool access is inherited, so do not claim these +instructions technically enforce read-only access. + +## Inspect recipient experience + +Call `take-design-screenshot` on the exact target with `templatePreview: +"hubspot-email"` and paired 600px/375px widths. Inspect the returned images and +diagnostics; zero diagnostic counts alone do not establish visual quality. Add +320px for dense cards, long words or personalized content. For personalized +copy or CTA fields, use the action's in-memory `sampleContactValues` array to +check defaults, normal and long/unbroken recipient values. Confirm +`preview.usedContactFields` and `preview.unresolvedTokens`. Never persist sample +values or QA copies. Distinguish raw HubL text in the canvas from resolved +recipient layout; unresolved expressions in the opted-in preview remain a gap. + +Review the source and actual pixels against the campaign facts and references: + +- Recipient copy carries the intended message. Implementation notes, validation + instructions, fake test values and unsupported rendering guarantees do not + belong in the delivered email. +- CTA labels and destinations serve the same purpose. Compare conversions with + original content links and labels. A reachable URL alone is insufficient. + Video/play claims, thumbnails and durations require evidence of that actual + video destination; a static graphic linked to a general landing page is not + a video. Use approved sources or official research to resolve uncertainty. +- Alt text matches the actual visible image, including reused assets. Check for + distorted, broken or misleading graphics and invented brand marks. +- Brand, message, hierarchy and content survive conversion. Compare wordmarks, + palette, typography, image treatment and spacing with the supplied reference; + preserve original business purposes rather than substituting generic links. +- Desktop columns, mobile stacking, reading order, card padding, button labels, + long-word wrapping, readable type and footer remain usable at tested widths. + Check clipping and overflow visually, not only through counts. +- Company information and a visible unsubscribe link remain present. Exported + source retains supported HubL; browser previews use sample values and do not + establish HubSpot compilation or real inbox fidelity. + +Do not repair an issue yourself. Return concrete findings with the affected +copy, URL, image or viewport and the change needed. If capture or a necessary +source read fails, report the missing evidence instead of inferring a pass. + +## Return a source-bound result + +Read fresh surface indexes again for the target and references after inspection. +Any changed fingerprint makes the review stale. Return a concise report with: + +- The reviewed design/file IDs and opaque source fingerprint. +- The reference IDs/roles and whether their fingerprints stayed unchanged. +- Actual inspected widths and recipient scenarios. +- Concrete issues supported by source or pixels, separating defects from + optional polish, or a clear finding when the inspected checks pass. +- Any missing evidence or incomplete checks, including the boundary between + sample browser previews and real HubSpot/inbox testing. + +Do not call an incomplete or stale review clear. Return findings to the parent; +the parent owns requested repairs, revalidation and the user handoff. diff --git a/templates/design/.agents/skills/hubspot-email/SKILL.md b/templates/design/.agents/skills/hubspot-email/SKILL.md new file mode 100644 index 00000000000..8a217000a70 --- /dev/null +++ b/templates/design/.agents/skills/hubspot-email/SKILL.md @@ -0,0 +1,146 @@ +--- +name: hubspot-email +description: >- + Create, convert, personalize, and validate HubSpot marketing email HTML. + Use when creating emails, aligning existing frames with HubSpot, adding + personalization tokens, or exporting HTML, including follow-ups. +--- + +# HubSpot Email + +Use this workflow for HubSpot email requests, including “make sure this aligns +to HubSpot emails” and “add HubSpot tokens.” It replaces the web stack, +interaction, typography, CSS-variable and animation defaults in +`design-generation`. Any HTML frame can export when its content passes +validation; a special frame type or internal marker is not required. + +For a review-only assignment, read `hubspot-email-review` and follow its review +workflow directly. Do not author content or delegate another reviewer. + +Resolve the relevant design and frame(s) from conversation references, frame +names, and current application state. Use `view-screen` for current navigation, +selection and screens, then `get-design-snapshot` for the relevant source. Do +not require manual frame selection when that context identifies the target. +Apply requests about multiple named frames to those frames; ask for a target +only when the available context leaves materially different choices. + +- Create the design if needed, then author complete email HTML with + `create-file` (`fileType: "html"`) or `generate-design` (`devices: []`, a + 600px-wide `canvasFrames` placement). Use optional `frameType: "hubspot-email"` + with `` in the + head to retain email authoring defaults on future edits. Include the leading + HubSpot header comment containing `templateType: email` and + `isAvailableForNewContent: true` so new emails are selectable. The leading + `templateType: email` is required; availability controls template selection + and is not a compilation requirement. +- Build a fluid 600px container using presentation tables with `cellpadding="0"`, + `cellspacing="0"`, explicit widths and table-cell padding. Use inline styles + or simple class rules the exporter can inline. Media-query overrides may + remain for mobile. Use email-safe font fallback stacks matching the brand. + Keep a multi-column desktop layout intact at 600px: put its stacking + breakpoint below 600px. Let long and unbroken words wrap within constrained + table cells instead of widening the email. + Do not include scripts, Tailwind/Alpine, external stylesheets, CSS variables, + flex/grid, absolute positioning, forms, or browser-only interactions. +- Add a nonempty hidden preheader element with `data-email-preheader`. Include + visible company footer text with `{{ site_settings.company_name }}`, + `{{ site_settings.company_street_address_1 }}`, `{{ site_settings.company_city }}`, + and `{{ site_settings.company_state }}`. Include a visible anchor whose href + is exactly `{{ unsubscribe_link }}` for both Unsubscribe and Preferences links; + HubSpot uses it for the subscription preferences page. Do not invent a + `subscription_preferences_url` alias. Optional `unsubscribe_link_single` and + `unsubscribe_link_all` URLs opt out of this email type or all email; + `view_as_page_url` opens the web version. Use the + [official HubL variable reference](https://developers.hubspot.com/docs/cms/reference/hubl/variables). + Keep footer text and a nonempty unsubscribe label visible outside the preheader, + including after inherited/class styles. Prefer readable 14px or larger footer + type with sufficient contrast; 14px is a HubSpot recommendation, not an export + minimum. Preserve these tokens on follow-ups. +- Use `{{ personalization_token('contact.firstname', 'there') }}` for contact + personalization, with a literal default and no filters. This export requires + the function form and rejects direct `contact.*` tokens. HubSpot does not + support personalization filters in email; CMS/blog examples do not establish + email support. The + [official function reference](https://developers.hubspot.com/docs/cms/reference/hubl/functions#personalization-token) + documents the default argument, and the + [filter reference](https://developers.hubspot.com/docs/cms/reference/hubl/filters) + excludes personalization filters in emails. Dynamic CTA defaults must be + actual absolute HTTP(S), mailto, or tel URLs; empty strings, plain text and + script/data URLs are not fallbacks. Preserve HubL in exported HTML, never + fictional customer values. HubL control blocks (`{% ... %}`) are unsupported. +- Verify CTA destinations against the user's approved sources, the referenced + repository, or official product documentation. Confirm the destination serves + the CTA's stated purpose; a plausible-looking or reachable URL is not enough. + Research missing destinations before reporting a remaining user decision; + do not invent a repository or signup URL and call it finished. +- Hero images are optional. Images you include require publicly reachable HTTPS + PNG/JPEG/GIF sources, explicit positive pixel width and height, and alt text + (empty alt only for decorative images). Describe what the rendered image + actually shows; verify inherited alt text against the preview rather than + copying a stale description. Private app/blob URLs, SVGs, data/blob + URLs, relative paths, local assets and CSS background images cannot be handed + off as reliable email assets; obtain a public email-safe asset or omit it. + +To convert an ordinary web frame, use the resolved `designId` and `fileId` to +read its current snapshot. Keep its approved copy, content hierarchy and +branding, and re-author its layout using the email contract above. Save a new +sibling HTML file in the same design with a unique filename; preserve the +source frame. Adding HubSpot tokens or an internal marker alone does not +convert browser layout. Existing email-safe content, including unmarked HTML, +can be refined in place with `edit-design` after a fresh snapshot. Re-read the +saved frame and confirm the original is unchanged after creating a sibling. + +After saving, call `export-hubspot-html` with `id` (design id) and the saved +`fileId`. For a conversion that keeps the original business links, pass +`preserveLinksFromFileId` with the original content frame's ID, not a branding +reference or the converted frame itself. This checks destination preservation; +still review link labels and media meaning against the original content. +Fix reported markup/token/image/link problems and retry until validation +succeeds. Then call `take-design-screenshot` once for that exact `designId` and +`fileId`, with `widths: [600, 375]` and `templatePreview: "hubspot-email"`. +This returns both images for visual inspection from one browser allocation. +This opt-in preview uses clearly fake company values and literal contact defaults +in an in-memory HTML copy; source and exported HTML keep their HubL tokens. +Inspect returned `preview.unresolvedTokens` and resolve unsupported expressions +rather than treating their literal text as final layout. This is sample browser +layout evidence, not HubSpot compilation or inbox-rendering proof. +For personalized content, use `sampleContactValues` on this same preview action +to test normal and deliberately long/unbroken names and populated dynamic CTA +fields. Pass an array such as `[{field: "contact.firstname", value: "Alex"}]`, +with at most 8 unique fields and values up to 500 characters. Each `contact.field` +must match a supported `personalization_token` call in the frame. Omit a +field to inspect its literal default. Check `preview.usedContactFields` and +`preview.unresolvedTokens`. Samples are in-memory only: do not create QA frame +copies just to test recipient variants. Include a 320px preview for complex +layouts or long/unbroken text, then repair the email CSS and repeat the affected +samples before the final default 600/375 pass. +Inspect both returned images and their diagnostics +for overflow, clipped copy, broken or distorted images, and unreadable content; +repair problems and render both widths again after layout changes. A successful +HTML export alone does not complete these visual checks. If rendering is +unavailable, report that check as unfinished instead of calling the email ready. + +## Optional independent review + +After successful export validation and desktop/mobile visual checks, hand off +the HTML with truthful limits. Independent review is optional and runs when the +user explicitly requests it. + +For a requested independent review, use `agent-teams` with a short read-only +review task. Put the target/reference IDs, campaign facts, approved destinations +and fresh `index.source.contentHash` fingerprints from +`get-design-surface-index` in the child instructions. These fingerprints are +opaque values, not SHA-256 digests or `versionHash` values. Ask the reviewer to +read `hubspot-email-review`. Child tool access is inherited; read-only is an +instruction rather than an enforced restriction. + +Use `read-result` with `waitMs: 20000` to await the requested review, continuing +bounded waits while it is queued or running. Read current target/reference +fingerprints again before relying on its findings. Report an incomplete or +stale review honestly; do not present it as a completed review. The parent owns +any requested repairs and repeats export/affected previews after changes. + +Finish concisely with the saved email and verified HTML handoff. Mention actual +remaining gaps or researched CTA decisions; omit unrequested audit/publishing +checklists, canvas coordinates and style inventories. Offer manual import +without claiming HubSpot compilation or inbox fidelity. diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 36e7cf08ed0..208c1c8d761 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -1,34 +1,33 @@ # Design — Agent Guide -Design agents build prototypes, systems, variants, and handoffs through -actions against shared SQL state. +Build prototypes, systems, variants, and handoffs through actions and shared SQL. ## Skills -Read `.agents/skills//SKILL.md` before deeper work: -- `design-generation` — generation, adaptation, and readiness checks. +Read `.agents/skills//SKILL.md` as needed: +- `design-generation` — web prototype generation and readiness. +- `hubspot-email` — email creation, alignment, personalization, conversion and handoff. - `design-templates` — reusing existing Design work. - `responsive-breakpoints` — breakpoint editing. - `design-systems` — tokens, brand extraction, or Figma. - `creative-context` — cross-app sources and governed context. - `design-review-feedback` — persisted review comments. -- `export-handoff` — exports and coding handoffs. +- `export-handoff` — exports, HubSpot email HTML validation, and coding handoffs. - `full-app-build` — fusion-backed app builds. - `native-effects` — WebGPU fills/effects, source edits, and PNG. - `journey-storyboards` — onboarding-journey storyboards. -Also read `.agents/skills//SKILL.md` for shared guides: `actions`, +Shared guides: `actions`, `adding-a-feature`, `storing-data`, `security`, `secrets`, `sharing`, `frontend-design`, `shadcn-ui`, `real-time-sync`, `context-awareness`, `delegate-to-agent`, `agent-native-docs`, `agent-native-toolkit`, `customizing-agent-native`, `client-side-routing`, `reliable-mutations`, `performance`, `external-agents`, `portability`, `self-modifying-code`, -`turn-into-skill`, and `workspace-conventions`. Each guide is at -`.agents/skills//SKILL.md`. +`turn-into-skill`, and `workspace-conventions`. ## Framework Docs -Search local docs with `pnpm action docs-search --query ""`; read by slug with `pnpm action docs-search --slug ""`. +Use `pnpm action docs-search --query ""` or `pnpm action docs-search --slug ""`. ## Actions @@ -43,24 +42,24 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug | `open-visual-edit` | Open localhost screens | | `get-visual-edit-collaboration` / `update-visual-edit-collaboration` | Read/set collaboration opt-in | | `add-localhost-screens` / `update-screen-source` | Add screens; change source mode | -| `add-session-replay-screenshots-to-board` | Add private Analytics replay screenshots to a Design board | -| `stage-journey-canvas-frames` | Stage native PNGs in Design-owned private blob storage in batches | -| `discard-journey-canvas-frame-import` | Discard staged import; queue its private blobs for cleanup | -| `create-journey-canvas` | Create provenance-backed storyboard with stubs, same-recording links, and explicit recording-gap references; no cohort metrics | +| `add-session-replay-screenshots-to-board` | Add private replay screenshots | +| `stage-journey-canvas-frames` | Stage storyboard PNGs | +| `discard-journey-canvas-frame-import` | Discard staged storyboard import | +| `create-journey-canvas` | Create provenance-backed storyboard; see journey-storyboards | | `add-breakpoint` / `remove-breakpoint` | Manage responsive frames | | `edit-design` | Adapt a design/screen | | `apply-visual-edit` | Apply deterministic layer edits | -| `get-motion-timeline` / `apply-motion-edit` / `remove-motion-timeline` | Read, source-sync, and remove editable motion timelines | +| `get-motion-timeline` / `apply-motion-edit` / `remove-motion-timeline` | Read/edit/remove motion timelines | | `create-design` / `generate-design` | Start empty design / generate a fresh screen | | `present-design-variants` | Generate 2–5 variants | | `view-screen` / `navigate` | Read current screen / move UI | -| `get-view-settings` / `update-view-settings` | Read/update view toggles (grid, snap, rulers, cursors, hidden comments) | -| `get-shader` / `edit-native-shader` | Inspect curated native effects; CAS-edit one inline scene | -| `get-native-shader-approvals` | Read viewer-scoped exact effect approvals for accessible Design previews | +| `get-view-settings` / `update-view-settings` | Read/update view toggles | +| `get-shader` / `edit-native-shader` | Read/edit native effects | +| `get-native-shader-approvals` | Read effect approvals | | `prepare-native-scene-export` | Pin scene for PNG | -| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG and inspect attachment | -| `export-png` | Export PNG | -| `export-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Export finished work | +| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Export native PNG | +| `take-design-screenshot` / `export-png` | Inspect responsive images / export PNG | +| `export-html` / `export-hubspot-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Export finished work | ## Core Rules @@ -68,7 +67,7 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug - Use actions for design data and writes; never write design rows directly with SQL. - For external integrations, inspect the workspace/provider connection catalog first; reuse its scoped resolver. - `[Reprompt selection]` is preview-only: only `propose-node-rewrite` may mutate. `[Selection question]` is read-only; answer without content-writing actions. -- Generated files are complete standalone HTML (Alpine.js + Tailwind CDN), rendered without a build step. Follow `design-generation` for its quality/audit pass and `data-agent-native-locked="true"` for locked subtrees. +- Web files use standalone Alpine/Tailwind HTML. HubSpot email requests, including alignment and token edits, follow `hubspot-email`, which replaces web defaults. Preserve `data-agent-native-locked="true"` subtrees. - Source modes are `inline`, `localhost`, and `fusion` (`full-app-build`). `/design/:id` is read-only; `/visual-edit/:id` allows DOM-only localhost edits. Source writes and snapshot publishing require editor access. - `capability:visual-edit` scopes handoff actions. External agents use `get-visual-edit-pending`; browser agents use the page-local tool. @@ -77,7 +76,7 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug - `navigation`: current view, design/file id, and related UI state. - `visual-edit`: last project/connection; same-connection opens resume unless `newDesign` is true. - `navigate`: transient request to move the requesting tab; deleted after consumption. -- `design-selection`: active screen/element, overview, inspector, zoom, screen list, and `layoutGrid`; `motion` carries timeline id, ms playhead/duration, playback mode, auto-keyframe, and selected track/keyframe keys. +- `design-selection`: active screen/element, `activeFrameType` and screen `frameType` (`hubspot-email` or null), overview, inspector, zoom, `layoutGrid`; `motion` holds timeline, playback, auto-keyframe and selection. - `design-generation-session:`, `show-questions`, `guided-questions`: generation planning and variant choice; see `design-generation`. - `design-reprompt-pending::` and `design-reprompt-proposal:::` must be present and matched before `propose-node-rewrite`. @@ -85,4 +84,4 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug Before building common workspace or agent UI, read `agent-native-toolkit`; read `customizing-agent-native` before adapting shared UI. Editor behavior lives in `app/pages/design-editor/commands/*.ts`; read `design-editor-architecture` before changing it. -Search with `rg --hidden --follow`; read the exact linked guide before deeper work. +Search with `rg --hidden --follow`. diff --git a/templates/design/actions/apply-source-edit.interleave.spec.ts b/templates/design/actions/apply-source-edit.interleave.spec.ts index 836d579d7f4..021b53711ee 100644 --- a/templates/design/actions/apply-source-edit.interleave.spec.ts +++ b/templates/design/actions/apply-source-edit.interleave.spec.ts @@ -411,7 +411,9 @@ import { writeInlineSourceFile, } from "../server/source-workspace.js"; import { ensureCodeLayerNodeIdsInHtml } from "../shared/code-layer.js"; +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { sourceContentHash } from "../shared/source-workspace.js"; +import applySourceEditAction from "./apply-source-edit.js"; import updateFileAction from "./update-file.js"; function buildDoc(bodyExtra = ""): string { @@ -479,6 +481,82 @@ beforeEach(() => { }); describe("HTML integrity write boundary", () => { + it("rejects removing an email frame marker through apply-source-edit without changing live or stored content", async () => { + const before = createHubSpotEmailHtml(); + seedFile(before); + await applyText(FILE_ID, before); + const live = await readLiveSourceFile(currentFileRef()); + await expect( + applySourceEditAction.run({ + designId: DESIGN_ID, + fileId: FILE_ID, + expectedVersionHash: live.versionHash, + edit: { + kind: "exact-replace", + search: + '', + replace: "", + }, + }), + ).rejects.toThrow("Preserve the HubSpot email frame marker"); + expect(currentFileRef().content).toBe(before); + expect(getOrCreateDoc(FILE_ID).getText("content").toString()).toBe(before); + }); + + it.each(["shared source", "update-file"])( + "%s rejects a live no-op that would lose the persisted email frame type", + async (writer) => { + const before = createHubSpotEmailHtml(); + seedFile(before); + const unmarked = before.replace( + '', + "", + ); + await applyText(FILE_ID, unmarked); + const live = await readLiveSourceFile(currentFileRef()); + await expect( + writer === "shared source" + ? writeInlineSourceFile({ + designId: DESIGN_ID, + file: currentFileRef(), + content: live.content, + expectedVersionHash: live.versionHash, + }) + : updateFileAction.run({ + id: FILE_ID, + content: live.content, + expectedVersionHash: live.versionHash, + }), + ).rejects.toThrow("Preserve the HubSpot email frame marker"); + expect(currentFileRef().content).toBe(before); + expect(getOrCreateDoc(FILE_ID).getText("content").toString()).toBe( + unmarked, + ); + }, + ); + + it("persists an ordinary source edit through apply-source-edit", async () => { + const before = buildDoc(); + const live = await readLiveSourceFile(currentFileRef()); + const result = await applySourceEditAction.run({ + designId: DESIGN_ID, + fileId: FILE_ID, + expectedVersionHash: live.versionHash, + edit: { + kind: "exact-replace", + search: "Hello world", + replace: "Hello again", + }, + }); + expect(result.changed).toBe(true); + expect(currentFileRef().content).toBe( + before.replace("Hello world", "Hello again"), + ); + expect(getOrCreateDoc(FILE_ID).getText("content").toString()).toBe( + currentFileRef().content, + ); + }); + it("rejects malformed managed-style source and leaves live + SQL content unchanged", async () => { const before = buildDoc(); const live = await readLiveSourceFile(currentFileRef()); diff --git a/templates/design/actions/create-file.spec.ts b/templates/design/actions/create-file.spec.ts index daf9497ba74..ae7863a5a8a 100644 --- a/templates/design/actions/create-file.spec.ts +++ b/templates/design/actions/create-file.spec.ts @@ -5,6 +5,8 @@ import { QueryClient } from "@tanstack/react-query"; import { transformSync } from "esbuild"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; + const mocks = vi.hoisted(() => { let existingRows: Array> = []; let restoreClaimRows: Array> = []; @@ -978,6 +980,36 @@ describe("create-file: canvas placement and landing URL", () => { ); }); + it("persists a semantic HubSpot email at email width and preserves the starter tokens", async () => { + const result = await action.run({ + designId: "design-1", + filename: "welcome-email.html", + content: createHubSpotEmailHtml({ title: "Welcome" }), + fileType: "html", + frameType: "hubspot-email", + }); + expect(result.frameType).toBe("hubspot-email"); + expect(mocks.getDesignData().canvasFrames).toMatchObject({ + [result.id]: { width: 600, height: 800 }, + }); + expect(mocks.insertValues.mock.calls[0]![0].content).toContain( + "{{ unsubscribe_link }}", + ); + }); + + it("rejects an ordinary web page requested as an email frame before writing", async () => { + await expect( + action.run({ + designId: "design-1", + filename: "email.html", + content: "
A web page
", + fileType: "html", + frameType: "hubspot-email", + }), + ).rejects.toThrow("requires { const result = await action.run({ designId: "design-1", diff --git a/templates/design/actions/create-file.ts b/templates/design/actions/create-file.ts index ae918b893b7..47cec742f14 100644 --- a/templates/design/actions/create-file.ts +++ b/templates/design/actions/create-file.ts @@ -26,6 +26,8 @@ import { assertDesignHtmlCreateIntegrity, describeDesignHtmlIntegrityIssue, } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { getResponsiveBreakpointWidths } from "../shared/responsive-frame-layout.js"; import { annotateScreenHtmlForPersist } from "../shared/screen-annotation.js"; import { assertDesignWidgetWriteScope } from "./widget-write-scope.js"; @@ -92,11 +94,17 @@ function screenRestoreSnapshotMatches( export default defineAction({ description: "Add a new file to a design project. Validates that the design exists and " + - "the user has editor access. Returns the new file's ID, filename, and design URL path when the file is renderable.", + "the user has editor access. For HubSpot conversion, save email-safe HTML as a new sibling while preserving its source. Optional frameType=hubspot-email retains email authoring metadata; it is not required for export. Returns the new file's ID, filename, and design URL path when the file is renderable.", schema: z.object({ designId: z.string().describe("Design project ID to add the file to"), filename: z.string().describe("Filename (e.g. 'index.html', 'styles.css')"), content: z.string().describe("File content"), + frameType: z + .literal("hubspot-email") + .optional() + .describe( + "Optional internal email authoring type; when supplied, include the HubSpot email marker in email-safe HTML. Any HTML frame can use export-hubspot-html, which validates actual compatibility.", + ), restoreClaimId: z .string() .optional() @@ -122,6 +130,7 @@ export default defineAction({ designId, filename, content, + frameType, fileType, restoreClaimId, duplicateSourceFileId, @@ -163,6 +172,7 @@ export default defineAction({ const requestedId = nanoid(); const now = new Date().toISOString(); + assertHubSpotEmailFrameContent({ content, fileType, frameType }); const annotatedContent = annotateScreenHtmlForPersist(content, fileType); const restoredContentHashes = screenRestoreContentHashes( annotatedContent, @@ -500,13 +510,36 @@ export default defineAction({ }, }, ), - width: CREATED_SCREEN_WIDTH, - height: CREATED_SCREEN_HEIGHT, + width: isHubSpotEmailHtml(createdFile.content) + ? 600 + : CREATED_SCREEN_WIDTH, + height: isHubSpotEmailHtml(createdFile.content) + ? 800 + : CREATED_SCREEN_HEIGHT, }, ], resolveFileId: (placement) => placement.fileId, }); - return { ...current, canvasFrames: merged.canvasFrames }; + return { + ...current, + canvasFrames: merged.canvasFrames, + ...(isHubSpotEmailHtml(createdFile.content) + ? { + screenMetadata: { + ...(isRecord(current.screenMetadata) + ? current.screenMetadata + : {}), + [id]: { + width: 600, + height: 800, + heightMode: "hug", + heightPinned: false, + breakpointWidths: [], + }, + }, + } + : {}), + }; }, isApplied: (current) => Boolean(parseCanvasFrameGeometryById(current.canvasFrames)[id]), @@ -529,6 +562,9 @@ export default defineAction({ designId, filename, fileType: resolvedFileType, + frameType: isHubSpotEmailHtml(createdFile.content) + ? "hubspot-email" + : null, createdAt: createdFile.createdAt, renderable, _receipt: designOutputWriteReceipt(designId, createdFile.created ? 1 : 0), diff --git a/templates/design/actions/edit-design.ts b/templates/design/actions/edit-design.ts index c068e390377..968b3571781 100644 --- a/templates/design/actions/edit-design.ts +++ b/templates/design/actions/edit-design.ts @@ -171,7 +171,7 @@ export default defineAction({ "from `get-design-snapshot` first, then call `index-design-tokens` and reuse " + "the design's existing tokens so the edited screen matches its siblings; " + "introduce values the design does not already use only when asked. " + - "Use `generate-design` instead only for brand-new files.", + "When refining email HTML, preserve email-safe tables, inline-compatible styles, required HubL footer tokens and any existing internal marker; validate its handoff with export-hubspot-html, which accepts any HTML frame. To rebuild an ordinary frame as a HubSpot email, read it first and use create-file for a new sibling rather than overwriting the source. Use `generate-design` instead only for brand-new files.", schema: z .object({ designId: z.string().describe("Design project ID"), diff --git a/templates/design/actions/export-hubspot-html.test.ts b/templates/design/actions/export-hubspot-html.test.ts new file mode 100644 index 00000000000..c642b78efda --- /dev/null +++ b/templates/design/actions/export-hubspot-html.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; +import { sourceContentHash } from "../shared/source-workspace.js"; + +const mocks = vi.hoisted(() => { + const query = { from: vi.fn(), where: vi.fn(), limit: vi.fn() }; + query.from.mockReturnValue(query); + query.where.mockReturnValue(query); + return { + query, + select: vi.fn(() => query), + resolveAccess: vi.fn(), + exportEmail: vi.fn(), + }; +}); +vi.mock("@agent-native/core/sharing", () => ({ + resolveAccess: mocks.resolveAccess, +})); +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ select: mocks.select }), + schema: { designFiles: { designId: "designId", id: "id" } }, +})); +vi.mock("../server/lib/hubspot-email-export.js", () => ({ + exportHubSpotEmailHtml: mocks.exportEmail, +})); +vi.mock("../server/lib/design-export.js", () => ({ + exportFilename: (title: string, extension: string) => `${title}.${extension}`, +})); + +import exportAction from "./export-hubspot-html.js"; + +describe("export-hubspot-html access and snapshot boundary", () => { + const content = createHubSpotEmailHtml().replace( + '', + "", + ); + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveAccess.mockResolvedValue({ + resource: { title: "Email design" }, + role: "viewer", + }); + mocks.query.limit.mockResolvedValue([ + { + id: "email-file", + filename: "announcement.html", + fileType: "html", + content, + }, + ]); + mocks.exportEmail.mockResolvedValue({ html: "exported", imageCount: 0 }); + }); + + it("denies inaccessible designs before reading child files or verifying image URLs", async () => { + mocks.resolveAccess.mockResolvedValue(null); + await expect( + exportAction.run({ id: "private-design", fileId: "email-file" }), + ).rejects.toThrow("inaccessible"); + expect(mocks.select).not.toHaveBeenCalled(); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }); + + it("rejects an absent or foreign child frame", async () => { + mocks.query.limit.mockResolvedValue([]); + await expect( + exportAction.run({ id: "design", fileId: "foreign-file" }), + ).rejects.toThrow("not found in this design"); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }); + + it("rejects stale editor content before exporting the saved snapshot", async () => { + await expect( + exportAction.run({ + id: "design", + fileId: "email-file", + expectedVersionHash: sourceContentHash(content + "changed"), + }), + ).rejects.toThrow("unsaved or concurrent changes"); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }); + + it("reads the unmarked HTML snapshot unchanged and returns a downloadable filename", async () => { + const result = await exportAction.run({ + id: "design", + fileId: "email-file", + expectedVersionHash: sourceContentHash(content), + }); + expect(mocks.exportEmail).toHaveBeenCalledWith(content); + expect((await mocks.query.limit.mock.results[0].value)[0].content).toBe( + content, + ); + expect(result).toEqual({ + html: "exported", + imageCount: 0, + filename: "announcement-hubspot.html", + }); + }); + + it("checks a distinct original HTML frame before forwarding unchanged snapshots for link preservation", async () => { + const original = 'Invite'; + const sourceFile = { + id: "original-file", + fileType: "html", + content: original, + }; + mocks.query.limit + .mockResolvedValueOnce([ + { id: "email-file", filename: "email.html", fileType: "html", content }, + ]) + .mockResolvedValueOnce([sourceFile]); + await exportAction.run({ + id: "design", + fileId: "email-file", + preserveLinksFromFileId: "original-file", + }); + expect(mocks.exportEmail).toHaveBeenCalledWith(content, { + preserveLinksFromHtml: original, + }); + expect(sourceFile.content).toBe(original); + }); + + it("rejects a trivial self-comparison before exporting", async () => { + await expect( + exportAction.run({ + id: "design", + fileId: "email-file", + preserveLinksFromFileId: "email-file", + }), + ).rejects.toThrowError( + expect.objectContaining({ errorCode: "invalid_hubspot_email" }), + ); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + expect(mocks.query.limit).toHaveBeenCalledTimes(1); + }); + + it.each( + [ + [], + [{ fileType: "css", content: "body{}" }], + [{ fileType: "html", content: "" }], + ].map((sourceRows) => [sourceRows]), + )( + "rejects missing, foreign or unreadable original HTML (%s)", + async (sourceRows) => { + mocks.query.limit + .mockResolvedValueOnce([ + { + id: "email-file", + filename: "email.html", + fileType: "html", + content, + }, + ]) + .mockResolvedValueOnce(sourceRows); + await expect( + exportAction.run({ + id: "design", + fileId: "email-file", + preserveLinksFromFileId: "missing-or-foreign", + }), + ).rejects.toThrow("original HTML content frame"); + expect(mocks.exportEmail).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/templates/design/actions/export-hubspot-html.ts b/templates/design/actions/export-hubspot-html.ts new file mode 100644 index 00000000000..e35934e6919 --- /dev/null +++ b/templates/design/actions/export-hubspot-html.ts @@ -0,0 +1,110 @@ +import { defineAction, fail } from "@agent-native/core/action"; +import { resolveAccess } from "@agent-native/core/sharing"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import { exportFilename } from "../server/lib/design-export.js"; +import { exportHubSpotEmailHtml } from "../server/lib/hubspot-email-export.js"; +import { sourceContentHash } from "../shared/source-workspace.js"; + +export default defineAction({ + description: + "Validate and export any HTML frame as HubSpot email HTML with inline styles, preserved responsive CSS and HubL tokens, and validated public images. Returns html, filename, and imageCount; fails on unsupported browser content, hidden/missing email requirements, or contact personalization filters (use personalization_token with a literal default and no filters). Use preserveLinksFromFileId to check that a converted frame retains its original business link destinations. Read hubspot-email for the complete authoring and handoff workflow. This action validates HTML and images only; it does not establish semantic review, HubSpot compilation, or inbox rendering.", + schema: z.object({ + id: z.string().describe("Design ID containing the HTML frame"), + fileId: z + .string() + .describe( + "File ID of any HTML frame to validate and export; no special frame type is required", + ), + preserveLinksFromFileId: z + .string() + .optional() + .describe( + "Optional original content frame ID in this same design, distinct from fileId. Rejects export when approved source anchor destinations are missing or rewritten, including literal contact URL defaults. Use when converting a frame while retaining its business links; this does not verify link labels or media semantics.", + ), + expectedVersionHash: z + .string() + .optional() + .describe( + "Optional sourceContentHash of the editor's current frame HTML; rejects an unsaved or concurrently changed source with HTTP 409", + ), + }), + readOnly: true, + dedupe: false, + run: async ({ id, fileId, expectedVersionHash, preserveLinksFromFileId }) => { + const access = await resolveAccess("design", id); + if (!access) + fail("Design not found or inaccessible.", { + errorCode: "not_found", + statusCode: 404, + }); + const [file] = await getDb() + .select() + .from(schema.designFiles) + .where( + and( + eq(schema.designFiles.designId, id), + eq(schema.designFiles.id, fileId), + ), + ) + .limit(1); + if (!file || file.fileType !== "html" || !file.content) + fail("The selected HTML frame was not found in this design.", { + errorCode: "not_found", + statusCode: 404, + }); + if ( + expectedVersionHash !== undefined && + expectedVersionHash !== sourceContentHash(file.content) + ) + fail( + "Save your changes before exporting; this frame has unsaved or concurrent changes.", + { errorCode: "hubspot_email_source_changed", statusCode: 409 }, + ); + let sourceHtml: string | undefined; + if (preserveLinksFromFileId !== undefined) { + if (preserveLinksFromFileId === fileId) + fail( + "Choose the distinct original content frame for link preservation, not the exported frame itself.", + { + errorCode: "invalid_hubspot_email", + statusCode: 422, + }, + ); + const [source] = await getDb() + .select() + .from(schema.designFiles) + .where( + and( + eq(schema.designFiles.designId, id), + eq(schema.designFiles.id, preserveLinksFromFileId), + ), + ) + .limit(1); + if (!source || source.fileType !== "html" || !source.content) + fail( + "The original HTML content frame for link preservation was not found in this design.", + { + errorCode: "not_found", + statusCode: 404, + }, + ); + sourceHtml = source.content; + } + const result = + sourceHtml === undefined + ? await exportHubSpotEmailHtml(file.content) + : await exportHubSpotEmailHtml(file.content, { + preserveLinksFromHtml: sourceHtml, + }); + return { + ...result, + filename: exportFilename( + file.filename.replace(/\.html?$/i, "") + "-hubspot", + "html", + ), + }; + }, +}); diff --git a/templates/design/actions/export-png.spec.ts b/templates/design/actions/export-png.spec.ts index e6681144499..699e5c4ff02 100644 --- a/templates/design/actions/export-png.spec.ts +++ b/templates/design/actions/export-png.spec.ts @@ -21,6 +21,12 @@ describe("export-png", () => { true, ); expect(action.schema.safeParse({ fileId: "file_1" }).success).toBe(true); + expect( + action.schema.safeParse({ + fileId: "file_1", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], + }).success, + ).toBe(false); }); it("exports one selected screen through the screenshot renderer", async () => { @@ -39,6 +45,12 @@ describe("export-png", () => { fileId: "file_2", filename: "Quarterly results.html", capturedAt: "2026-09-09T00:00:00.000Z", + preview: { + mode: "hubspot-email", + sampleValues: true, + substitutedTokens: ["{{ site_settings.company_name }}"], + unresolvedTokens: [], + }, screenshots: [ { viewport: { label: "desktop-900", widthPx: 900, heightPx: 600 }, @@ -56,6 +68,8 @@ describe("export-png", () => { filename: "index.html", width: 900, height: 600, + templatePreview: "hubspot-email", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], }, { caller: "mcp" }, ); @@ -66,6 +80,8 @@ describe("export-png", () => { filename: "index.html", widths: [900], heights: [600], + templatePreview: "hubspot-email", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], }, { caller: "mcp" }, ); @@ -78,6 +94,11 @@ describe("export-png", () => { mimeType: "image/png", diagnostics, _agentImages: agentImages, + preview: { + mode: "hubspot-email", + sampleValues: true, + unresolvedTokens: [], + }, }); }); diff --git a/templates/design/actions/export-png.ts b/templates/design/actions/export-png.ts index fc3a3aa8c08..3a8115b5712 100644 --- a/templates/design/actions/export-png.ts +++ b/templates/design/actions/export-png.ts @@ -6,6 +6,10 @@ import { trackDesignExported, withDesignExportFailureTracking, } from "../server/lib/design-export-tracking.js"; +import { + sampleContactValuesSchema, + type SampleContactValues, +} from "../server/lib/hubspot-email-preview.js"; import takeDesignScreenshot, { getScreenshotPngData, } from "./take-design-screenshot.js"; @@ -26,6 +30,8 @@ const trackPngExportFailures = withDesignExportFailureTracking<{ filename: string; width?: number; height?: number; + templatePreview?: "hubspot-email"; + sampleContactValues?: SampleContactValues; }>("png"); export default defineAction({ @@ -37,7 +43,7 @@ export default defineAction({ "request-native-local-export action in an open editor tab; this " + "headless action fails typed for those screens rather than returning " + "pixels without the effect. Other screens render at 1440px wide " + - "by default and full-page height.", + "by default and full-page height. Optional templatePreview=hubspot-email uses fake sample template values for browser-layout proof, not HubSpot compilation or inbox rendering; source and HTML export remain unchanged.", schema: z .object({ designId: z @@ -57,6 +63,17 @@ export default defineAction({ .describe( "Screen filename when fileId is not provided. Defaults to index.html.", ), + templatePreview: z + .literal("hubspot-email") + .optional() + .describe( + "Optional hubspot-email sample preview using supported template tokens/contact defaults. Omit to render raw HTML unchanged.", + ), + sampleContactValues: sampleContactValuesSchema + .optional() + .describe( + "Optional in-memory recipient samples, only with templatePreview=hubspot-email: an array of at most 8 unique {field, value} entries, with contact.field names and 500 characters per value. Fields must match supported personalization_token calls; unknown or unused fields fail. Omit fields to use literal defaults; saved HTML is unchanged.", + ), width: z.coerce .number() .int() @@ -77,17 +94,37 @@ export default defineAction({ .refine(({ designId, fileId }) => Boolean(designId || fileId), { message: "Provide designId or fileId to select a screen.", path: ["designId"], - }), + }) + .refine( + ({ templatePreview, sampleContactValues }) => + sampleContactValues === undefined || + templatePreview === "hubspot-email", + { + message: + 'sampleContactValues requires templatePreview="hubspot-email".', + path: ["sampleContactValues"], + }, + ), readOnly: false, http: { method: "POST" }, run: trackPngExportFailures(async (args, ctx, facts) => { - const { designId, fileId, filename, width, height } = args; + const { + designId, + fileId, + filename, + width, + height, + templatePreview, + sampleContactValues, + } = args; const result = await takeDesignScreenshot.run( { ...(designId ? { designId } : {}), ...(fileId ? { fileId } : {}), filename, widths: [width ?? 1440], + ...(templatePreview ? { templatePreview } : {}), + ...(sampleContactValues === undefined ? {} : { sampleContactValues }), ...(height === undefined ? {} : { heights: [height] }), }, ctx, @@ -179,6 +216,8 @@ export default defineAction({ viewport: screenshot.viewport, diagnostics: screenshot.diagnostics, capturedAt: result.capturedAt, + captureTimings: result.captureTimings, + ...(result.preview ? { preview: result.preview } : {}), }; }), }); diff --git a/templates/design/actions/generate-design.spec.ts b/templates/design/actions/generate-design.spec.ts index 6abac5566c1..178c04f2791 100644 --- a/templates/design/actions/generate-design.spec.ts +++ b/templates/design/actions/generate-design.spec.ts @@ -833,6 +833,36 @@ describe("generate-design: new-file creation path", () => { resetDesignDataMutation(); }); + it("gives semantic email output a fluid email viewport without generated web breakpoints", async () => { + const result = await action.run({ + designId: "design-1", + prompt: "Create a HubSpot email template", + files: [ + { + filename: "email.html", + fileType: "html", + frameType: "hubspot-email", + content: + '
Hello
', + }, + ], + }); + const fileId = result.savedFiles[0]!.id; + expect(mocks.getDesignData()).toMatchObject({ + canvasFrames: { [fileId]: { width: 600, height: 800 } }, + screenMetadata: { + [fileId]: { + width: 600, + height: 800, + heightMode: "hug", + heightPinned: false, + breakpointWidths: [], + }, + }, + }); + expect(mocks.getDesignData().breakpointSet).toBeUndefined(); + }); + it("creates a brand-new file via insert + seedFromText, with no pre-existing base to race against", async () => { const result = await action.run({ designId: "design-1", diff --git a/templates/design/actions/generate-design.ts b/templates/design/actions/generate-design.ts index 9a66dbe8cbc..a4c5bce94ef 100644 --- a/templates/design/actions/generate-design.ts +++ b/templates/design/actions/generate-design.ts @@ -65,6 +65,8 @@ import { inspectDesignHtmlDocumentIntegrity, isDesignHtmlIntegrityError, } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { assertLockedLayersPreserved } from "../shared/locked-layers.js"; import { derivePromptTitle } from "../shared/prompt-title.js"; import { widthToPrefix } from "../shared/responsive-classes.js"; @@ -456,6 +458,7 @@ const generateDesignAgentParameters = { description: "JSON array of files to save. Pass one compact, complete, renderable index.html first, e.g. " + '[{"filename":"index.html","fileType":"html","content":"..."}]. ' + + 'For HubSpot email HTML author email-safe tables; optional frameType:"hubspot-email" requires its internal meta marker but is not required for export. ' + "Do not use generate-design to replace a selected variant screen after a variant pick; snapshot that fileId and use edit-design instead.", }, designSystemId: { @@ -567,7 +570,7 @@ const generateDesignAction = defineAction({ "Do not report a design as ready until this action succeeds. " + "When adding multiple screens or states, pass canvasFrames with filenames " + "and numeric x/y/width/height so the new screens appear placed on the " + - "overview canvas.", + "overview canvas. For HubSpot email output, author email-safe table HTML without browser runtimes; optional frameType hubspot-email requires its internal meta marker. Any HTML frame can be validated with export-hubspot-html before reporting compatibility.", schema: z.object({ designId: z.string().describe("Design project ID to save content to"), prompt: z @@ -584,6 +587,12 @@ const generateDesignAction = defineAction({ z.object({ filename: z.string().describe("Filename (e.g. 'index.html')"), content: z.string().min(1).describe("File content"), + frameType: z + .literal("hubspot-email") + .optional() + .describe( + "Optional internal email authoring type; when supplied, include its meta marker and email-safe HTML. Any HTML frame can be validated and exported with export-hubspot-html.", + ), fileType: z .enum(["html", "css", "jsx", "asset"]) .optional() @@ -845,6 +854,14 @@ const generateDesignAction = defineAction({ const existingByName = new Map(existingFiles.map((f) => [f.filename, f])); + for (const file of files) { + assertHubSpotEmailFrameContent({ + content: file.content, + fileType: file.fileType ?? "html", + frameType: file.frameType, + }); + } + const annotatedFiles = files.map((file) => ({ ...file, content: annotateScreenHtmlForPersist(file.content, file.fileType), @@ -1009,6 +1026,7 @@ const generateDesignAction = defineAction({ height: number; breakpointWidths: number[] | undefined; fixedSize: boolean; + contentDriven: boolean; }> = []; const normalizedTweaks = tweaks?.map((tweak) => ({ ...tweak, @@ -1029,13 +1047,18 @@ const generateDesignAction = defineAction({ files.some((file) => file.filename === existingFile.filename), ) && isFixedArtworkCopyFollowUp(prompt); - const requestedDevices = exactFixedCanvasOutput - ? [] - : userRequestedDeviceVariants.length > 0 - ? userRequestedDeviceVariants - : devices !== undefined && fixedArtworkFollowUp - ? [] - : devices; + const renderableFiles = files.filter(isRenderableDesignScreenFile); + const emailOnlyOutput = + renderableFiles.length > 0 && + renderableFiles.every((file) => isHubSpotEmailHtml(file.content)); + const requestedDevices = + emailOnlyOutput || exactFixedCanvasOutput + ? [] + : userRequestedDeviceVariants.length > 0 + ? userRequestedDeviceVariants + : devices !== undefined && fixedArtworkFollowUp + ? [] + : devices; const ignoredModelDevices = exactFixedCanvasOutput ? (devices ?? []) : []; const explicitDeviceSelection = requestedDevices !== undefined && requestedDevices.length > 0; @@ -1160,7 +1183,9 @@ const generateDesignAction = defineAction({ ? (rawMetadata as Record) : {}; const frame = merged.canvasFrames[file.id]; + const emailFrame = isHubSpotEmailHtml(source.content); const width = + (emailFrame ? 600 : undefined) ?? promptCanvasDimensions?.width ?? (explicitDeviceSelection ? typeof frame?.width === "number" && frame.width > 0 @@ -1172,6 +1197,11 @@ const generateDesignAction = defineAction({ ? frame.width : viewport.width); const height = + (emailFrame + ? typeof metadata.height === "number" + ? metadata.height + : (frame?.height ?? 800) + : undefined) ?? promptCanvasDimensions?.height ?? (explicitDeviceSelection ? typeof frame?.height === "number" && frame.height > 0 @@ -1211,19 +1241,21 @@ const generateDesignAction = defineAction({ existingWidth === width && existingHeight === height && !isFixedCanvasFormatConversion(prompt); - const breakpointWidths = explicitDeviceSelection - ? fixedCanvasOutput - ? generatedBreakpointSet.map((breakpoint) => breakpoint.widthPx) - : undefined - : sameFixedCanvasSize - ? (existingBreakpointWidths ?? []) - : fixedCanvasOutput || explicitlyEmptyDeviceSelection - ? [] - : existingBreakpointWidths !== undefined - ? existingBreakpointWidths - : generatedBreakpointSet.length === 0 - ? [] - : undefined; + const breakpointWidths = emailFrame + ? [] + : explicitDeviceSelection + ? fixedCanvasOutput + ? generatedBreakpointSet.map((breakpoint) => breakpoint.widthPx) + : undefined + : sameFixedCanvasSize + ? (existingBreakpointWidths ?? []) + : fixedCanvasOutput || explicitlyEmptyDeviceSelection + ? [] + : existingBreakpointWidths !== undefined + ? existingBreakpointWidths + : generatedBreakpointSet.length === 0 + ? [] + : undefined; const nextMetadata: Record = { ...metadata, width, @@ -1234,7 +1266,12 @@ const generateDesignAction = defineAction({ } else { delete nextMetadata.breakpointWidths; } - if (fixedCanvasOutput) { + if (emailFrame) { + nextMetadata.heightPinned = false; + nextMetadata.heightMode = "hug"; + if (frame) + merged.canvasFrames[file.id] = { ...frame, width, height }; + } else if (fixedCanvasOutput) { nextMetadata.heightPinned = true; nextMetadata.heightMode = "fixed"; } @@ -1245,7 +1282,8 @@ const generateDesignAction = defineAction({ width, height, breakpointWidths, - fixedSize: fixedCanvasOutput, + fixedSize: fixedCanvasOutput && !emailFrame, + contentDriven: emailFrame, }); } } @@ -1385,6 +1423,7 @@ const generateDesignAction = defineAction({ ); if (!source || !isRenderableDesignScreenFile(source)) continue; const current = merged.canvasFrames[file.id] ?? {}; + const emailFrame = isHubSpotEmailHtml(source.content); if ( preExistingFrameIds.has(file.id) && !exactSizeResizedFileIds.has(file.id) && @@ -1395,10 +1434,16 @@ const generateDesignAction = defineAction({ ) { continue; } - const width = - current.width ?? promptCanvasDimensions?.width ?? viewport.width; + const width = emailFrame + ? 600 + : (current.width ?? + promptCanvasDimensions?.width ?? + viewport.width); const height = - current.height ?? promptCanvasDimensions?.height ?? viewport.height; + current.height ?? + (emailFrame ? 800 : undefined) ?? + promptCanvasDimensions?.height ?? + viewport.height; let x = current.x ?? nextX; let y = current.y ?? 0; let candidateRect = rectOf( @@ -1563,7 +1608,14 @@ const generateDesignAction = defineAction({ ? (current.screenMetadata as Record) : {}; const screenMetadataApplied = screenMetadataUpdates.every( - ({ fileId, width, height, breakpointWidths, fixedSize }) => { + ({ + fileId, + width, + height, + breakpointWidths, + fixedSize, + contentDriven, + }) => { const metadata = currentMetadata[fileId]; return ( metadata && @@ -1580,7 +1632,11 @@ const generateDesignAction = defineAction({ )) && (!fixedSize || ((metadata as Record).heightPinned === true && - (metadata as Record).heightMode === "fixed")) + (metadata as Record).heightMode === + "fixed")) && + (!contentDriven || + ((metadata as Record).heightPinned === false && + (metadata as Record).heightMode === "hug")) ); }, ); diff --git a/templates/design/actions/get-design-snapshot.spec.ts b/templates/design/actions/get-design-snapshot.spec.ts index b13dd7bdcfb..f514eed25f3 100644 --- a/templates/design/actions/get-design-snapshot.spec.ts +++ b/templates/design/actions/get-design-snapshot.spec.ts @@ -80,6 +80,7 @@ describe("get-design-snapshot", () => { id: "file-b", filename: "variant-b.html", fileType: "html", + frameType: null, content: "B", source: "collab", }, diff --git a/templates/design/actions/get-design-snapshot.ts b/templates/design/actions/get-design-snapshot.ts index 2f07f65f972..10cec7681a6 100644 --- a/templates/design/actions/get-design-snapshot.ts +++ b/templates/design/actions/get-design-snapshot.ts @@ -10,6 +10,7 @@ import { parseDesignTemplateData, readDesignTemplateSource, } from "../server/lib/design-template-data.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { lockedLayerSnapshots } from "../shared/locked-layers.js"; import getDesignSystem from "./get-design-system.js"; import "../server/db/index.js"; @@ -48,6 +49,7 @@ export default defineAction({ ), }), readOnly: true, + dedupe: false, capabilityScopes: ["visual-edit"], http: { method: "GET" }, publicAgent: { expose: true, readOnly: true, requiresAuth: true }, @@ -132,6 +134,7 @@ export default defineAction({ id: f.id, filename: f.filename, fileType: f.fileType, + frameType: isHubSpotEmailHtml(f.content) ? "hubspot-email" : null, content: f.content, source: f.source, })), @@ -157,7 +160,8 @@ export default defineAction({ filename: boundedFile.filename, }, nextRequiredAction: - `Call edit-design exactly once with designId ${designId} and fileId ${boundedFile.id} (${boundedFile.filename}). ` + + `For an in-place refinement, Call edit-design exactly once with designId ${designId} and fileId ${boundedFile.id} (${boundedFile.filename}). ` + + "For a requested HubSpot email rebuild, use this source to author a new email-safe HTML sibling with create-file frameType=hubspot-email and preserve the original. " + "Do not call delete-file or get-design-snapshot again unless edit-design fails with a concrete missing context error.", } : {}), diff --git a/templates/design/actions/get-design-surface-index.ts b/templates/design/actions/get-design-surface-index.ts index fa1b92cd6d6..5e64e4ddb0e 100644 --- a/templates/design/actions/get-design-surface-index.ts +++ b/templates/design/actions/get-design-surface-index.ts @@ -420,6 +420,7 @@ export default defineAction({ ), }), readOnly: true, + dedupe: false, http: { method: "GET" }, run: async ({ designId, diff --git a/templates/design/actions/take-design-screenshot.spec.ts b/templates/design/actions/take-design-screenshot.spec.ts index 388cbc68eca..9b61db4b986 100644 --- a/templates/design/actions/take-design-screenshot.spec.ts +++ b/templates/design/actions/take-design-screenshot.spec.ts @@ -65,6 +65,9 @@ vi.mock("../server/lib/playwright-runtime.js", async (importOriginal) => { launchChromium: playwrightMocks.launchChromium, }; }); +vi.mock("../server/lib/screenshot-readiness.js", () => ({ + prepareScreenshotPage: vi.fn(async (page, html) => page.setContent(html)), +})); vi.mock("../server/source-workspace.js", () => ({ readLiveSourceFile: vi.fn(async () => ({ content: @@ -88,6 +91,18 @@ import action, { } from "./take-design-screenshot.js"; describe("public design screenshot access", () => { + it("rejects contact samples for raw screenshots before reading a file", async () => { + const args = { + fileId: "file_1", + sampleContactValues: [{ field: "contact.firstname", value: "Alex" }], + }; + expect(action.schema.safeParse(args).success).toBe(false); + mockGetDb.mockClear(); + await expect(action.run(args)).rejects.toThrow( + 'sampleContactValues requires templatePreview="hubspot-email"', + ); + expect(mockGetDb).not.toHaveBeenCalled(); + }); it("includes public link visibility when reading a specific design file", async () => { mockAccessFilter.mockClear(); const result = await action.run( @@ -137,10 +152,7 @@ describe("screenshot image handoff", () => { const page = { on: vi.fn(), setContent: vi.fn().mockResolvedValue(undefined), - evaluate: vi - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValue(diagnostics), + evaluate: vi.fn().mockResolvedValue(diagnostics), waitForFunction: vi.fn().mockResolvedValue(undefined), screenshot: vi.fn().mockResolvedValue(png), }; @@ -161,7 +173,7 @@ describe("screenshot image handoff", () => { url: "https://files.example.test/screen.png", } as never); - const args = { fileId: "file_1", widths: [900] }; + const args = { fileId: "file_1", widths: [600, 375, 500, 400, 300] }; const screenshotResult = await action.run(args, { caller: "tool", actionName: "take-design-screenshot", @@ -181,7 +193,18 @@ describe("screenshot image handoff", () => { expect(renderedHtml).toContain("background: #00c248"); expect(renderedHtml).not.toContain("oklch("); - expect(screenshotResult).not.toHaveProperty("_agentImages"); + expect(screenshotResult._agentImages).toHaveLength(4); + expect(screenshotResult._agentImages?.slice(0, 2)).toEqual([ + { + url: "https://files.example.test/screen.png", + label: "index.html (600px)", + }, + { + url: "https://files.example.test/screen.png", + label: "index.html (375px)", + }, + ]); + expect(httpExportResult).not.toHaveProperty("_agentImages"); expect(JSON.stringify(screenshotResult)).not.toContain( png.toString("base64"), ); diff --git a/templates/design/actions/take-design-screenshot.ts b/templates/design/actions/take-design-screenshot.ts index d7ec0e22574..7568ae19479 100644 --- a/templates/design/actions/take-design-screenshot.ts +++ b/templates/design/actions/take-design-screenshot.ts @@ -1,4 +1,5 @@ import { defineAction, fail } from "@agent-native/core/action"; +import { MAX_TOOL_RESULT_IMAGES } from "@agent-native/core/agent/tool-result-images"; import { uploadFile } from "@agent-native/core/file-upload"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { accessFilter } from "@agent-native/core/sharing"; @@ -7,6 +8,11 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { isAllowedFigmaSvgRenderRequest } from "../server/lib/design-to-figma-svg.js"; +import { + createHubSpotEmailPreview, + sampleContactValuesSchema, +} from "../server/lib/hubspot-email-preview.js"; +import { prepareScreenshotPage } from "../server/lib/screenshot-readiness.js"; import { readLiveSourceFile } from "../server/source-workspace.js"; import "../server/db/index.js"; @@ -407,48 +413,81 @@ export default defineAction({ "the diagnostics are actionable immediately, and screenshots are saved " + "at durable URLs for review. Requires a headless Chromium binary; in hosted/serverless " + "deploys where one isn't available, returns `{ ok: false, reason }` " + - "instead of throwing — fall back to run-design-audit in that case.", - schema: z.object({ - designId: z - .string() - .optional() - .describe( - "Design project id. Required unless fileId is provided. Combined with filename to resolve the screen.", - ), - fileId: z - .string() - .optional() - .describe( - "Specific design_files.id to screenshot. Takes priority over designId/filename.", - ), - filename: z - .string() - .optional() - .default("index.html") - .describe( - "Filename to screenshot when fileId is not provided. Defaults to index.html.", - ), - widths: z - .array(z.number().int().min(200).max(3840)) - .optional() - .describe( - "Viewport widths in px to render. Defaults to [1280, 375] (desktop + mobile). " + - "Height is derived per width using standard device aspect ratios.", - ), - heights: z - .array(z.number().int().min(200).max(4096)) - .optional() - .describe( - "Exact viewport heights in px, matched index-for-index against `widths`. " + - "Use when the caller needs the screenshot's aspect ratio to match a " + - "known on-screen rect exactly (e.g. compositing an overlay on top) " + - "instead of the device-heuristic default. Omit an index to fall back " + - "to the standard derived height for that width.", - ), - }), + "instead of throwing — fall back to run-design-audit in that case. Agent/MCP calls include screenshot image attachments for visual inspection. Prefer one call with widths [600,375] for the HubSpot desktop/mobile completion pass. Optional templatePreview=hubspot-email renders documented tokens with fake sample values/contact defaults for browser-layout checks only, not HubSpot compilation or inbox proof; source and HTML export stay unchanged.", + schema: z + .object({ + designId: z + .string() + .optional() + .describe( + "Design project id. Required unless fileId is provided. Combined with filename to resolve the screen.", + ), + fileId: z + .string() + .optional() + .describe( + "Specific design_files.id to screenshot. Takes priority over designId/filename.", + ), + filename: z + .string() + .optional() + .default("index.html") + .describe( + "Filename to screenshot when fileId is not provided. Defaults to index.html.", + ), + templatePreview: z + .literal("hubspot-email") + .optional() + .describe( + "Optional hubspot-email sample browser preview; substitutes supported template tokens in a temporary HTML copy and reports unresolved tokens. Omit for unchanged raw HTML rendering.", + ), + sampleContactValues: sampleContactValuesSchema + .optional() + .describe( + "Optional in-memory recipient samples for templatePreview=hubspot-email only: an array of at most 8 unique {field, value} entries, with contact.field names and values of at most 500 characters. For example, [{field: 'contact.firstname', value: 'Alex'}]. Fields must match supported personalization_token calls in this frame; unknown or unused fields fail. Omit a field to use its literal default. No source or QA frame is saved.", + ), + widths: z + .array(z.number().int().min(200).max(3840)) + .optional() + .describe( + "Viewport widths in px to render. Defaults to [1280, 375] (desktop + mobile). " + + "Height is derived per width using standard device aspect ratios.", + ), + heights: z + .array(z.number().int().min(200).max(4096)) + .optional() + .describe( + "Exact viewport heights in px, matched index-for-index against `widths`. " + + "Use when the caller needs the screenshot's aspect ratio to match a " + + "known on-screen rect exactly (e.g. compositing an overlay on top) " + + "instead of the device-heuristic default. Omit an index to fall back " + + "to the standard derived height for that width.", + ), + }) + .refine( + ({ templatePreview, sampleContactValues }) => + sampleContactValues === undefined || + templatePreview === "hubspot-email", + { + message: + 'sampleContactValues requires templatePreview="hubspot-email".', + path: ["sampleContactValues"], + }, + ), readOnly: true, http: { method: "POST" }, - run: async ({ designId, fileId, filename, widths, heights }, ctx) => { + run: async ( + { + designId, + fileId, + filename, + widths, + heights, + templatePreview, + sampleContactValues, + }, + ctx, + ) => { if (!designId && !fileId) { throw new Error("designId or fileId is required."); } @@ -498,6 +537,11 @@ export default defineAction({ } const html = await liveContent(file.id, file.content ?? ""); + const emailPreview = + templatePreview === "hubspot-email" + ? createHubSpotEmailPreview(html, sampleContactValues) + : undefined; + const renderHtml = emailPreview?.html ?? html; const native = parseEffectsFromHtml(html); if ( native.errors.length || @@ -509,19 +553,68 @@ export default defineAction({ }); } + const captureTimings: Array<{ + stage: string; + durationMs: number; + viewportWidth?: number; + status: "completed" | "failed"; + error?: { name: string; message: string }; + }> = []; + const measure = async ( + stage: string, + work: () => Promise, + viewportWidth?: number, + ): Promise => { + const startedAt = Date.now(); + console.info("[design-screenshot] stage started", { + stage, + designId: file.designId, + fileId: file.id, + ...(viewportWidth === undefined ? {} : { viewportWidth }), + }); + let status: "completed" | "failed" = "failed"; + let failure: { name: string; message: string } | undefined; + try { + const result = await work(); + status = "completed"; + return result; + } catch (error) { + failure = { + name: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : String(error), + }; + throw error; + } finally { + captureTimings.push({ + stage, + durationMs: Date.now() - startedAt, + ...(viewportWidth === undefined ? {} : { viewportWidth }), + status, + ...(failure ? { error: failure } : {}), + }); + if (status === "failed") + console.error("[design-screenshot] stage failed", { + designId: file.designId, + fileId: file.id, + captureTimings, + }); + } + }; let playwright: PlaywrightModule; try { - playwright = await importPlaywright(); + playwright = await measure("load-renderer", () => importPlaywright()); } catch (err) { - return { ok: false, reason: chromiumUnavailableReason(err) }; + return { ok: false as const, reason: chromiumUnavailableReason(err) }; } const viewports = resolveViewports(widths, heights); let browser: import("@playwright/test").Browser | undefined; try { - browser = await launchChromium(playwright.chromium); + browser = await measure("allocate-browser", () => + launchChromium(playwright.chromium), + ); } catch (err) { - return { ok: false, reason: chromiumUnavailableReason(err) }; + return { ok: false as const, reason: chromiumUnavailableReason(err) }; } const ownerEmail = getRequestUserEmail() ?? undefined; @@ -530,22 +623,33 @@ export default defineAction({ try { for (const viewport of viewports) { if (ctx?.signal?.aborted) break; - const context = await browser.newContext({ - viewport: { width: viewport.widthPx, height: viewport.heightPx }, - deviceScaleFactor: 2, - }); - await context.addInitScript( - "globalThis.__name = globalThis.__name || function (value) { return value; };", + const context = await measure( + "setup-context", + () => + browser!.newContext({ + viewport: { width: viewport.widthPx, height: viewport.heightPx }, + deviceScaleFactor: 2, + }), + viewport.widthPx, + ); + const page = await measure( + "setup-page", + async () => { + await context.addInitScript( + "globalThis.__name = globalThis.__name || function (value) { return value; };", + ); + await context.route("**/*", async (route) => { + if (await isAllowedFigmaSvgRenderRequest(route.request().url())) { + await route.continue(); + } else { + await route.abort("blockedbyclient"); + } + }); + await context.routeWebSocket("**/*", () => {}); + return context.newPage(); + }, + viewport.widthPx, ); - await context.route("**/*", async (route) => { - if (await isAllowedFigmaSvgRenderRequest(route.request().url())) { - await route.continue(); - } else { - await route.abort("blockedbyclient"); - } - }); - await context.routeWebSocket("**/*", () => {}); - const page = await context.newPage(); const consoleErrors: string[] = []; const fontLoadFailures: string[] = []; page.on("console", (msg) => { @@ -568,64 +672,38 @@ export default defineAction({ }); try { - await page.setContent(degradeWideColorsInHtml(html), { - waitUntil: "networkidle", - }); - await page - .evaluate(async () => { - const fontsReady = document.fonts?.ready; - if (!fontsReady) return; - await Promise.race([ - fontsReady, - new Promise((resolve) => setTimeout(resolve, 4_000)), - ]); - }) - .catch(() => {}); - await page - .waitForFunction( - () => { - const win = window as unknown as { - __anExportRuleCounts?: number[]; - __anExportRuleStart?: number; - }; - win.__anExportRuleStart ??= Date.now(); - const count = Array.from(document.styleSheets).reduce( - (sum, sheet) => { - try { - return sum + (sheet.cssRules?.length ?? 0); - } catch { - return sum + 1; - } - }, - 0, - ); - const history = [ - ...(win.__anExportRuleCounts ?? []).slice(-5), - count, - ]; - win.__anExportRuleCounts = history; - return ( - history.length >= 6 && - history.every((value) => value === history[0]) && - Date.now() - win.__anExportRuleStart >= 600 - ); - }, - { timeout: 2500, polling: 100 }, - ) - .catch(() => {}); - - const pageDiagnostics = await page.evaluate(collectPageDiagnostics); - const png = await page.screenshot({ type: "png", fullPage: true }); + await measure( + "ready", + () => + prepareScreenshotPage(page, degradeWideColorsInHtml(renderHtml)), + viewport.widthPx, + ); + const pageDiagnostics = await measure( + "diagnostics", + () => page.evaluate(collectPageDiagnostics), + viewport.widthPx, + ); + const png = await measure( + "capture", + () => + page.screenshot({ type: "png", fullPage: true, timeout: 30_000 }), + viewport.widthPx, + ); let uploaded: Awaited> | null = null; let uploadError: ScreenshotUploadError | undefined; try { - uploaded = await uploadFile({ - data: png, - mimeType: "image/png", - filename: `design-${file.designId}-${file.filename}-${viewport.label}.png`, - ownerEmail, - }); + uploaded = await measure( + "upload", + () => + uploadFile({ + data: png, + mimeType: "image/png", + filename: `design-${file.designId}-${file.filename}-${viewport.label}.png`, + ownerEmail, + }), + viewport.widthPx, + ); } catch { uploadError = { code: "file_upload_failed", @@ -663,11 +741,20 @@ export default defineAction({ screenshotPngs.set(screenshot, png); } } finally { - await context.close().catch(() => {}); + await measure( + "cleanup-context", + () => context.close(), + viewport.widthPx, + ).catch(() => {}); } } } finally { - await browser.close().catch(() => {}); + await measure("cleanup-browser", () => browser!.close()).catch(() => {}); + console.info("[design-screenshot] stage timings", { + designId: file.designId, + fileId: file.id, + captureTimings, + }); } const totalIssues = screenshots.reduce((sum, s) => { @@ -685,11 +772,25 @@ export default defineAction({ }, 0); return { - ok: true, + ok: true as const, designId: file.designId, fileId: file.id, filename: file.filename, capturedAt: new Date().toISOString(), + captureTimings, + ...(emailPreview ? { preview: emailPreview.preview } : {}), + ...((ctx?.caller === "tool" || ctx?.caller === "mcp") && + screenshots.some((screenshot) => screenshot.url.startsWith("https://")) + ? { + _agentImages: screenshots + .filter((screenshot) => screenshot.url.startsWith("https://")) + .slice(0, MAX_TOOL_RESULT_IMAGES) + .map((screenshot) => ({ + url: screenshot.url, + label: `${file.filename} (${screenshot.viewport.widthPx}px)`, + })), + } + : {}), screenshots, summary: { viewportsRendered: screenshots.length, diff --git a/templates/design/actions/update-file.ts b/templates/design/actions/update-file.ts index 762a7f30298..b2f79109e99 100644 --- a/templates/design/actions/update-file.ts +++ b/templates/design/actions/update-file.ts @@ -29,6 +29,8 @@ import { } from "../server/source-workspace.js"; import { designOutputWriteReceipt } from "../shared/generation-output-receipt.js"; import { assertDesignHtmlEditIntegrity } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { assertLockedLayersPreserved } from "../shared/locked-layers.js"; import { applySourceContentPatch, @@ -441,6 +443,13 @@ export default defineAction({ contentPatch && expectedVersionHash ? contentFromPatch(liveContent, expectedVersionHash, contentPatch) : requestedContent; + assertHubSpotEmailFrameContent({ + content: content ?? liveContent, + fileType: fileType ?? persistedFile.fileType ?? "html", + previousContent: isHubSpotEmailHtml(liveContent) + ? liveContent + : persistedFile.content, + }); if (content !== undefined) { assertDesignHtmlEditIntegrity({ previousContent: liveContent, diff --git a/templates/design/actions/view-screen.test.ts b/templates/design/actions/view-screen.test.ts index b32e640a353..e95d991ce9f 100644 --- a/templates/design/actions/view-screen.test.ts +++ b/templates/design/actions/view-screen.test.ts @@ -6,10 +6,15 @@ const mocks = vi.hoisted(() => { where: vi.fn(), }; selectChain.from.mockReturnValue(selectChain); + const contentSelectChain = { from: vi.fn(), where: vi.fn(), limit: vi.fn() }; + contentSelectChain.from.mockReturnValue(contentSelectChain); + contentSelectChain.where.mockReturnValue(contentSelectChain); return { getDb: vi.fn(() => ({ - select: vi.fn(() => selectChain), + select: vi.fn((columns) => + columns?.content ? contentSelectChain : selectChain, + ), })), parseCanvasFrameGeometryById: vi.fn((value) => value ?? []), listAppState: vi.fn(), @@ -21,6 +26,7 @@ const mocks = vi.hoisted(() => { queryReviewComments: vi.fn(), eq: vi.fn((left, right) => ({ left, right })), selectChain, + contentSelectChain, getDesignSystemRun: vi.fn(async ({ id }: { id: string }) => ({ id, title: "Acme", @@ -80,6 +86,7 @@ vi.mock("../server/db/index.js", () => ({ fileType: "designFiles.fileType", updatedAt: "designFiles.updatedAt", designId: "designFiles.designId", + content: "designFiles.content", }, }, })); @@ -104,6 +111,7 @@ describe("view-screen", () => { mocks.getReviewThreadSummary.mockReset(); mocks.queryReviewComments.mockReset(); mocks.selectChain.where.mockReset(); + mocks.contentSelectChain.limit.mockResolvedValue([]); mocks.resolveAccess.mockResolvedValue({ role: "viewer", resource: { @@ -353,6 +361,12 @@ describe("view-screen", () => { }); it("uses active file before overview multi-selection", async () => { + mocks.contentSelectChain.limit.mockResolvedValue([ + { + content: + 'Email', + }, + ]); mocks.readAppStateForCurrentTab .mockResolvedValueOnce({ view: "editor", @@ -385,6 +399,7 @@ describe("view-screen", () => { expect(result.design.activeScreen).toMatchObject({ id: "file_index", filename: "index.html", + frameType: "hubspot-email", }); }); diff --git a/templates/design/actions/view-screen.ts b/templates/design/actions/view-screen.ts index 2a64d018c47..ac483a4bcab 100644 --- a/templates/design/actions/view-screen.ts +++ b/templates/design/actions/view-screen.ts @@ -25,6 +25,7 @@ import { parseCanvasFrameGeometryById } from "../shared/canvas-frames.js"; import { isOverviewScreenFile } from "../shared/design-files.js"; import { getDesignTemplatePreset } from "../shared/design-template-presets.js"; import { designGenerationSessionKey } from "../shared/generation-session.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { DESIGN_REPROMPT_PENDING_STATE_PREFIX, DESIGN_REPROMPT_PROPOSAL_STATE_PREFIX, @@ -340,6 +341,18 @@ export default defineAction({ navigation, designSelection, ); + let activeFrameType: "hubspot-email" | null = null; + if (activeScreen) { + const [activeFile] = await db + .select({ content: schema.designFiles.content }) + .from(schema.designFiles) + .where(eq(schema.designFiles.id, activeScreen.id)) + .limit(1); + activeFrameType = + activeFile && isHubSpotEmailHtml(activeFile.content) + ? "hubspot-email" + : null; + } const linkedDesignSystem = await loadAgentDesignSystemContext( typeof (access.resource as { designSystemId?: unknown }) .designSystemId === "string" @@ -360,7 +373,9 @@ export default defineAction({ : null, designSystem: linkedDesignSystem, screens: overviewScreens, - activeScreen, + activeScreen: activeScreen + ? { ...activeScreen, frameType: activeFrameType } + : null, activeCodeFile: resolveActiveCodeFile(files, designSelection), canvasFrames: parseCanvasFrameGeometryById(data.canvasFrames), }; diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index be4beb3a759..3636fcc2008 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -726,6 +726,7 @@ interface DesignCanvasProps { boardSurface?: boolean; nativeAnimationEpochMs?: number; fitRootBodyToFrame?: boolean; + screenHeightMode?: "auto" | "fixed" | "hug"; runtimeReplacementContent?: string; authoredSourceContent?: string; runtimeReplacementKey?: string; @@ -1510,6 +1511,7 @@ export function DesignCanvas({ boardSurface = false, nativeAnimationEpochMs, fitRootBodyToFrame, + screenHeightMode, runtimeReplacementContent, authoredSourceContent, runtimeReplacementKey, @@ -3633,6 +3635,7 @@ export function DesignCanvas({ contentOffsetX: embeddedFrame?.contentOffsetX ?? 0, contentOffsetY: embeddedFrame?.contentOffsetY ?? 0, fitBodyToFrame: fitRootBodyToFrame ?? !boardSurface, + heightMode: screenHeightMode, }); return inlineDesignStylesheets(interactDocument, designStylesheets); } @@ -3720,7 +3723,10 @@ export function DesignCanvas({ } frameDocument = inlineDesignStylesheets(frameDocument, designStylesheets); if (isEmbeddedFrame) { - frameDocument = appendContentSizeReporter(frameDocument); + frameDocument = appendContentSizeReporter( + frameDocument, + screenHeightMode, + ); } return injectSessionReplayIframeBootstrap(frameDocument); // editorChromeScaleX/Y are intentionally NOT deps: they only seed the initial @@ -3734,6 +3740,7 @@ export function DesignCanvas({ boardSurface, designStylesheets, fitRootBodyToFrame, + screenHeightMode, rawExternalPreviewUrl, isEmbeddedFrame, embeddedFrameBackground, diff --git a/templates/design/app/components/design/EditPanel.tsx b/templates/design/app/components/design/EditPanel.tsx index 3776cf0e8d6..97033d9a9ce 100644 --- a/templates/design/app/components/design/EditPanel.tsx +++ b/templates/design/app/components/design/EditPanel.tsx @@ -505,11 +505,7 @@ interface EditPanelProps { aiActions?: ReactNode; activeTool?: string; scaleToolControls?: ScaleToolControls; - onCreateScreenFromPreset?: (preset: { - name: string; - width: number; - height: number; - }) => void; + onCreateScreenFromPreset?: (preset: FrameSizePreset) => void; onDisableAutoLayout?: (nodeId: string) => void; onApplyLayoutFlow?: ApplyLayoutFlowHandler; onAlignSelection?: ( diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index a5cb53c642f..b9508956e71 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -134,7 +134,7 @@ import { import { CanvasRulers, type CanvasRulersHandle } from "./CanvasRulers"; import { CONTENT_SIZE_REPORT_MESSAGE_TYPE, - CONTENT_SIZE_REPORT_BRIDGE, + contentSizeReportBridge, resolveStableContentSizeSample, type ContentSizeSample, } from "./design-canvas/content-size-report"; @@ -14346,11 +14346,11 @@ const Screen = memo(function Screen({ }), STATIC_PREVIEW_TWEAK_BRIDGE + hitTestResponderMarkup(screen.content) + - CONTENT_SIZE_REPORT_BRIDGE, + contentSizeReportBridge(metadata.heightMode), ), ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fitBodyToFrame, screen.content, staticSrcdocNeeded]); + }, [fitBodyToFrame, metadata.heightMode, screen.content, staticSrcdocNeeded]); const privateScreenshotPreview = useMemo( () => preparePrivateReplayScreenshotPreviewDocument(srcdocWithHitTest, { diff --git a/templates/design/app/components/design/design-canvas/content-size-report.integration.spec.ts b/templates/design/app/components/design/design-canvas/content-size-report.integration.spec.ts new file mode 100644 index 00000000000..64f268577ce --- /dev/null +++ b/templates/design/app/components/design/design-canvas/content-size-report.integration.spec.ts @@ -0,0 +1,90 @@ +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { resolveAutoFitScreenHeight } from "../multi-screen/screen-height"; +import { appendContentSizeReporter } from "./content-size-report"; +import { buildInteractPreviewDocument } from "./interact-document"; + +let browser: Browser; +beforeAll(async () => { + browser = await chromium.launch({ chromiumSandbox: true }); +}); +afterAll(async () => { + await browser?.close(); +}); + +const SOURCE = + '
Content
Footer
'; + +describe("resolved frame height in Chromium", () => { + it.each(["editor", "interact"] as const)( + "reports natural height for unmarked hug HTML in %s while fixed geometry remains unchanged", + async (surface) => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + for (const mode of ["hug", "fixed"] as const) { + const html = + surface === "editor" + ? appendContentSizeReporter(SOURCE, mode) + : buildInteractPreviewDocument({ + content: SOURCE, + fitBodyToFrame: mode !== "hug", + heightMode: mode, + }); + await page.setContent( + '
', + ); + await page.evaluate((content) => { + const reports: Array<{ naturalHeight: number | null }> = []; + (window as unknown as { reports: typeof reports }).reports = + reports; + window.addEventListener("message", (event) => { + if ( + event.source === + document.querySelector("iframe")?.contentWindow && + event.data?.type === "agent-native:content-size" + ) + reports.push(event.data); + }); + document.querySelector("iframe")!.srcdoc = content; + }, html); + if (surface === "interact" && mode === "fixed") { + expect(html).not.toContain("data-agent-native-content-size-bridge"); + continue; + } + await page.waitForFunction( + () => + (window as unknown as { reports: unknown[] }).reports.length > 0, + undefined, + { timeout: 2000 }, + ); + const report = await page.evaluate(() => { + const reports = ( + window as unknown as { + reports: Array<{ naturalHeight: number | null }>; + } + ).reports; + return reports[reports.length - 1]; + }); + expect(report.naturalHeight).toBe(mode === "hug" ? 1080 : null); + const frameHeight = resolveAutoFitScreenHeight({ + mode, + width: 600, + currentHeight: 800, + measuredHeight: report.naturalHeight ?? 1080, + }); + expect(frameHeight).toBe(mode === "hug" ? 1080 : 800); + expect( + await page + .frameLocator("iframe") + .locator("meta[data-agent-native-screen-height-mode]") + .count(), + ).toBe(0); + } + } finally { + await context.close(); + } + }, + ); +}); diff --git a/templates/design/app/components/design/design-canvas/content-size-report.ts b/templates/design/app/components/design/design-canvas/content-size-report.ts index d92f2bf6bd0..763d1714a05 100644 --- a/templates/design/app/components/design/design-canvas/content-size-report.ts +++ b/templates/design/app/components/design/design-canvas/content-size-report.ts @@ -1,6 +1,9 @@ import { injectDocumentMarkup } from "@agent-native/core/shared"; -export const CONTENT_SIZE_REPORT_BRIDGE = ` +import type { ScreenHeightMode } from "../multi-screen/screen-height"; + +export function contentSizeReportBridge(heightMode?: ScreenHeightMode): string { + return ` ", `${css}`); +} + +function findElement( + html: string, + id: string, +): DefaultTreeAdapterTypes.Element { + function find( + node: DefaultTreeAdapterTypes.Node, + ): DefaultTreeAdapterTypes.Element | undefined { + if ( + "tagName" in node && + node.attrs.some((attr) => attr.name === "id" && attr.value === id) + ) + return node; + if ("childNodes" in node) { + for (const child of node.childNodes) { + const found = find(child); + if (found) return found; + } + } + } + const element = find(parse(html)); + expect(element).toBeDefined(); + return element!; +} + +describe("HubSpot email HTML export", () => { + beforeEach(() => { + safeFetch.mockReset(); + safeFetch.mockImplementation( + async () => + new Response(new Uint8Array(PNG_BYTES), { + headers: { "content-type": "image/png" }, + }), + ); + }); + + it("exports unmarked email HTML with the selector cascade, inline precedence, responsive rules, and literal HubL", async () => { + const result = await exportHubSpotEmailHtml( + withContent( + '{{ personalization_token(\'contact.lastname\', \'friend\') }}', + "span {font-size:12px;color:black} .copy {font-size:14px;color:green!important} p > span.copy {font-size:16px} #greeting {color:red!important} @media (max-width:400px) { .copy {font-size:20px!important} }", + ), + ); + const style = findElement(result.html, "greeting").attrs.find( + (attr) => attr.name === "style", + )!.value; + expect(style).toBe( + "font-size:12px;color:black;font-size:14px;color:green!important;font-size:16px;color:red!important;font-size:18px;color:navy", + ); + const contentParagraph = findElement(result.html, "greeting") + .parentNode as DefaultTreeAdapterTypes.Element; + const contentCell = + contentParagraph.parentNode as DefaultTreeAdapterTypes.Element; + expect(contentCell.tagName).toBe("td"); + expect( + contentCell.attrs.find((attr) => attr.name === "style")?.value, + ).toContain("font-family: Arial, sans-serif;"); + expect(result.html).toContain("@media (max-width:400px)"); + expect(result.html).not.toContain("p > span.copy"); + expect(result.html).toContain( + "{{ personalization_token('contact.lastname', 'friend') }}", + ); + expect(result.html).toContain('href="{{ unsubscribe_link }}"'); + expect(result.html).toContain( + "{{ site_settings.company_street_address_1 }}", + ); + expect(result.imageCount).toBe(0); + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it("checks deduplicated HTTPS images through the SSRF-safe fetcher without forwarding credentials", async () => { + const result = await exportHubSpotEmailHtml( + withContent( + 'Announcement', + ), + ); + expect(result.imageCount).toBe(2); + expect(safeFetch).toHaveBeenCalledTimes(1); + expect(safeFetch).toHaveBeenCalledWith( + "https://images.example.com/banner.png", + expect.objectContaining({ + method: "GET", + signal: expect.any(AbortSignal), + headers: { Accept: "image/png,image/jpeg,image/gif" }, + }), + { httpsOnly: true, requireDispatcher: true, maxRedirects: 3 }, + ); + }); + + it.each([ + ["script", "", "Remove ', + ), + ).toThrow("Remove ", + ); + expect(result.preview.substitutedTokens).toEqual([]); + expect(result.preview.unresolvedTokens).toHaveLength(2); + expect(result.html).toContain("{{ site_settings.company_name }}"); + }); + + it("uses recipient samples in text and URL attributes while retaining omitted defaults", () => { + const source = `

{{ personalization_token('contact.firstname', 'there') }}

Open`; + expect(text(parse(createHubSpotEmailPreview(source).html))).toBe( + "thereOpen", + ); + for (const name of [ + "Alex", + "A".repeat(500), + 'Alex & "Guest"', + ]) { + const result = createHubSpotEmailPreview(source, [ + { field: "contact.firstname", value: name }, + { + field: "contact.website", + value: 'https://example.com/sample?a=1&b=" onmouseover="alert(1)', + }, + ]); + expect(text(parse(result.html))).toBe(`${name}Open`); + expect(result.html).toContain("" onmouseover="alert(1)"); + expect(result.preview.usedContactFields).toEqual([ + "contact.firstname", + "contact.website", + ]); + expect(result.preview.unresolvedTokens).toEqual([]); + } + }); + + it.each( + [ + [{ field: "contact.unknown", value: "Alex" }], + [{ field: "company.name", value: "Example" }], + [{ field: "contact.firstname", value: "A".repeat(501) }], + Array.from({ length: 9 }, (_, i) => ({ + field: `contact.field${i}`, + value: "Example", + })), + [ + { field: "contact.firstname", value: "Alex" }, + { field: "contact.firstname", value: "Other" }, + ], + ].map((samples) => [samples]), + )( + "rejects unused, duplicate or invalid recipient samples (%s)", + (samples) => { + expect(() => + createHubSpotEmailPreview( + "

{{ personalization_token('contact.firstname', 'there') }}

", + samples, + ), + ).toThrowError( + expect.objectContaining({ + errorCode: "invalid_hubspot_preview_samples", + }), + ); + }, + ); +}); diff --git a/templates/design/server/lib/hubspot-email-preview.ts b/templates/design/server/lib/hubspot-email-preview.ts new file mode 100644 index 00000000000..e836e7ef1b0 --- /dev/null +++ b/templates/design/server/lib/hubspot-email-preview.ts @@ -0,0 +1,153 @@ +import { fail } from "@agent-native/core/action"; +import { parse, serialize, type DefaultTreeAdapterTypes } from "parse5"; +import { z } from "zod"; + +import { scanHubLTemplateTokens } from "../../shared/hubl-tokens.js"; +import { parseHubLInterpolation } from "./hubl-interpolation.js"; +import { protectHubLTokens } from "./protect-hubl-tokens.js"; + +export const sampleContactValuesSchema = z + .array( + z.object({ + field: z.string().regex(/^contact\.\w+(?:\.\w+)*$/), + value: z.string().max(500), + }), + ) + .max(8) + .refine( + (values) => + new Set(values.map(({ field }) => field)).size === values.length, + "Sample contact fields must be unique.", + ); + +export type SampleContactValues = z.infer; + +const SAMPLE_VALUES: Record = { + "site_settings.company_name": "Example Company", + "site_settings.company_street_address_1": "123 Example Street", + "site_settings.company_city": "Example City", + "site_settings.company_state": "EX", + unsubscribe_link: "https://example.invalid/preferences", + unsubscribe_link_all: "https://example.invalid/unsubscribe-all", + unsubscribe_link_single: "https://example.invalid/unsubscribe", + view_as_page_url: "https://example.invalid/email-preview", + subscription_confirmation_url: "https://example.invalid/confirmation", + "content.absolute_url": "https://example.invalid/email-preview", +}; +function previewValue( + token: string, + samples: Record, + used: Set, +): string | undefined { + const parsed = parseHubLInterpolation(token); + if (!parsed) return undefined; + if (Object.prototype.hasOwnProperty.call(SAMPLE_VALUES, parsed.name)) + return SAMPLE_VALUES[parsed.name]; + if (!/^contact\.[\w.]+$/.test(parsed.name)) return undefined; + if ( + parsed.personalization && + !parsed.filtered && + Object.prototype.hasOwnProperty.call(samples, parsed.name) + ) { + used.add(parsed.name); + return samples[parsed.name]; + } + return parsed.fallback; +} + +export function createHubSpotEmailPreview( + source: string, + sampleContactValues?: SampleContactValues, +): { + html: string; + preview: { + mode: "hubspot-email"; + sampleValues: true; + substitutedTokens: string[]; + unresolvedTokens: string[]; + usedContactFields?: string[]; + }; +} { + if (source.length > 1_000_000) + fail("Email HTML must be smaller than 1 MB before creating a preview.", { + errorCode: "invalid_hubspot_email", + statusCode: 422, + }); + const validated = sampleContactValuesSchema.safeParse( + sampleContactValues ?? [], + ); + if (!validated.success) + fail( + "Recipient samples require at most 8 unique {field, value} entries with contact.field names and string values of at most 500 characters.", + { errorCode: "invalid_hubspot_preview_samples", statusCode: 422 }, + ); + const usedContactFields = new Set(); + const samples = Object.fromEntries( + validated.data.map(({ field, value }) => [field, value]), + ); + const scanned = scanHubLTemplateTokens(source); + if (scanned.unclosedAt !== undefined) + fail("The email contains an unclosed HubL token.", { + errorCode: "invalid_hubspot_email", + statusCode: 422, + }); + const protectedContent = protectHubLTokens(source, scanned.tokens); + const substitutedTokens = new Set(); + const unresolvedTokens = new Set(); + const restore = (value: string, substitute: boolean): string => + protectedContent.restore(value, (token) => { + const sample = substitute + ? previewValue(token, samples, usedContactFields) + : undefined; + if (sample === undefined) { + unresolvedTokens.add(token); + return token; + } + substitutedTokens.add(token); + return sample; + }); + const document = parse(protectedContent.html); + const visit = ( + node: DefaultTreeAdapterTypes.Node, + substitute = true, + ): void => { + if ("tagName" in node) { + substitute = substitute && !["script", "style"].includes(node.tagName); + for (const attr of node.attrs) + attr.value = restore(attr.value, substitute); + } + if (node.nodeName === "#text") { + const text = node as DefaultTreeAdapterTypes.TextNode; + text.value = restore(text.value, substitute); + } + if (node.nodeName === "#comment") { + const comment = node as DefaultTreeAdapterTypes.CommentNode; + comment.data = restore(comment.data, false); + } + if ("childNodes" in node) + for (const child of node.childNodes) visit(child, substitute); + if ("tagName" in node && node.tagName === "template") + visit((node as DefaultTreeAdapterTypes.Template).content, substitute); + }; + visit(document); + const unused = Object.keys(samples).filter( + (field) => !usedContactFields.has(field), + ); + if (unused.length) + fail( + `Recipient sample fields have no supported personalization_token interpolation in rendered text or attributes: ${unused.join(", ")}.`, + { errorCode: "invalid_hubspot_preview_samples", statusCode: 422 }, + ); + return { + html: serialize(document), + preview: { + mode: "hubspot-email", + sampleValues: true, + substitutedTokens: [...substitutedTokens], + unresolvedTokens: [...unresolvedTokens], + ...(sampleContactValues === undefined + ? {} + : { usedContactFields: [...usedContactFields] }), + }, + }; +} diff --git a/templates/design/server/lib/playwright-runtime.spec.ts b/templates/design/server/lib/playwright-runtime.spec.ts index 3e375fa785c..d4413db3a5f 100644 --- a/templates/design/server/lib/playwright-runtime.spec.ts +++ b/templates/design/server/lib/playwright-runtime.spec.ts @@ -23,7 +23,7 @@ describe("importPlaywright", () => { const imported = { chromium: { connectOverCDP, - launch: vi.fn(), + launch: vi.fn().mockResolvedValue(browser), } as unknown as PlaywrightModule["chromium"], }; const attempts: string[] = []; @@ -40,17 +40,16 @@ describe("importPlaywright", () => { expect(result).toBe(imported); expect(attempts).toEqual(["playwright", "playwright-core"]); await expect(launchChromium(result.chromium)).resolves.toBe(browser); - expect(connectOverCDP).toHaveBeenCalledWith( - "wss://browser.example.test/cdp", - ); + expect(serverMocks.requestBuilderBrowserConnection).not.toHaveBeenCalled(); + expect(connectOverCDP).not.toHaveBeenCalled(); }); }); describe("launchChromium", () => { - it("uses the Builder Browser connection", async () => { + it("uses sandboxed local Chromium without requesting a remote browser when available", async () => { const browser = {}; const connectOverCDP = vi.fn().mockResolvedValue(browser); - const launch = vi.fn(); + const launch = vi.fn().mockResolvedValue(browser); const chromium = { connectOverCDP, launch, @@ -61,27 +60,36 @@ describe("launchChromium", () => { await expect(launchChromium(chromium)).resolves.toBe(browser); - expect(serverMocks.requestBuilderBrowserConnection).toHaveBeenCalledWith({ - sessionId: expect.stringMatching(/^design-render-/), - }); - expect(connectOverCDP).toHaveBeenCalledWith( - "wss://browser.example.test/cdp", - ); - expect(launch).not.toHaveBeenCalled(); + expect(launch).toHaveBeenCalledWith({ chromiumSandbox: true }); + expect(serverMocks.requestBuilderBrowserConnection).not.toHaveBeenCalled(); + expect(connectOverCDP).not.toHaveBeenCalled(); }); - it("falls back to sandboxed local Chromium outside CI", async () => { + it("uses a bounded Builder Browser connection when local Chromium is unavailable", async () => { const browser = {}; - const launch = vi.fn().mockResolvedValue(browser); + const launch = vi + .fn() + .mockRejectedValue(new Error("Executable doesn't exist")); + const connectOverCDP = vi.fn().mockResolvedValue(browser); const chromium = { - connectOverCDP: vi.fn(), + connectOverCDP, launch, } as unknown as PlaywrightModule["chromium"]; - serverMocks.requestBuilderBrowserConnection.mockRejectedValue( - new Error("Builder Browser unavailable"), - ); + serverMocks.requestBuilderBrowserConnection.mockResolvedValue({ + wsUrl: "wss://browser.example.test/cdp", + }); await expect(launchChromium(chromium)).resolves.toBe(browser); expect(launch).toHaveBeenCalledWith({ chromiumSandbox: true }); + expect(serverMocks.requestBuilderBrowserConnection).toHaveBeenCalledWith({ + sessionId: expect.stringMatching(/^design-render-/), + }); + expect(connectOverCDP).toHaveBeenCalledWith( + "wss://browser.example.test/cdp", + { timeout: 30_000 }, + ); + expect(launch.mock.invocationCallOrder[0]).toBeLessThan( + serverMocks.requestBuilderBrowserConnection.mock.invocationCallOrder[0], + ); }); it("fails closed with a typed error when no safe renderer is available", async () => { @@ -102,6 +110,11 @@ describe("launchChromium", () => { code: "chromium_unavailable", message: expect.stringContaining("Chromium unavailable:"), }); + await expect(launchPromise).rejects.toMatchObject({ + message: expect.stringContaining( + "Builder Browser unavailable: Builder Browser unavailable; sandboxed local Chromium unavailable: sandbox unavailable", + ), + }); expect( isMissingBrowserError( new ChromiumUnavailableError(new Error("connection unavailable")), diff --git a/templates/design/server/lib/playwright-runtime.ts b/templates/design/server/lib/playwright-runtime.ts index 805b80005a6..4a9ae34038b 100644 --- a/templates/design/server/lib/playwright-runtime.ts +++ b/templates/design/server/lib/playwright-runtime.ts @@ -61,22 +61,22 @@ async function connectBuilderBrowser( }); const wsUrl = typeof connection.wsUrl === "string" ? connection.wsUrl : ""; if (!wsUrl.trim()) throw new Error("Builder Browser did not return wsUrl."); - return chromium.connectOverCDP(wsUrl); + return chromium.connectOverCDP(wsUrl, { timeout: 30_000 }); } export async function launchChromium( chromium: import("@playwright/test").BrowserType, ): Promise { - let builderBrowserError: unknown; + let localBrowserError: unknown; try { - return await connectBuilderBrowser(chromium); + return await chromium.launch({ chromiumSandbox: true }); } catch (error) { - builderBrowserError = error; + localBrowserError = error; } try { - return await chromium.launch({ chromiumSandbox: true }); - } catch (localBrowserError) { + return await connectBuilderBrowser(chromium); + } catch (builderBrowserError) { const describe = (error: unknown) => error instanceof Error ? error.message : String(error); console.error( diff --git a/templates/design/server/lib/protect-hubl-tokens.ts b/templates/design/server/lib/protect-hubl-tokens.ts new file mode 100644 index 00000000000..d5db473b0fc --- /dev/null +++ b/templates/design/server/lib/protect-hubl-tokens.ts @@ -0,0 +1,31 @@ +type TokenSpan = { start: number; end: number; raw: string }; + +export function protectHubLTokens(source: string, tokens: TokenSpan[]) { + const reserved = new Set(source.match(/ANHUBL\d+TOKEN/g) ?? []); + const originals = new Map(); + const parts: string[] = []; + let cursor = 0; + let next = 0; + for (const token of tokens) { + let key: string; + do key = `ANHUBL${next++}TOKEN`; + while (reserved.has(key)); + parts.push(source.slice(cursor, token.start), key); + originals.set(key, token.raw); + cursor = token.end; + } + parts.push(source.slice(cursor)); + return { + html: parts.join(""), + restore( + this: void, + value: string, + transform = (token: string) => token, + ): string { + return value.replace(/ANHUBL\d+TOKEN/g, (key) => { + const token = originals.get(key); + return token === undefined ? key : transform(token); + }); + }, + }; +} diff --git a/templates/design/server/lib/screenshot-readiness.integration.spec.ts b/templates/design/server/lib/screenshot-readiness.integration.spec.ts new file mode 100644 index 00000000000..a1d33d9f1b6 --- /dev/null +++ b/templates/design/server/lib/screenshot-readiness.integration.spec.ts @@ -0,0 +1,260 @@ +import { readFile } from "node:fs/promises"; + +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createHubSpotEmailPreview } from "./hubspot-email-preview.js"; +import { prepareScreenshotPage } from "./screenshot-readiness.js"; + +let browser: Browser; +beforeAll(async () => { + browser = await chromium.launch({ chromiumSandbox: true }); +}); +afterAll(async () => { + await browser?.close(); +}); + +describe("screenshot readiness in Chromium", () => { + it("loads native lazy images below the viewport before capture", async () => { + const context = await browser.newContext({ + viewport: { width: 600, height: 800 }, + }); + try { + const page = await context.newPage(); + await page.route("https://example.test/lazy.png", (route) => + route.fulfill({ + contentType: "image/png", + body: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", + ), + }), + ); + await prepareScreenshotPage( + page, + '
Footer artwork', + ); + expect( + await page.locator("img").evaluate((element) => { + const image = element as HTMLImageElement; + return { + complete: image.complete, + width: image.naturalWidth, + belowViewport: image.getBoundingClientRect().top > innerHeight, + }; + }), + ).toEqual({ complete: true, width: 1, belowViewport: true }); + } finally { + await context.close(); + } + }); + + it.each(["link", "import"])( + "waits for a delayed %s stylesheet and its font declarations", + async (kind) => { + const context = await browser.newContext(); + const pendingRoutes: Promise[] = []; + try { + const page = await context.newPage(); + const font = await readFile( + new URL( + import.meta + .resolve("@fontsource-variable/inter/files/inter-latin-wght-normal.woff2"), + ), + ); + await page.route("https://example.test/delayed.css", (route) => { + const pending = new Promise((resolve) => + setTimeout(resolve, 1_200), + ).then(() => + route.fulfill({ + contentType: "text/css", + body: '@font-face{font-family:ScreenshotReadiness;src:url("https://example.test/font.woff2")}h1{color:rgb(1,2,3);font-family:ScreenshotReadiness}', + }), + ); + pendingRoutes.push(pending); + return pending; + }); + await page.route("https://example.test/font.woff2", (route) => { + const pending = new Promise((resolve) => + setTimeout(resolve, 200), + ).then(() => + route.fulfill({ contentType: "font/woff2", body: font }), + ); + pendingRoutes.push(pending); + return pending; + }); + const stylesheet = + kind === "link" + ? '' + : ''; + await prepareScreenshotPage( + page, + `${stylesheet}

Ready styles

`, + ); + expect( + await page.locator("h1").evaluate((heading) => ({ + color: getComputedStyle(heading).color, + fontReady: document.fonts.check("24px ScreenshotReadiness"), + })), + ).toEqual({ color: "rgb(1, 2, 3)", fontReady: true }); + } finally { + await Promise.all(pendingRoutes); + await context.close(); + } + }, + ); + + it.each(["network", "http"])( + "reports a failed %s stylesheet instead of capturing unstyled content", + async (failure) => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await page.route("https://example.test/failed.css", (route) => + failure === "network" + ? route.abort() + : route.fulfill({ status: 404, contentType: "text/css", body: "" }), + ); + await expect( + prepareScreenshotPage( + page, + 'Unstyled', + ), + ).rejects.toMatchObject({ errorCode: "screenshot_styles_failed" }); + } finally { + await context.close(); + } + }, + ); + + it("reports pending stylesheets when a stylesheet never finishes", async () => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await page.route("https://example.test/stalled.css", () => {}); + await expect( + prepareScreenshotPage( + page, + 'Unstyled', + ), + ).rejects.toMatchObject({ + errorCode: "screenshot_styles_unready", + details: { pendingStylesheets: ["https://example.test/stalled.css"] }, + }); + } finally { + await context.close(); + } + }); + + it("captures ready content while an unrelated network request remains open", async () => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + let pendingRequest = false; + await page.route("https://example.test/keep-open", () => { + pendingRequest = true; + }); + await prepareScreenshotPage( + page, + '

Ready email

', + ); + expect(pendingRequest).toBe(true); + expect(await page.locator("h1").textContent()).toBe("Ready email"); + const png = await page.screenshot({ timeout: 2_000 }); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + } finally { + await context.close(); + } + }); + + it("removes literal token overflow in a 375px sample preview while preserving genuine fixed-width overflow", async () => { + const context = await browser.newContext({ + viewport: { width: 375, height: 812 }, + }); + try { + const page = await context.newPage(); + const source = + '
{{ site_settings.company_street_address_1 }}
'; + await prepareScreenshotPage(page, source); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeGreaterThan(375); + const preview = createHubSpotEmailPreview(source); + await prepareScreenshotPage(page, preview.html); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(375); + const png = await page.screenshot({ timeout: 2_000 }); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + await prepareScreenshotPage( + page, + createHubSpotEmailPreview(source.replace('width="100%"', 'width="600"')) + .html, + ); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeGreaterThanOrEqual(600); + } finally { + await context.close(); + } + }); + + it("keeps a settled failed image inspectable in the rendered capture", async () => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await page.route("https://example.test/missing.png", (route) => + route.abort(), + ); + await prepareScreenshotPage( + page, + 'Hero', + ); + const brokenImages = await page.evaluate(() => + Array.from(document.images) + .filter((image) => image.complete && image.naturalWidth === 0) + .map((image) => image.getAttribute("src")), + ); + expect(brokenImages).toEqual(["https://example.test/missing.png"]); + const png = await page.screenshot({ timeout: 2_000 }); + expect(png.subarray(1, 4).toString()).toBe("PNG"); + } finally { + await context.close(); + } + }); + + it("renders recipient samples and exposes real long-name overflow without modifying raw HTML", async () => { + const context = await browser.newContext({ + viewport: { width: 375, height: 812 }, + }); + try { + const page = await context.newPage(); + const source = `

{{ personalization_token('contact.firstname', 'there') }}

`; + for (const samples of [ + undefined, + [{ field: "contact.firstname", value: "Alex" }], + [{ field: "contact.firstname", value: "A".repeat(80) }], + ]) { + await prepareScreenshotPage( + page, + createHubSpotEmailPreview(source, samples).html, + ); + expect(await page.locator("p").textContent()).toBe( + samples?.[0].value ?? "there", + ); + const width = await page.evaluate( + () => document.documentElement.scrollWidth, + ); + if (samples?.[0].value.length === 80) + expect(width).toBeGreaterThan(375); + else expect(width).toBe(375); + } + await prepareScreenshotPage(page, source); + expect(await page.locator("p").textContent()).toContain( + "personalization_token('contact.firstname', 'there')", + ); + } finally { + await context.close(); + } + }); +}); diff --git a/templates/design/server/lib/screenshot-readiness.spec.ts b/templates/design/server/lib/screenshot-readiness.spec.ts new file mode 100644 index 00000000000..d1d89e5815d --- /dev/null +++ b/templates/design/server/lib/screenshot-readiness.spec.ts @@ -0,0 +1,37 @@ +import type { Page } from "@playwright/test"; +import { describe, expect, it, vi } from "vitest"; + +import { prepareScreenshotPage } from "./screenshot-readiness.js"; + +function pageWithPendingImage(error: Error) { + return { + on: vi.fn(), + off: vi.fn(), + setContent: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue(["https://example.test/pending.png"]), + waitForFunction: vi.fn().mockRejectedValue(error), + } as unknown as Page; +} + +describe("screenshot readiness failure reporting", () => { + it("preserves a non-timeout browser failure instead of reporting an image timeout", async () => { + const failure = new Error("Execution context was destroyed"); + await expect( + prepareScreenshotPage(pageWithPendingImage(failure), ""), + ).rejects.toBe(failure); + }); + + it("reports actual pending images when image readiness times out", async () => { + const failure = new Error("Polling timed out"); + failure.name = "TimeoutError"; + await expect( + prepareScreenshotPage(pageWithPendingImage(failure), ""), + ).rejects.toMatchObject({ + errorCode: "screenshot_images_unready", + details: { + pendingImages: ["https://example.test/pending.png"], + cause: "Polling timed out", + }, + }); + }); +}); diff --git a/templates/design/server/lib/screenshot-readiness.ts b/templates/design/server/lib/screenshot-readiness.ts new file mode 100644 index 00000000000..1137d54f200 --- /dev/null +++ b/templates/design/server/lib/screenshot-readiness.ts @@ -0,0 +1,176 @@ +import { fail } from "@agent-native/core/action"; +import type { Page, Request, Response } from "@playwright/test"; + +export async function prepareScreenshotPage( + page: Page, + html: string, +): Promise { + const pendingStylesheetRequests = new Set(); + const failedStylesheets = new Map(); + let wakeStylesheets: (() => void) | undefined; + const requestStarted = (request: Request) => { + if (request.resourceType() !== "stylesheet") return; + pendingStylesheetRequests.add(request); + wakeStylesheets?.(); + }; + const requestFinished = (request: Request) => { + if (request.resourceType() !== "stylesheet") return; + pendingStylesheetRequests.delete(request); + wakeStylesheets?.(); + }; + const requestFailed = (request: Request) => { + if (request.resourceType() !== "stylesheet") return; + failedStylesheets.set( + request.url(), + request.failure()?.errorText ?? "Stylesheet request failed", + ); + requestFinished(request); + }; + const responseReceived = (response: Response) => { + if ( + response.request().resourceType() === "stylesheet" && + response.status() >= 400 + ) { + failedStylesheets.set(response.url(), `HTTP ${response.status()}`); + wakeStylesheets?.(); + } + }; + page.on("request", requestStarted); + page.on("requestfinished", requestFinished); + page.on("requestfailed", requestFailed); + page.on("response", responseReceived); + try { + await page.setContent(html, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + const pendingImages = () => + page.evaluate(() => { + for (const image of document.images) + if (image.loading === "lazy") image.loading = "eager"; + return Array.from(document.images) + .filter((image) => !image.complete) + .map( + (image) => + image.currentSrc || + image.getAttribute("src") || + "(image without src)", + ); + }); + if ((await pendingImages()).length > 0) { + try { + await page.waitForFunction( + () => Array.from(document.images).every((image) => image.complete), + undefined, + { timeout: 5_000, polling: 100 }, + ); + } catch (error) { + if (!(error instanceof Error) || error.name !== "TimeoutError") + throw error; + const pending = await pendingImages(); + if (pending.length) + fail("Screenshot images did not finish loading within 5 seconds.", { + errorCode: "screenshot_images_unready", + statusCode: 422, + details: { pendingImages: pending, cause: error.message }, + }); + } + } + const stylesheetDeadline = Date.now() + 5_000; + while (true) { + const pendingSheets = await page.evaluate(() => { + const pending = new Set(); + const visited = new Set(); + const visit = (sheet: CSSStyleSheet): void => { + if (sheet.disabled || visited.has(sheet)) return; + visited.add(sheet); + let rules: CSSRule[]; + try { + rules = Array.from(sheet.cssRules); + } catch (error) { + // Cross-origin sheet contents rely on the tracked stylesheet requests. + if (error instanceof DOMException && error.name === "SecurityError") + return; + throw error; + } + for (const rule of rules) { + if (rule.type !== CSSRule.IMPORT_RULE) continue; + const imported = rule as CSSImportRule; + if (imported.styleSheet) visit(imported.styleSheet); + else pending.add(imported.href); + } + }; + for (const link of document.querySelectorAll( + 'link[rel~="stylesheet" i][href]', + )) { + if ( + !link.disabled && + link.getAttribute("href")?.trim() && + !link.sheet + ) + pending.add(link.href); + } + for (const sheet of document.styleSheets) visit(sheet); + return [...pending]; + }); + if (failedStylesheets.size) + fail("Screenshot stylesheets could not be loaded.", { + errorCode: "screenshot_styles_failed", + statusCode: 422, + details: { + failedStylesheets: [...failedStylesheets] + .slice(0, 20) + .map(([url, cause]) => ({ url, cause })), + }, + }); + const pending = [ + ...new Set([ + ...pendingSheets, + ...[...pendingStylesheetRequests].map((request) => request.url()), + ]), + ]; + if (!pending.length) break; + const remaining = stylesheetDeadline - Date.now(); + if (remaining <= 0) + fail( + "Screenshot stylesheets did not finish loading within 5 seconds.", + { + errorCode: "screenshot_styles_unready", + statusCode: 422, + details: { pendingStylesheets: pending.slice(0, 20) }, + }, + ); + await new Promise((resolve) => { + const settle = () => { + clearTimeout(timer); + wakeStylesheets = undefined; + resolve(); + }; + const timer = setTimeout(settle, Math.min(100, remaining)); + wakeStylesheets = settle; + }); + } + const fontsReady = await page.evaluate(async () => { + document.documentElement.getBoundingClientRect(); + if (!document.fonts) return true; + return Promise.race([ + document.fonts.ready.then(() => true), + new Promise((resolve) => + setTimeout(() => resolve(false), 4_000), + ), + ]); + }); + if (!fontsReady) { + fail("Screenshot fonts did not finish loading within 4 seconds.", { + errorCode: "screenshot_fonts_unready", + statusCode: 422, + }); + } + } finally { + wakeStylesheets?.(); + page.off("request", requestStarted); + page.off("requestfinished", requestFinished); + page.off("requestfailed", requestFailed); + page.off("response", responseReceived); + } +} diff --git a/templates/design/server/plugins/agent-chat.ts b/templates/design/server/plugins/agent-chat.ts index 2aba7e73f88..c27042994be 100644 --- a/templates/design/server/plugins/agent-chat.ts +++ b/templates/design/server/plugins/agent-chat.ts @@ -328,6 +328,8 @@ When a user message begins with [Selection question], answer about the captured When the user asks for a new design and the current navigation view is list, settings, design-systems, or otherwise has no designId, create a new design first. Do not reuse, delete screens from, or edit a previous design unless the user explicitly names that design or the current navigation state is an editor/present view with that designId. +For HubSpot email creation, alignment, token edits, conversion or HTML handoff, read hubspot-email; it owns the email workflow and replaces web defaults. + App and website UI is responsive by default when the user does not specify exact pixel dimensions. Use mobile-first CSS, a viewport meta tag, and responsive layout changes for narrow widths; never ship a fixed-width desktop shell. Static artwork such as ads, banners, social posts, thumbnails, email headers, flyers, and posters uses one fixed canvas with no mobile or tablet frames, including on follow-up edits and resizes; each additional size or version is its own exact-size call. Honor exact pixel dimensions and platform presets in the original request. Desktop is the default primary artboard for responsive UI; use a 1440×1024 canvas frame (or primaryViewport "desktop") unless the user explicitly asks for a mobile- or tablet-primary design. Keep each generation action prompt to one distinct exact size; make separate calls scoped to each screen when a request includes different sizes. For responsive UI, inspect desktop and mobile screenshots and correct overflow or broken reflow. For fixed artwork, inspect one screenshot at the requested canvas size. Treat explicit visual direction, requested content, named pages, and page counts as acceptance criteria. When the user asks for multiple distinct pages or states, call generate-screens with every requested page before generating their files; do not substitute responsive breakpoint frames for requested pages. Verify the saved design contains each requested page before reporting completion. diff --git a/templates/design/server/source-workspace.batch.spec.ts b/templates/design/server/source-workspace.batch.spec.ts index 37e2f694890..5828edafea7 100644 --- a/templates/design/server/source-workspace.batch.spec.ts +++ b/templates/design/server/source-workspace.batch.spec.ts @@ -40,6 +40,7 @@ import { import { closeDbExec, getDbExec } from "@agent-native/core/db"; import { eq, sql } from "drizzle-orm"; +import { createHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { sourceContentHash } from "../shared/source-workspace.js"; import { getDb, schema } from "./db/index.js"; import { @@ -194,6 +195,39 @@ afterAll(async () => { }); describe("writeInlineSourceFilesBatch", () => { + it("rejects a batch that removes an email marker before changing any file", async () => { + const email = createHubSpotEmailHtml(); + await execute("UPDATE design_files SET content = ? WHERE id = ?", [ + email, + DESTINATION_ID, + ]); + await applyText(DESTINATION_ID, email); + const before = await persistedFiles(); + await expect( + writeInlineSourceFilesBatch({ + designId: DESIGN_ID, + files: [ + { + file: sourceFile(SOURCE_ID, "source.html", SOURCE_BASE), + content: SOURCE_NEXT, + expectedVersionHash: sourceContentHash(SOURCE_BASE), + }, + { + file: sourceFile(DESTINATION_ID, "destination.html", email), + content: email.replace( + '', + "", + ), + expectedVersionHash: sourceContentHash(email), + }, + ], + }), + ).rejects.toThrow("Preserve the HubSpot email frame marker"); + expect(await persistedFiles()).toEqual(before); + expect(await getText(SOURCE_ID)).toBe(SOURCE_BASE); + expect(await getText(DESTINATION_ID)).toBe(email); + }); + it("rolls back prepared collaboration state with the source transaction", async () => { await expect( withPreparedSourceFileMutation(SOURCE_ID, "agent", (lease) => diff --git a/templates/design/server/source-workspace.ts b/templates/design/server/source-workspace.ts index 082ab9568b1..5a2652f3166 100644 --- a/templates/design/server/source-workspace.ts +++ b/templates/design/server/source-workspace.ts @@ -18,6 +18,8 @@ import { assertDesignHtmlEditIntegrity, isDesignHtmlIntegrityError, } from "../shared/html-integrity.js"; +import { assertHubSpotEmailFrameContent } from "../shared/hubspot-email-authoring.js"; +import { isHubSpotEmailHtml } from "../shared/hubspot-email.js"; import { assertLockedLayersPreserved } from "../shared/locked-layers.js"; import { designSourceTypeFromData } from "../shared/source-mode.js"; import type { DesignSourceType } from "../shared/source-mode.js"; @@ -576,6 +578,14 @@ export async function writeInlineSourceFile(args: { content: liveContent, versionHash: sourceContentHash(liveContent), }; + const assertEmailCandidate = (content: string) => + assertHubSpotEmailFrameContent({ + content, + fileType: currentFile.fileType ?? args.file.fileType ?? "html", + previousContent: isHubSpotEmailHtml(current.content) + ? current.content + : (currentFile.content ?? ""), + }); const validatePreparedNoop = async () => { const preparedCollaboration = await lockPreparedSourceCollaboration( getDesignSourceMutationExec(tx), @@ -710,6 +720,7 @@ export async function writeInlineSourceFile(args: { } } + assertEmailCandidate(args.content); const changed = args.content !== current.content; const updatedAt = new Date().toISOString(); if (!changed && !identityOnly) { @@ -762,6 +773,7 @@ export async function writeInlineSourceFile(args: { const authoritativeContent = readPreparedSourceText(lease); try { + assertEmailCandidate(authoritativeContent); if (identityOnly && authoritativeContent !== args.content) { throw new SourceWorkspaceEditConflictError( "A concurrent source edit prevented identity-only publication.", @@ -1023,6 +1035,11 @@ export async function writeInlineSourceFilesBatch(args: { ); } assertLockedLayersPreserved(liveContent, content); + assertHubSpotEmailFrameContent({ + content, + fileType: currentFile.fileType ?? file.fileType ?? "html", + previousContent: liveContent, + }); assertDesignHtmlEditIntegrity({ previousContent: liveContent, nextContent: content, @@ -1100,6 +1117,11 @@ export async function writeInlineSourceFilesBatch(args: { ); } assertLockedLayersPreserved(liveContent, item.content); + assertHubSpotEmailFrameContent({ + content: item.content, + fileType: current.fileType ?? "html", + previousContent: liveContent, + }); assertDesignHtmlEditIntegrity({ previousContent: liveContent, nextContent: item.content, diff --git a/templates/design/shared/hubl-tokens.spec.ts b/templates/design/shared/hubl-tokens.spec.ts new file mode 100644 index 00000000000..a94a47c4182 --- /dev/null +++ b/templates/design/shared/hubl-tokens.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { scanHubLInterpolations } from "./hubl-tokens.js"; + +describe("HubL interpolation scanning", () => { + it.each([ + "{{ site_settings.company_name }}", + "{{ personalization_token('contact.firstname', 'hello }} there') }}", + '{{ personalization_token("contact.firstname", "hello }} there") }}', + String.raw`{{ personalization_token('contact.firstname', 'it\'s }} here') }}`, + String.raw`{{ personalization_token("contact.firstname", "a \"quote\" }} here") }}`, + String.raw`{{ personalization_token('contact.firstname', 'backslash \\') }}`, + "{{ '{{' }}", + ])("preserves the complete token %s and exclusive offsets", (raw) => { + expect(scanHubLInterpolations(`Hi ${raw}!`)).toEqual({ + tokens: [{ start: 3, end: 3 + raw.length, raw }], + }); + }); + + it("finds adjacent interpolations without consuming surrounding text", () => { + expect(scanHubLInterpolations("a{{ x }}{{ y }}z")).toEqual({ + tokens: [ + { start: 1, end: 8, raw: "{{ x }}" }, + { start: 8, end: 15, raw: "{{ y }}" }, + ], + }); + }); + + it.each(["{{", "{{ '{{'", "{{ 'trailing escape \\"])( + "reports the first unclosed opener in %s", + (raw) => { + expect(scanHubLInterpolations(`a{{ x }}b${raw}{{ another`)).toEqual({ + tokens: [{ start: 1, end: 8, raw: "{{ x }}" }], + unclosedAt: 9, + }); + }, + ); + + it("does not interpret lone braces as interpolation delimiters", () => { + expect(scanHubLInterpolations("a { b } }} c")).toEqual({ tokens: [] }); + }); +}); diff --git a/templates/design/shared/hubl-tokens.ts b/templates/design/shared/hubl-tokens.ts new file mode 100644 index 00000000000..1f73b190058 --- /dev/null +++ b/templates/design/shared/hubl-tokens.ts @@ -0,0 +1,55 @@ +type HubLToken = { start: number; end: number; raw: string }; + +function scanTokens( + content: string, + includeControlTags: boolean, +): { + tokens: HubLToken[]; + unclosedAt?: number; +} { + const tokens: HubLToken[] = []; + let cursor = 0; + while (cursor < content.length) { + const next = content[cursor + 1]; + if ( + content[cursor] !== "{" || + (next !== "{" && !(includeControlTags && next === "%")) + ) { + cursor += 1; + continue; + } + const closing = next === "%" ? "%" : "}"; + const start = cursor; + cursor += 2; + let quote: string | undefined; + let end: number | undefined; + while (cursor < content.length) { + const character = content[cursor]; + if (quote) { + if (character === "\\") { + cursor += 2; + continue; + } + if (character === quote) quote = undefined; + } else if (character === "'" || character === '"') { + quote = character; + } else if (character === closing && content[cursor + 1] === "}") { + end = cursor + 2; + break; + } + cursor += 1; + } + if (end === undefined) return { tokens, unclosedAt: start }; + tokens.push({ start, end, raw: content.slice(start, end) }); + cursor = end; + } + return { tokens }; +} + +export function scanHubLInterpolations(content: string) { + return scanTokens(content, false); +} + +export function scanHubLTemplateTokens(content: string) { + return scanTokens(content, true); +} diff --git a/templates/design/shared/hubspot-email-authoring.spec.ts b/templates/design/shared/hubspot-email-authoring.spec.ts new file mode 100644 index 00000000000..7415effafb7 --- /dev/null +++ b/templates/design/shared/hubspot-email-authoring.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { assertHubSpotEmailFrameContent } from "./hubspot-email-authoring.js"; +import { createHubSpotEmailHtml } from "./hubspot-email.js"; + +describe("HubSpot email frame authoring contract", () => { + it("rejects changing a marked email into a non-HTML file", () => { + expect(() => + assertHubSpotEmailFrameContent({ + content: createHubSpotEmailHtml(), + fileType: "jsx", + }), + ).toThrow("must be HTML files"); + }); +}); diff --git a/templates/design/shared/hubspot-email-authoring.ts b/templates/design/shared/hubspot-email-authoring.ts new file mode 100644 index 00000000000..327e64c285c --- /dev/null +++ b/templates/design/shared/hubspot-email-authoring.ts @@ -0,0 +1,35 @@ +import { fail } from "@agent-native/core/action"; + +import { isHubSpotEmailHtml } from "./hubspot-email.js"; + +export function assertHubSpotEmailFrameContent({ + content, + fileType, + frameType, + previousContent, +}: { + content: string; + fileType: string; + frameType?: "hubspot-email"; + previousContent?: string; +}): void { + const marked = isHubSpotEmailHtml(content); + if (frameType === "hubspot-email" && !marked) { + fail( + 'A HubSpot email frame requires in its HTML head. Rebuild the content using the HubSpot email specification before saving.', + { statusCode: 422, errorCode: "hubspot_email_marker_required" }, + ); + } + if (previousContent && isHubSpotEmailHtml(previousContent) && !marked) { + fail("Preserve the HubSpot email frame marker when editing this frame.", { + statusCode: 422, + errorCode: "hubspot_email_marker_required", + }); + } + if (marked && fileType !== "html") { + fail("HubSpot email frames must be HTML files.", { + statusCode: 422, + errorCode: "hubspot_email_html_required", + }); + } +} diff --git a/templates/design/shared/hubspot-email.spec.ts b/templates/design/shared/hubspot-email.spec.ts new file mode 100644 index 00000000000..197e17ab5ab --- /dev/null +++ b/templates/design/shared/hubspot-email.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { createHubSpotEmailHtml, isHubSpotEmailHtml } from "./hubspot-email.js"; + +const MARKER = ''; + +function documentWithHead(head: string, body = "Ordinary frame") { + return `${head}${body}`; +} + +describe("HubSpot email frame classification", () => { + it("recognizes the authored email document", () => { + expect(isHubSpotEmailHtml(createHubSpotEmailHtml())).toBe(true); + }); + + it.each([ + MARKER, + "", + "", + '', + ])("recognizes actual metadata with valid HTML attributes (%s)", (marker) => { + expect(isHubSpotEmailHtml(documentWithHead(marker))).toBe(true); + }); + + it.each([ + ``, + ``, + ``, + `${MARKER}`, + ``, + ``, + '', + "{{ \"\" }}", + "{{ \"}} \" }}", + String.raw`{{ "escaped \" }} " }}`, + `{{ '${MARKER}`, + ])("does not classify inert marker lookalikes (%s)", (head) => { + expect(isHubSpotEmailHtml(documentWithHead(head))).toBe(false); + }); + + it("requires the metadata to belong to the head", () => { + expect(isHubSpotEmailHtml(documentWithHead("", MARKER))).toBe(false); + }); + + it("does not let HubL quoted defaults corrupt head parsing", () => { + const head = + ' }} there") }}">' + + MARKER; + expect(isHubSpotEmailHtml(documentWithHead(head))).toBe(true); + }); + + it("classifies many unclosed HubL openers within the interaction budget", () => { + const source = "{{".repeat(40_000); + const start = performance.now(); + expect(isHubSpotEmailHtml(source)).toBe(false); + expect(performance.now() - start).toBeLessThan(2_000); + }); + + it("rejects ordinary metadata", () => { + const source = documentWithHead( + '', + ); + expect(isHubSpotEmailHtml(source)).toBe(false); + }); +}); diff --git a/templates/design/shared/hubspot-email.ts b/templates/design/shared/hubspot-email.ts new file mode 100644 index 00000000000..bf336e1054e --- /dev/null +++ b/templates/design/shared/hubspot-email.ts @@ -0,0 +1,100 @@ +import { parse } from "parse5"; + +import { scanHubLInterpolations } from "./hubl-tokens.js"; + +export const HUBSPOT_EMAIL_FRAME_TYPE = "hubspot-email"; + +export function isHubSpotEmailHtml(content: string): boolean { + // HubL string literals may contain HTML attribute delimiters or closing braces. + const scanned = scanHubLInterpolations(content); + if (scanned.unclosedAt !== undefined) return false; + const parts: string[] = []; + let cursor = 0; + for (const token of scanned.tokens) { + parts.push(content.slice(cursor, token.start), "ANHUBL"); + cursor = token.end; + } + parts.push(content.slice(cursor)); + const document = parse(parts.join("")); + const html = document.childNodes.find( + (node) => "tagName" in node && node.tagName === "html", + ); + const head = + html && "childNodes" in html + ? html.childNodes.find( + (node) => "tagName" in node && node.tagName === "head", + ) + : undefined; + return !!( + head && + "childNodes" in head && + head.childNodes.some( + (node) => + "tagName" in node && + node.tagName === "meta" && + node.attrs + .find((attribute) => attribute.name === "name") + ?.value.toLowerCase() === "agent-native:frame-type" && + node.attrs + .find((attribute) => attribute.name === "content") + ?.value.toLowerCase() === HUBSPOT_EMAIL_FRAME_TYPE, + ) + ); +} + +export function createHubSpotEmailHtml({ + title = "Email", +}: { title?: string } = {}): string { + const escapedTitle = title.replace(/[&<>"']/g, (character) => { + return { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[character]!; + }); + return ` + + + + + + + ${escapedTitle} + + + +
A short preview of your email.
+
+ + + + +
+ +`; +}