From 780185bf2e3158faf01b4307ce7657b8d26e8a1b Mon Sep 17 00:00:00 2001 From: Paul Somers Date: Thu, 12 Mar 2026 21:09:38 +0000 Subject: [PATCH] feat: add workflow form submission via --field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `--field Title=value` support to `workflow run`, enabling form submission through a short-lived RTM WebSocket that captures view_opened events and submits via views.submit. New modules: - rtm-websocket: low-level Bun-compatible WebSocket over node:https - workflow-submit: orchestrates trip → capture → submit flow --- README.md | 2 +- skills/agent-slack/SKILL.md | 1 + skills/agent-slack/references/commands.md | 2 +- src/cli/workflow-command.ts | 88 +++++++-- src/lib/rtm-websocket.ts | 208 ++++++++++++++++++++++ src/slack/workflow-submit.ts | 149 ++++++++++++++++ test/rtm-websocket.test.ts | 159 +++++++++++++++++ test/workflow-submit.test.ts | 127 +++++++++++++ 8 files changed, 722 insertions(+), 14 deletions(-) create mode 100644 src/lib/rtm-websocket.ts create mode 100644 src/slack/workflow-submit.ts create mode 100644 test/rtm-websocket.test.ts create mode 100644 test/workflow-submit.test.ts diff --git a/README.md b/README.md index 1512116..2a66f6b 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ agent-slack │ ├── list # workflows bookmarked in a channel │ ├── preview # trigger metadata (no side effects) │ ├── get # workflow definition + form fields -│ └── run # trip a workflow trigger +│ └── run # trip a trigger (--field for form submission) └── canvas └── get # canvas → markdown ``` diff --git a/skills/agent-slack/SKILL.md b/skills/agent-slack/SKILL.md index 558033c..a000a41 100644 --- a/skills/agent-slack/SKILL.md +++ b/skills/agent-slack/SKILL.md @@ -33,6 +33,7 @@ agent-slack unreads agent-slack later list agent-slack canvas get "CANVAS_URL" agent-slack workflow list "general" +agent-slack workflow run "Ft123ABC" --channel "general" --field "Title=My request" agent-slack user list agent-slack channel list agent-slack user dm-open @alice @bob diff --git a/skills/agent-slack/references/commands.md b/skills/agent-slack/references/commands.md index a4473a3..d330194 100644 --- a/skills/agent-slack/references/commands.md +++ b/skills/agent-slack/references/commands.md @@ -193,7 +193,7 @@ Common options: - `agent-slack workflow list [--workspace ]` — list workflows bookmarked or featured in a channel - `agent-slack workflow preview [--workspace ]` — get workflow metadata from a trigger ID (no side effects) - `agent-slack workflow get [--workspace ]` — get workflow definition including form fields and steps (accepts `Ft...` or `Wf...`) -- `agent-slack workflow run --channel [--workspace ]` — trip a workflow trigger +- `agent-slack workflow run --channel [--field ]... [--workspace ]` — trip a workflow trigger; with `--field`, submits form data via RTM WebSocket (requires browser auth) ## Users diff --git a/src/cli/workflow-command.ts b/src/cli/workflow-command.ts index 0160b16..eb68515 100644 --- a/src/cli/workflow-command.ts +++ b/src/cli/workflow-command.ts @@ -9,11 +9,21 @@ import { resolveShortcutUrl, runWorkflow, } from "../slack/workflows.ts"; +import { + requireBrowserAuth, + submitWorkflow, + validateFieldInputs, +} from "../slack/workflow-submit.ts"; type WorkspaceOption = { workspace?: string; }; +type RunOptions = WorkspaceOption & { + channel: string; + field?: string[]; +}; + export function registerWorkflowCommand(input: { program: Command; ctx: CliContext }): void { const workflowCmd = input.program .command("workflow") @@ -105,27 +115,81 @@ export function registerWorkflowCommand(input: { program: Command; ctx: CliConte workflowCmd .command("run") - .description("Trip a workflow trigger") + .description("Trip a workflow trigger (with --field, submits form data)") .argument("", "Trigger ID (Ft...)") .requiredOption("--channel ", "Channel where the workflow is bookmarked") + .option( + "--field ", + "Form field value (repeatable)", + (v, prev: string[]) => { + prev.push(v); + return prev; + }, + [] as string[], + ) .option( "--workspace ", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)", ) .action(async (...args) => { - const [triggerId, options] = args as [string, WorkspaceOption & { channel: string }]; + const [triggerId, options] = args as [string, RunOptions]; try { const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace); - const payload = await input.ctx.withAutoRefresh({ - workspaceUrl, - work: async () => { - const { client } = await input.ctx.getClientForWorkspace(workspaceUrl); - const channelId = await resolveChannelId(client, options.channel); - const shortcutUrl = await resolveShortcutUrl(client, { channelId, triggerId }); - return await runWorkflow(client, { shortcutUrl, channelId, triggerId }); - }, - }); - console.log(JSON.stringify(pruneEmpty(payload), null, 2)); + const fieldArgs = options.field ?? []; + + if (fieldArgs.length === 0) { + // Trip-only (existing behavior, no WebSocket) + const payload = await input.ctx.withAutoRefresh({ + workspaceUrl, + work: async () => { + const { client } = await input.ctx.getClientForWorkspace(workspaceUrl); + const channelId = await resolveChannelId(client, options.channel); + const shortcutUrl = await resolveShortcutUrl(client, { channelId, triggerId }); + return await runWorkflow(client, { shortcutUrl, channelId, triggerId }); + }, + }); + console.log(JSON.stringify(pruneEmpty(payload), null, 2)); + } else { + // Parse --field args + const fields = new Map(); + for (const arg of fieldArgs) { + const eqIdx = arg.indexOf("="); + if (eqIdx < 1) { + throw new Error(`Invalid --field format: "${arg}". Expected Title=value`); + } + fields.set(arg.substring(0, eqIdx), arg.substring(eqIdx + 1)); + } + + const payload = await input.ctx.withAutoRefresh({ + workspaceUrl, + work: async () => { + const { client, auth } = await input.ctx.getClientForWorkspace(workspaceUrl); + requireBrowserAuth(auth); + + const channelId = await resolveChannelId(client, options.channel); + + // Preview → schema → validate before opening WebSocket + const preview = await previewWorkflow(client, triggerId); + const schema = await getWorkflowSchema(client, preview.workflow.id); + const errors = validateFieldInputs(fields, schema); + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + const shortcutUrl = await resolveShortcutUrl(client, { channelId, triggerId }); + return await submitWorkflow({ + client, + auth, + shortcutUrl, + channelId, + triggerId, + fields, + schema, + }); + }, + }); + console.log(JSON.stringify(pruneEmpty(payload), null, 2)); + } } catch (err: unknown) { console.error(input.ctx.errorMessage(err)); process.exitCode = 1; diff --git a/src/lib/rtm-websocket.ts b/src/lib/rtm-websocket.ts new file mode 100644 index 0000000..5e7541c --- /dev/null +++ b/src/lib/rtm-websocket.ts @@ -0,0 +1,208 @@ +/** + * Short-lived RTM WebSocket connection using node:https upgrade. + * Bun delivers HTTP 101 via the response callback (not the 'upgrade' event), + * so we read WebSocket frames directly from the response stream. + */ +import { request } from "node:https"; + +export type RtmConnection = { + waitForMessage: ( + predicate: (msg: Record) => boolean, + timeoutMs?: number, + ) => Promise>; + close: () => void; +}; + +export function connectRtm(input: { + wsUrl: string; + cookie: string; + timeoutMs?: number; +}): Promise { + const { wsUrl, cookie, timeoutMs = 5000 } = input; + const url = new URL(wsUrl.replace("wss://", "https://")); + const keyBytes = new Uint8Array(16); + crypto.getRandomValues(keyBytes); + const wsKey = btoa(String.fromCharCode(...keyBytes)); + + const messages: Record[] = []; + const listeners: ((msg: Record) => void)[] = []; + let buffer: Buffer = Buffer.alloc(0); + let req: ReturnType | null = null; + let res: { destroy: () => void } | null = null; + let closed = false; + + function onFrame(text: string): void { + try { + const parsed: unknown = JSON.parse(text); + if (typeof parsed === "object" && parsed !== null) { + const msg = parsed as Record; + messages.push(msg); + for (const listener of listeners) { + listener(msg); + } + } + } catch { + // ignore non-JSON frames + } + } + + function close(): void { + if (closed) { + return; + } + closed = true; + try { + res?.destroy(); + } catch { + // ignore + } + try { + req?.destroy(); + } catch { + // ignore + } + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + close(); + reject(new Error("RTM WebSocket connection timed out")); + }, timeoutMs); + + req = request( + { + hostname: url.hostname, + port: 443, + path: url.pathname + url.search, + method: "GET", + headers: { + Upgrade: "websocket", + Connection: "Upgrade", + Cookie: cookie, + Origin: "https://app.slack.com", + "Sec-WebSocket-Key": wsKey, + "Sec-WebSocket-Version": "13", + }, + }, + (response) => { + res = response; + if (response.statusCode !== 101) { + clearTimeout(timeout); + close(); + reject(new Error(`RTM WebSocket connection failed: HTTP ${response.statusCode}`)); + return; + } + + response.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + const { texts, remaining } = extractTextFrames(buffer); + buffer = remaining; + for (const text of texts) { + onFrame(text); + } + }); + + // Wait briefly for the hello message before resolving + setTimeout(() => { + clearTimeout(timeout); + resolve({ waitForMessage, close }); + }, 300); + }, + ); + + req.on("error", (err: Error) => { + clearTimeout(timeout); + close(); + reject(new Error(`RTM WebSocket connection failed: ${err.message}`)); + }); + + req.end(); + }); + + function waitForMessage( + predicate: (msg: Record) => boolean, + waitTimeoutMs = 15000, + ): Promise> { + // Check already-received messages first + for (const msg of messages) { + if (predicate(msg)) { + return Promise.resolve(msg); + } + } + + return new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + const idx = listeners.indexOf(listener); + if (idx >= 0) { + listeners.splice(idx, 1); + } + reject(new Error(`Timed out waiting for message (${waitTimeoutMs / 1000}s)`)); + }, waitTimeoutMs); + + function listener(msg: Record): void { + if (predicate(msg)) { + clearTimeout(timer); + const idx = listeners.indexOf(listener); + if (idx >= 0) { + listeners.splice(idx, 1); + } + resolve(msg); + } + } + + listeners.push(listener); + }); + } +} + +/** + * Parse WebSocket text frames from a raw byte buffer. + * Server→client frames are unmasked per RFC 6455. + */ +export function extractTextFrames(buf: Buffer): { + texts: string[]; + remaining: Buffer; +} { + const texts: string[] = []; + let pos = 0; + + while (pos < buf.length) { + if (buf.length - pos < 2) { + break; + } + const opcode = buf[pos]! & 0x0f; + const masked = (buf[pos + 1]! & 0x80) !== 0; + let payloadLen = buf[pos + 1]! & 0x7f; + let headerLen = 2; + + if (payloadLen === 126) { + if (buf.length - pos < 4) { + break; + } + payloadLen = buf.readUInt16BE(pos + 2); + headerLen = 4; + } else if (payloadLen === 127) { + if (buf.length - pos < 10) { + break; + } + payloadLen = Number(buf.readBigUInt64BE(pos + 2)); + headerLen = 10; + } + if (masked) { + headerLen += 4; + } + + const totalLen = headerLen + payloadLen; + if (buf.length - pos < totalLen) { + break; + } + + if (opcode === 1) { + const payload = buf.subarray(pos + headerLen, pos + totalLen); + texts.push(payload.toString("utf8")); + } + pos += totalLen; + } + + return { texts, remaining: buf.subarray(pos) }; +} diff --git a/src/slack/workflow-submit.ts b/src/slack/workflow-submit.ts new file mode 100644 index 0000000..e175672 --- /dev/null +++ b/src/slack/workflow-submit.ts @@ -0,0 +1,149 @@ +import type { SlackApiClient, SlackAuth } from "./client.ts"; +import type { FormField, WorkflowSchema } from "./workflows.ts"; +import { connectRtm, type RtmConnection } from "../lib/rtm-websocket.ts"; +import { runWorkflow } from "./workflows.ts"; +import { asArray, getString, isRecord } from "../lib/object-type-guards.ts"; + +export type WorkflowSubmitResult = { + function_execution_id: string; + trigger_execution_id: string; + view_id: string; + submitted: boolean; +}; + +export function requireBrowserAuth( + auth: SlackAuth, +): asserts auth is Extract { + if (auth.auth_type !== "browser") { + throw new Error( + "Form submission requires browser auth (xoxc/xoxd). Standard bot tokens cannot submit workflow forms.", + ); + } +} + +export function validateFieldInputs(fields: Map, schema: WorkflowSchema): string[] { + const errors: string[] = []; + const available = schema.fields.map((f) => f.title); + const titleToField = new Map(); + for (const f of schema.fields) { + titleToField.set(f.title.toLowerCase(), f); + } + + // Check for unknown field titles + for (const [title] of fields) { + if (!titleToField.has(title.toLowerCase())) { + errors.push(`Unknown field "${title}". Available: ${available.join(", ")}`); + } + } + + // Check for missing required fields + for (const f of schema.fields) { + if (f.required && !fields.has(f.title.toLowerCase()) && !fields.has(f.title)) { + errors.push(`Required field "${f.title}" is missing`); + } + } + + return errors; +} + +export async function submitWorkflow(input: { + client: SlackApiClient; + auth: Extract; + shortcutUrl: string; + channelId: string; + triggerId: string; + fields: Map; + schema: WorkflowSchema; +}): Promise { + const { client, auth, shortcutUrl, channelId, triggerId, fields, schema } = input; + const cookie = `d=${encodeURIComponent(auth.xoxd_cookie)}`; + + // Build title→FormField lookup (case-insensitive) + const titleToField = new Map(); + for (const f of schema.fields) { + titleToField.set(f.title.toLowerCase(), f); + } + + // Step 1: RTM connect + const rtmResp = await client.api("rtm.connect", {}); + const wsUrl = getString(rtmResp.url); + if (!wsUrl) { + throw new Error("rtm.connect did not return a WebSocket URL"); + } + + let rtm: RtmConnection | null = null; + try { + // Step 2: Open WebSocket + rtm = await connectRtm({ wsUrl, cookie }); + + // Step 3: Start waiting for view_opened BEFORE tripping (it can arrive before trip resolves) + const viewPromise = rtm.waitForMessage( + (msg) => msg.type === "view_opened" || msg.type === "view_push", + 15000, + ); + + // Step 4: Trip trigger + const tripResult = await runWorkflow(client, { shortcutUrl, channelId, triggerId }); + + // Step 5: Wait for view_opened + const viewMsg = await viewPromise; + const view = isRecord(viewMsg.view) ? viewMsg.view : {}; + const viewId = getString(view.id); + if (!viewId) { + throw new Error("view_opened event did not contain a view_id"); + } + + // Step 6: Extract block_id → action_id mapping from the view + const blocks = asArray(view.blocks).filter(isRecord); + const stateValues: Record> = {}; + + for (const block of blocks) { + const blockId = getString(block.block_id); + if (!blockId) { + continue; + } + + const element = isRecord(block.element) ? block.element : {}; + const actionId = getString(element.action_id); + if (!actionId) { + continue; + } + + // Match action_id (field UUID) to a schema field by name + const schemaField = schema.fields.find((f) => f.name === actionId); + if (!schemaField) { + continue; + } + + // Look up user-supplied value by title (case-insensitive) + const userValue = + fields.get(schemaField.title.toLowerCase()) ?? fields.get(schemaField.title); + if (userValue === undefined) { + continue; + } + + stateValues[blockId] = { + [actionId]: { + type: "plain_text_input", + value: userValue, + }, + }; + } + + // Step 7: Submit + await client.api("views.submit", { + view_id: viewId, + client_token: `cli-${Date.now()}`, + state: JSON.stringify({ values: stateValues }), + }); + + return { + function_execution_id: tripResult.function_execution_id, + trigger_execution_id: tripResult.trigger_execution_id, + view_id: viewId, + submitted: true, + }; + } finally { + rtm?.close(); + } +} diff --git a/test/rtm-websocket.test.ts b/test/rtm-websocket.test.ts new file mode 100644 index 0000000..439957a --- /dev/null +++ b/test/rtm-websocket.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; +import { extractTextFrames } from "../src/lib/rtm-websocket.ts"; + +/** Build an unmasked WebSocket text frame (opcode 1) for a UTF-8 string. */ +function textFrame(text: string): Buffer { + const payload = Buffer.from(text, "utf8"); + const len = payload.length; + + if (len < 126) { + const header = Buffer.alloc(2); + header[0] = 0x81; // FIN + opcode 1 (text) + header[1] = len; + return Buffer.concat([header, payload]); + } + if (len < 65536) { + const header = Buffer.alloc(4); + header[0] = 0x81; + header[1] = 126; + header.writeUInt16BE(len, 2); + return Buffer.concat([header, payload]); + } + const header = Buffer.alloc(10); + header[0] = 0x81; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + return Buffer.concat([header, payload]); +} + +/** Build an unmasked binary frame (opcode 2). */ +function binaryFrame(data: Buffer): Buffer { + const header = Buffer.alloc(2); + header[0] = 0x82; // FIN + opcode 2 (binary) + header[1] = data.length; + return Buffer.concat([header, data]); +} + +/** Build an unmasked ping frame (opcode 9). */ +function pingFrame(): Buffer { + const header = Buffer.alloc(2); + header[0] = 0x89; // FIN + opcode 9 (ping) + header[1] = 0; + return header; +} + +/** Build an unmasked close frame (opcode 8). */ +function closeFrame(): Buffer { + const header = Buffer.alloc(2); + header[0] = 0x88; // FIN + opcode 8 (close) + header[1] = 0; + return header; +} + +describe("extractTextFrames", () => { + test("parses a single small text frame", () => { + const frame = textFrame('{"type":"hello"}'); + const { texts, remaining } = extractTextFrames(frame); + expect(texts).toEqual(['{"type":"hello"}']); + expect(remaining.length).toBe(0); + }); + + test("parses multiple text frames in one buffer", () => { + const buf = Buffer.concat([ + textFrame('{"type":"hello"}'), + textFrame('{"type":"view_opened","view_id":"V1"}'), + ]); + const { texts, remaining } = extractTextFrames(buf); + expect(texts).toEqual(['{"type":"hello"}', '{"type":"view_opened","view_id":"V1"}']); + expect(remaining.length).toBe(0); + }); + + test("returns partial frame as remaining", () => { + const full = textFrame('{"ok":true}'); + const partial = full.subarray(0, -3); + const { texts, remaining } = extractTextFrames(partial); + expect(texts).toEqual([]); + expect(remaining.length).toBe(partial.length); + }); + + test("returns complete frames and keeps partial remainder", () => { + const first = textFrame("complete"); + const second = textFrame("incomplete"); + const partial = second.subarray(0, -2); + const buf = Buffer.concat([first, partial]); + + const { texts, remaining } = extractTextFrames(buf); + expect(texts).toEqual(["complete"]); + expect(remaining.length).toBe(partial.length); + }); + + test("skips non-text frames (binary, ping, close)", () => { + const buf = Buffer.concat([ + pingFrame(), + textFrame("hello"), + binaryFrame(Buffer.from([0x01, 0x02])), + closeFrame(), + textFrame("world"), + ]); + const { texts, remaining } = extractTextFrames(buf); + expect(texts).toEqual(["hello", "world"]); + expect(remaining.length).toBe(0); + }); + + test("handles empty buffer", () => { + const { texts, remaining } = extractTextFrames(Buffer.alloc(0)); + expect(texts).toEqual([]); + expect(remaining.length).toBe(0); + }); + + test("handles buffer with only one byte (incomplete header)", () => { + const { texts, remaining } = extractTextFrames(Buffer.from([0x81])); + expect(texts).toEqual([]); + expect(remaining.length).toBe(1); + }); + + test("handles 16-bit extended payload length (126-65535 bytes)", () => { + const payload = "x".repeat(200); + const frame = textFrame(payload); + // Verify we built a 16-bit length frame (header byte 1 should have 126) + expect(frame[1]! & 0x7f).toBe(126); + + const { texts, remaining } = extractTextFrames(frame); + expect(texts).toEqual([payload]); + expect(remaining.length).toBe(0); + }); + + test("handles 64-bit extended payload length (>65535 bytes)", () => { + const payload = "y".repeat(70000); + const frame = textFrame(payload); + // Verify we built a 64-bit length frame (header byte 1 should have 127) + expect(frame[1]! & 0x7f).toBe(127); + + const { texts, remaining } = extractTextFrames(frame); + expect(texts).toEqual([payload]); + expect(remaining.length).toBe(0); + }); + + test("handles incomplete 16-bit length header", () => { + // 0x81 = FIN+text, 0x7e = 126 (16-bit length follows), then only 1 byte of the 2 + const buf = Buffer.from([0x81, 0x7e, 0x00]); + const { texts, remaining } = extractTextFrames(buf); + expect(texts).toEqual([]); + expect(remaining.length).toBe(3); + }); + + test("handles incomplete 64-bit length header", () => { + // 0x81 = FIN+text, 0x7f = 127 (64-bit length follows), then only 4 of the 8 bytes + const buf = Buffer.from([0x81, 0x7f, 0x00, 0x00, 0x00, 0x00]); + const { texts, remaining } = extractTextFrames(buf); + expect(texts).toEqual([]); + expect(remaining.length).toBe(6); + }); + + test("handles UTF-8 multibyte characters", () => { + const payload = '{"emoji":"🤘","text":"café"}'; + const frame = textFrame(payload); + const { texts } = extractTextFrames(frame); + expect(texts).toEqual([payload]); + }); +}); diff --git a/test/workflow-submit.test.ts b/test/workflow-submit.test.ts new file mode 100644 index 0000000..35cc39e --- /dev/null +++ b/test/workflow-submit.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { requireBrowserAuth, validateFieldInputs } from "../src/slack/workflow-submit.ts"; +import type { WorkflowSchema } from "../src/slack/workflows.ts"; +import type { SlackAuth } from "../src/slack/client.ts"; + +function makeSchema(fields: Partial[]): WorkflowSchema { + return { + workflow_id: "Wf000TEST", + title: "Test Workflow", + description: "A test workflow", + fields: fields.map((f, i) => ({ + name: `field_${i}`, + title: f.title ?? `Field ${i}`, + type: f.type ?? "text", + description: f.description ?? "", + required: f.required ?? false, + ...f, + })), + steps: [], + }; +} + +describe("validateFieldInputs", () => { + test("returns no errors for valid required fields", () => { + const schema = makeSchema([ + { title: "Summary", required: true }, + { title: "Details", required: false }, + ]); + const fields = new Map([["Summary", "some text"]]); + expect(validateFieldInputs(fields, schema)).toEqual([]); + }); + + test("returns no errors when all fields provided", () => { + const schema = makeSchema([ + { title: "Summary", required: true }, + { title: "Details", required: true }, + ]); + const fields = new Map([ + ["Summary", "text"], + ["Details", "more text"], + ]); + expect(validateFieldInputs(fields, schema)).toEqual([]); + }); + + test("reports unknown field titles", () => { + const schema = makeSchema([{ title: "Summary" }]); + const fields = new Map([["Bogus", "value"]]); + const errors = validateFieldInputs(fields, schema); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("Unknown field"); + expect(errors[0]).toContain("Bogus"); + expect(errors[0]).toContain("Summary"); + }); + + test("reports missing required fields", () => { + const schema = makeSchema([ + { title: "First", required: true }, + { title: "Second", required: true }, + ]); + const fields = new Map(); + const errors = validateFieldInputs(fields, schema); + expect(errors).toHaveLength(2); + expect(errors[0]).toContain("First"); + expect(errors[1]).toContain("Second"); + }); + + test("does not report missing optional fields", () => { + const schema = makeSchema([ + { title: "Summary", required: true }, + { title: "Extra", required: false }, + ]); + const fields = new Map([["Summary", "text"]]); + expect(validateFieldInputs(fields, schema)).toEqual([]); + }); + + test("matches field titles case-insensitively", () => { + const schema = makeSchema([{ title: "Summary", required: true }]); + const fields = new Map([["summary", "text"]]); + expect(validateFieldInputs(fields, schema)).toEqual([]); + }); + + test("matches unknown check case-insensitively", () => { + const schema = makeSchema([{ title: "Summary" }]); + const fields = new Map([["SUMMARY", "text"]]); + expect(validateFieldInputs(fields, schema)).toEqual([]); + }); + + test("reports both unknown and missing errors together", () => { + const schema = makeSchema([{ title: "Summary", required: true }]); + const fields = new Map([["Bogus", "value"]]); + const errors = validateFieldInputs(fields, schema); + expect(errors).toHaveLength(2); + expect(errors.some((e) => e.includes("Unknown"))).toBe(true); + expect(errors.some((e) => e.includes("missing"))).toBe(true); + }); + + test("handles emoji in field titles", () => { + const schema = makeSchema([{ title: "Label", required: true }]); + const fields = new Map([["Label", "text"]]); + expect(validateFieldInputs(fields, schema)).toEqual([]); + }); + + test("returns no errors for empty schema with no fields", () => { + const schema = makeSchema([]); + const fields = new Map(); + expect(validateFieldInputs(fields, schema)).toEqual([]); + }); +}); + +describe("requireBrowserAuth", () => { + test("does not throw for browser auth", () => { + const auth: SlackAuth = { + auth_type: "browser", + xoxc_token: "xoxc-fake", + xoxd_cookie: "xoxd-fake", + }; + expect(() => requireBrowserAuth(auth)).not.toThrow(); + }); + + test("throws for standard auth", () => { + const auth: SlackAuth = { + auth_type: "standard", + token: "xoxb-fake", + }; + expect(() => requireBrowserAuth(auth)).toThrow("browser auth"); + }); +});