Implement Task 8.0 — Manual Export Provider with full provider abstraction:
Source files:
- src/providers/manual-export.js (104 lines) — new provider that wraps
pre-built prompts in copy-ready format for ChatGPT Web/Business use
- src/providers/factory.js — register 'manual' provider in whitelist
- src/providers/openai.js — update JSDoc for ProviderRequest parameter
All 5 handlers updated with ReviewRequest pattern:
- src/tools/ask-chatgpt.js
- src/tools/debug-issue.js
- src/tools/review-code.js
- src/tools/review-plan.js
- src/tools/architecture-review.js
Each handler now packages shared data as { prompt, input } between step 5
and 6 of the orchestration flow.
Tests:
- test/providers/manual-export.test.js (56 tests) — covers structure,
copy-ready box formatting, tool name detection, long prompts, Unicode,
edge cases, provider contract compliance, idempotency
- test/providers/factory.test.js (+9 tests) — manual provider whitelist,
factory routing, send delegation
Docs:
- ARCHITECTURE.md — provider selection table with openai/manual values
- README.md — provider comparison table and manual workflow description
507 lines
18 KiB
JavaScript
507 lines
18 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import { manualExportProvider } from "../../src/providers/manual-export.js";
|
|
|
|
// --- Basic structure ---
|
|
|
|
describe("basic structure", () => {
|
|
it("exports a provider with send method", () => {
|
|
expect(manualExportProvider).toBeDefined();
|
|
expect(typeof manualExportProvider.send).toBe("function");
|
|
});
|
|
|
|
it("returns { content: string } shape on success", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test prompt" },
|
|
{},
|
|
);
|
|
expect(result).toHaveProperty("content");
|
|
expect(typeof result.content).toBe("string");
|
|
});
|
|
|
|
it("returns non-empty content for valid prompt", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test prompt" },
|
|
{},
|
|
);
|
|
expect(result.content.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("is deterministic — same input produces same output", async () => {
|
|
const input = { prompt: "deterministic test", input: { question: "hi" } };
|
|
const r1 = await manualExportProvider.send(input, {});
|
|
const r2 = await manualExportProvider.send(input, {});
|
|
expect(r1.content).toBe(r2.content);
|
|
});
|
|
|
|
it("does not modify the request object", async () => {
|
|
const req = { prompt: "test", input: { question: "hi" } };
|
|
const original = JSON.stringify(req);
|
|
await manualExportProvider.send(req, {});
|
|
expect(JSON.stringify(req)).toBe(original);
|
|
});
|
|
|
|
it("does not modify the config object", async () => {
|
|
const config = { openaiApiKey: "sk-test" };
|
|
await manualExportProvider.send({ prompt: "test" }, config);
|
|
expect(config.openaiApiKey).toBe("sk-test");
|
|
});
|
|
});
|
|
|
|
// --- Copy-ready structure ---
|
|
|
|
describe("copy-ready structure", () => {
|
|
it("includes MANUAL EXPORT header with tool name", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("MANUAL EXPORT");
|
|
});
|
|
|
|
it("includes COPY box with top-left corner delimiters (┌)", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("┌");
|
|
});
|
|
|
|
it("includes COPY box with bottom-right corner delimiters (┐ and ┘)", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("┐");
|
|
expect(result.content).toContain("└");
|
|
expect(result.content).toContain("┘");
|
|
});
|
|
|
|
it("includes instructions section with numbered steps", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("📝 INSTRUCTIONS:");
|
|
expect(result.content).toContain("1.");
|
|
expect(result.content).toContain("2.");
|
|
expect(result.content).toContain("3.");
|
|
});
|
|
|
|
it("includes metadata section", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("📊 METADATA:");
|
|
});
|
|
|
|
it("includes provider info in metadata", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Manual Export");
|
|
});
|
|
|
|
it("includes tool name in metadata", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toMatch(/Tool: \w+/);
|
|
});
|
|
|
|
it("includes prompt length in metadata with formatted thousands separator", async () => {
|
|
const longPrompt = "x".repeat(12345);
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: longPrompt, input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Prompt Length:");
|
|
expect(result.content).toContain("12,345 characters");
|
|
});
|
|
|
|
it("includes advisory footer", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("advisory output only");
|
|
});
|
|
|
|
it("wraps each line of prompt in box with │ delimiter", async () => {
|
|
const multiLine = "line one\nline two\nline three";
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: multiLine, input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("│ line one");
|
|
expect(result.content).toContain("│ line two");
|
|
expect(result.content).toContain("│ line three");
|
|
});
|
|
});
|
|
|
|
// --- Per-tool detection ---
|
|
|
|
describe("tool name detection", () => {
|
|
it("detects debug_issue when input has logs", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { logs: "Error: boom" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Tool: debug_issue");
|
|
});
|
|
|
|
it("detects review_code when input has relevantFiles with paths", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { relevantFiles: [{ path: "src/foo.js" }] } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Tool: review_code");
|
|
});
|
|
|
|
it("detects architecture_review when context includes 'architecture'", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { context: "Architecture decision here" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Tool: architecture_review");
|
|
});
|
|
|
|
it("detects review_plan when input has projectSummary", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { projectSummary: "Project scope" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Tool: review_plan");
|
|
});
|
|
|
|
it("defaults to ask_chatgpt when no hints available", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test", input: { question: "hi" } },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Tool: ask_chatgpt");
|
|
});
|
|
|
|
it("uses 'unknown' when input is absent in request", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test" },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("Tool: unknown");
|
|
});
|
|
});
|
|
|
|
// --- Long prompt handling ---
|
|
|
|
describe("long prompt handling", () => {
|
|
it("adds warning banner for prompts over 30k characters", async () => {
|
|
const longPrompt = "x".repeat(31_000);
|
|
const result = await manualExportProvider.send({ prompt: longPrompt, input: {} }, {});
|
|
expect(result.content).toContain("⚠️ NOTE");
|
|
});
|
|
|
|
it("does NOT add warning for prompts under 30k characters", async () => {
|
|
const validPrompt = "x".repeat(29_999);
|
|
const result = await manualExportProvider.send({ prompt: validPrompt, input: {} }, {});
|
|
expect(result.content).not.toContain("⚠️ NOTE");
|
|
});
|
|
|
|
it("still returns valid output for very long prompts (>50k chars)", async () => {
|
|
const veryLongPrompt = "x".repeat(50_000);
|
|
const result = await manualExportProvider.send({ prompt: veryLongPrompt, input: {} }, {});
|
|
expect(result.content).toContain("MANUAL EXPORT");
|
|
expect(result.content.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("shows correct character count in metadata for long prompts", async () => {
|
|
const length = 50_000;
|
|
const result = await manualExportProvider.send({ prompt: "x".repeat(length), input: {} }, {});
|
|
expect(result.content).toContain(`${length.toLocaleString()} characters`);
|
|
});
|
|
|
|
it("handles prompts at exactly the 30k boundary", async () => {
|
|
const exactPrompt = "x".repeat(30_000);
|
|
const result = await manualExportProvider.send({ prompt: exactPrompt, input: {} }, {});
|
|
expect(result.content).not.toContain("⚠️ NOTE"); // not over 30k, exactly at it
|
|
});
|
|
});
|
|
|
|
// --- Unicode and special characters ---
|
|
|
|
describe("unicode and special characters", () => {
|
|
it("preserves Japanese characters in prompt", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "こんにちは世界", input: {} },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("こんにちは世界");
|
|
});
|
|
|
|
it("preserves emoji in prompt", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "🚀 Launch plan 🎯", input: {} },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("🚀");
|
|
expect(result.content).toContain("🎯");
|
|
});
|
|
|
|
it("preserves markdown code blocks in prompt", async () => {
|
|
const codePrompt = "```js\nconst x = 1;\n```";
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: codePrompt, input: {} },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("```js");
|
|
expect(result.content).toContain("const x = 1;");
|
|
});
|
|
|
|
it("preserves JSON in prompt", async () => {
|
|
const jsonPrompt = '{"key": "value", "nested": {"a": 1}}';
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: jsonPrompt, input: {} },
|
|
{},
|
|
);
|
|
expect(result.content).toContain('"key"');
|
|
expect(result.content).toContain('"value"');
|
|
});
|
|
|
|
it("preserves special HTML-like characters", async () => {
|
|
const htmlPrompt = '<script>alert("xss")</script>';
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: htmlPrompt, input: {} },
|
|
{},
|
|
);
|
|
expect(result.content).toContain('<script>');
|
|
expect(result.content).toContain('"xss"');
|
|
});
|
|
});
|
|
|
|
// --- Empty / edge cases ---
|
|
|
|
describe("empty and edge case handling", () => {
|
|
it("returns warning content when prompt is empty string", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "", input: {} }, {});
|
|
expect(result.content).toContain("received no prompt to export");
|
|
});
|
|
|
|
it("returns warning content when prompt field is missing from request", async () => {
|
|
const result = await manualExportProvider.send({}, {});
|
|
expect(result.content).toContain("received no prompt to export");
|
|
});
|
|
|
|
it("returns warning content when request is null", async () => {
|
|
const result = await manualExportProvider.send(null, {});
|
|
expect(result.content).toContain("received no prompt to export");
|
|
});
|
|
|
|
it("returns warning content when request is undefined", async () => {
|
|
const result = await manualExportProvider.send(undefined, {});
|
|
expect(result.content).toContain("received no prompt to export");
|
|
});
|
|
|
|
it("handles null config gracefully", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "test" }, null);
|
|
expect(result.content).toContain("MANUAL EXPORT");
|
|
});
|
|
|
|
it("handles undefined config gracefully", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "test" }, undefined);
|
|
expect(result.content).toContain("MANUAL EXPORT");
|
|
});
|
|
|
|
it("handles single-line prompt without newlines", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "single line prompt" },
|
|
{},
|
|
);
|
|
expect(result.content).toContain("│ single line prompt");
|
|
});
|
|
|
|
it("handles prompts with only whitespace lines", async () => {
|
|
const wsPrompt = "\n\n \n\t\n";
|
|
const result = await manualExportProvider.send({ prompt: wsPrompt }, {});
|
|
expect(result.content).toContain("MANUAL EXPORT");
|
|
});
|
|
|
|
it("preserves tab characters in prompt", async () => {
|
|
const tabPrompt = "first\tsecond\tthird";
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: tabPrompt },
|
|
{},
|
|
);
|
|
expect(result.content).toContain(tabPrompt);
|
|
});
|
|
|
|
it("preserves newlines within the copy box (each line as │)", async () => {
|
|
const nlPrompt = "line1\nline2\nline3";
|
|
const result = await manualExportProvider.send({ prompt: nlPrompt }, {});
|
|
// Each line should be wrapped with │ prefix
|
|
expect(result.content).toContain("│ line1");
|
|
expect(result.content).toContain("│ line2");
|
|
expect(result.content).toContain("│ line3");
|
|
});
|
|
});
|
|
|
|
// --- Provider contract compliance ---
|
|
|
|
describe("provider contract compliance", () => {
|
|
it("returns a Promise (async function)", async () => {
|
|
const result = manualExportProvider.send({ prompt: "test" }, {});
|
|
expect(result).toBeInstanceOf(Promise);
|
|
await result; // don't leave unhandled promise
|
|
});
|
|
|
|
it("content is always a string, never null or object", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: "test" },
|
|
{},
|
|
);
|
|
expect(typeof result.content).toBe("string");
|
|
expect(result.content).not.toBeNull();
|
|
expect(result.content).not.toBeInstanceOf(Object);
|
|
});
|
|
|
|
it("content does not contain raw JSON (is text output)", async () => {
|
|
const result = await manualExportProvider.send(
|
|
{ prompt: '{"json": "in prompt"}' },
|
|
{},
|
|
);
|
|
// The JSON string should appear in the │ wrapped lines, not as a top-level JSON object
|
|
expect(result.content).not.toBe("{" + '"content":"...' + "}");
|
|
});
|
|
|
|
it("always resolves (never rejects) for valid inputs", async () => {
|
|
const testCases = [
|
|
{ prompt: "hi" },
|
|
{ prompt: "" },
|
|
{},
|
|
null,
|
|
];
|
|
for (const tc of testCases) {
|
|
await expect(manualExportProvider.send(tc, {})).resolves.toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("is idempotent — same input always produces same output", async () => {
|
|
const inputs = [
|
|
{ prompt: "prompt1", input: { question: "q1" } },
|
|
{ prompt: "prompt2\nmultiline" },
|
|
{},
|
|
];
|
|
for (const input of inputs) {
|
|
const r1 = await manualExportProvider.send(input, {});
|
|
const r2 = await manualExportProvider.send({ ...input }, {});
|
|
expect(r1.content).toBe(r2.content);
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- Integration: separator lines ---
|
|
|
|
describe("separator formatting", () => {
|
|
it("uses ══ as top and bottom border characters", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
|
// Count border lines — should have exactly 2 (top and bottom)
|
|
const match = result.content.match(/═+/g);
|
|
expect(match).not.toBeNull();
|
|
expect(match.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("has matching top and bottom separator blocks", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
|
const lines = result.content.split("\n");
|
|
expect(lines[0]).toMatch(/═+/);
|
|
expect(lines[lines.length - 1]).toMatch(/═+/);
|
|
// Top and bottom separators should be identical length
|
|
expect(lines[0].length).toBe(lines[lines.length - 1].length);
|
|
});
|
|
|
|
it("includes correct number of sections (header, copy box, instructions, metadata, footer)", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
|
expect(result.content).toContain("MANUAL EXPORT");
|
|
expect(result.content).toContain("COPY THIS PROMPT");
|
|
expect(result.content).toContain("INSTRUCTIONS");
|
|
expect(result.content).toContain("METADATA");
|
|
expect(result.content).toContain("advisory output only");
|
|
});
|
|
});
|
|
|
|
// --- Repeatability ---
|
|
|
|
describe("repeatability", () => {
|
|
it("creates identical providers for same config each time", async () => {
|
|
const results = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
const r = await manualExportProvider.send({ prompt: "test" }, {});
|
|
results.push(r.content);
|
|
}
|
|
expect(results.every((c) => c === results[0])).toBe(true);
|
|
});
|
|
|
|
it("does not share mutable state between send calls", async () => {
|
|
const r1 = await manualExportProvider.send({ prompt: "call1" }, {});
|
|
const r2 = await manualExportProvider.send({ prompt: "call2" }, {});
|
|
expect(r1.content).not.toContain("call2");
|
|
expect(r2.content).not.toContain("call1");
|
|
});
|
|
});
|
|
|
|
// --- Visual structure validation ---
|
|
|
|
describe("visual structure", () => {
|
|
it("copy box has equal left (┌) and right (┐/└/┘) corners", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
|
expect(result.content).toContain("┌");
|
|
expect(result.content).toContain("┐");
|
|
expect(result.content).toContain("└");
|
|
expect(result.content).toContain("┘");
|
|
});
|
|
|
|
it("copy box content lines all start with │", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "line1\nline2" }, {});
|
|
const lines = result.content.split("\n");
|
|
// Find the lines between ┌ and ┘ (the copy box content)
|
|
let inBox = false;
|
|
for (const line of lines) {
|
|
if (line.includes("┌─")) {
|
|
inBox = true;
|
|
continue;
|
|
}
|
|
if (line.includes("└─")) {
|
|
inBox = false;
|
|
continue;
|
|
}
|
|
if (inBox && !line.includes("📋")) {
|
|
// Content lines inside box should start with │
|
|
expect(line.startsWith("│ ")).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("instructions section lists exactly 5 numbered steps", async () => {
|
|
const result = await manualExportProvider.send({ prompt: "test" }, {});
|
|
const instructionsMatch = result.content.match(/📝 INSTRUCTIONS:\n([\s\S]*?)(?=\n\n|$)/);
|
|
expect(instructionsMatch).not.toBeNull();
|
|
const stepLines = instructionsMatch[1].trim().split("\n").filter((l) => /^\d+\./.test(l.trim()));
|
|
expect(stepLines.length).toBe(5);
|
|
});
|
|
|
|
it("tool-specific content varies by detected tool", async () => {
|
|
const resultAsk = await manualExportProvider.send(
|
|
{ prompt: "t", input: { question: "q" } },
|
|
{},
|
|
);
|
|
const resultDebug = await manualExportProvider.send(
|
|
{ prompt: "t", input: { logs: "err" } },
|
|
{},
|
|
);
|
|
expect(resultAsk.content).toContain("ask_chatgpt");
|
|
expect(resultDebug.content).toContain("debug_issue");
|
|
expect(resultAsk.content).not.toContain("debug_issue");
|
|
expect(resultDebug.content).not.toContain("ask_chatgpt");
|
|
});
|
|
});
|