|
| 1 | +import { describe, it, expect } from 'vitest'; |
| 2 | +import { extractStepGraph, type StepGraph } from '../../utils/workflowToSteps'; |
| 3 | +import type { |
| 4 | + ParsedBubbleWithInfo, |
| 5 | + ValidateBubbleFlowResponse, |
| 6 | +} from '@bubblelab/shared-schemas'; |
| 7 | + |
| 8 | +// Get API base URL from environment (defaults to localhost for testing) |
| 9 | +const API_BASE_URL = |
| 10 | + process.env.VITE_API_BASE_URL || |
| 11 | + process.env.API_BASE_URL || |
| 12 | + 'http://localhost:3001'; |
| 13 | + |
| 14 | +async function getValidationResponse( |
| 15 | + code: string |
| 16 | +): Promise<ValidateBubbleFlowResponse> { |
| 17 | + // Call backend validation endpoint (simulating useValidateCode without flowId) |
| 18 | + const response = await fetch(`${API_BASE_URL}/bubble-flow/validate`, { |
| 19 | + method: 'POST', |
| 20 | + headers: { |
| 21 | + 'Content-Type': 'application/json', |
| 22 | + // Add auth token if needed for tests |
| 23 | + ...(process.env.TEST_AUTH_TOKEN && { |
| 24 | + Authorization: `Bearer ${process.env.TEST_AUTH_TOKEN}`, |
| 25 | + }), |
| 26 | + }, |
| 27 | + body: JSON.stringify({ |
| 28 | + code, |
| 29 | + // No flowId - simulating validation without existing flow |
| 30 | + options: { |
| 31 | + includeDetails: true, |
| 32 | + strictMode: true, |
| 33 | + }, |
| 34 | + }), |
| 35 | + }); |
| 36 | + |
| 37 | + expect(response.ok).toBe(true); |
| 38 | + |
| 39 | + const validationResult: ValidateBubbleFlowResponse = await response.json(); |
| 40 | + console.log(JSON.stringify(validationResult.workflow, null, 2)); |
| 41 | + |
| 42 | + expect(validationResult.valid).toBe(true); |
| 43 | + expect(validationResult.workflow).toBeDefined(); |
| 44 | + expect(validationResult.bubbles).toBeDefined(); |
| 45 | + |
| 46 | + // Convert bubbles from Record<string, ParsedBubbleWithInfo> to Record<number, ParsedBubbleWithInfo> |
| 47 | + // The API returns string keys, but extractStepGraph expects number keys |
| 48 | + const bubbles: Record<number, ParsedBubbleWithInfo> = {}; |
| 49 | + if (validationResult.bubbles) { |
| 50 | + Object.entries(validationResult.bubbles).forEach(([key, bubble]) => { |
| 51 | + // Try to use variableId if available, otherwise parse the key |
| 52 | + const bubbleId = bubble.variableId ?? parseInt(key, 10); |
| 53 | + if (!isNaN(bubbleId)) { |
| 54 | + bubbles[bubbleId] = bubble; |
| 55 | + } |
| 56 | + }); |
| 57 | + } |
| 58 | + |
| 59 | + return validationResult; |
| 60 | +} |
| 61 | + |
| 62 | +async function validateGraph( |
| 63 | + validationResult: ValidateBubbleFlowResponse |
| 64 | +): Promise<StepGraph> { |
| 65 | + const stepGraph: StepGraph = extractStepGraph( |
| 66 | + validationResult.workflow, |
| 67 | + validationResult.bubbles as Record<number, ParsedBubbleWithInfo> |
| 68 | + ); |
| 69 | + // Assert we get defined results |
| 70 | + expect(stepGraph).toBeDefined(); |
| 71 | + expect(stepGraph.steps).toBeDefined(); |
| 72 | + expect(Array.isArray(stepGraph.steps)).toBe(true); |
| 73 | + expect(stepGraph.edges).toBeDefined(); |
| 74 | + expect(Array.isArray(stepGraph.edges)).toBe(true); |
| 75 | + |
| 76 | + // Verify edges connect steps properly |
| 77 | + if (stepGraph.steps.length > 1 && stepGraph.edges.length > 0) { |
| 78 | + // Each edge should reference valid step IDs |
| 79 | + stepGraph.edges.forEach((edge) => { |
| 80 | + const sourceExists = stepGraph.steps.some( |
| 81 | + (step) => step.id === edge.sourceStepId |
| 82 | + ); |
| 83 | + const targetExists = stepGraph.steps.some( |
| 84 | + (step) => step.id === edge.targetStepId |
| 85 | + ); |
| 86 | + |
| 87 | + expect(sourceExists).toBe(true); |
| 88 | + expect(targetExists).toBe(true); |
| 89 | + }); |
| 90 | + |
| 91 | + // Verify no self-loops (steps shouldn't connect to themselves) |
| 92 | + stepGraph.edges.forEach((edge) => { |
| 93 | + expect(edge.sourceStepId).not.toBe(edge.targetStepId); |
| 94 | + }); |
| 95 | + } |
| 96 | + return stepGraph; |
| 97 | +} |
| 98 | + |
| 99 | +describe('workflowToSteps', () => { |
| 100 | + describe('extractStepGraph', () => { |
| 101 | + it('should create proper edge connections between steps', async () => { |
| 102 | + const code = ` |
| 103 | +import {z} from 'zod'; |
| 104 | +
|
| 105 | +import { |
| 106 | + BubbleFlow, |
| 107 | + AIAgentBubble, |
| 108 | + WebScrapeTool, |
| 109 | + GoogleDriveBubble, |
| 110 | + type WebhookEvent, |
| 111 | +} from '@bubblelab/bubble-core'; |
| 112 | +
|
| 113 | +export interface Output { |
| 114 | + generatedLaunchPost: string; |
| 115 | + docUrl: string; |
| 116 | + processed: boolean; |
| 117 | +} |
| 118 | +
|
| 119 | +export interface YCLaunchGeneratorPayload extends WebhookEvent { |
| 120 | + /** Array of URLs to YC launch posts to use as style references. */ |
| 121 | + exampleUrls?: string[]; |
| 122 | + /** URL to the company's GitHub repository (public). */ |
| 123 | + githubRepoUrl: string; |
| 124 | + /** URL to the company's website. */ |
| 125 | + companyWebsiteUrl: string; |
| 126 | + /** The current draft of the launch post. */ |
| 127 | + currentDraft: string; |
| 128 | + /** Name for the Google Doc that will be created. */ |
| 129 | + docName?: string; |
| 130 | +} |
| 131 | +
|
| 132 | +export class YCLaunchGeneratorFlow extends BubbleFlow<'webhook/http'> { |
| 133 | + |
| 134 | + // Validates that required URLs are present |
| 135 | + private validateInput(payload: YCLaunchGeneratorPayload): void { |
| 136 | + if (!payload.githubRepoUrl || !payload.companyWebsiteUrl || !payload.currentDraft) { |
| 137 | + throw new Error("Missing required inputs: githubRepoUrl, companyWebsiteUrl, or currentDraft."); |
| 138 | + } |
| 139 | + } |
| 140 | +
|
| 141 | + // Scrapes the company website to extract product details and marketing content |
| 142 | + private async scrapeWebsite(url: string): Promise<string> { |
| 143 | + // Using 'markdown' format to preserve structure which helps the AI understand headers and lists. |
| 144 | + // onlyMainContent is true to avoid clutter from navigation menus and footers. |
| 145 | + const websiteScraper = new WebScrapeTool({ |
| 146 | + url: url, |
| 147 | + format: 'markdown', |
| 148 | + onlyMainContent: true |
| 149 | + }); |
| 150 | +
|
| 151 | + const websiteResult = await websiteScraper.action(); |
| 152 | +
|
| 153 | + return websiteResult.success |
| 154 | + ? (websiteResult.data.content || '[No content found for Company Website]') |
| 155 | + : \`[Failed to retrieve content for Company Website]\`; |
| 156 | + } |
| 157 | +
|
| 158 | + // Scrapes the GitHub repository to extract technical details and implementation information |
| 159 | + private async scrapeRepo(url: string): Promise<string> { |
| 160 | + // Using 'markdown' format to preserve code blocks and documentation structure. |
| 161 | + // onlyMainContent is true to focus on README and key files rather than GitHub UI elements. |
| 162 | + const repoScraper = new WebScrapeTool({ |
| 163 | + url: url, |
| 164 | + format: 'markdown', |
| 165 | + onlyMainContent: true |
| 166 | + }); |
| 167 | +
|
| 168 | + const repoResult = await repoScraper.action(); |
| 169 | +
|
| 170 | + return repoResult.success |
| 171 | + ? (repoResult.data.content || '[No content found for GitHub Repository]') |
| 172 | + : \`[Failed to retrieve content for GitHub Repository]\`; |
| 173 | + } |
| 174 | +
|
| 175 | + // Scrapes a single example YC launch post to learn writing style and structure |
| 176 | + private async scrapeExample(url: string, index: number): Promise<string> { |
| 177 | + // Using 'markdown' format to capture formatting like bold headers, bullet points, and code blocks. |
| 178 | + // onlyMainContent is true to exclude comments and sidebar content, focusing on the post itself. |
| 179 | + const scraper = new WebScrapeTool({ |
| 180 | + url: url, |
| 181 | + format: 'markdown', |
| 182 | + onlyMainContent: true |
| 183 | + }); |
| 184 | +
|
| 185 | + const result = await scraper.action(); |
| 186 | +
|
| 187 | + if (!result.success) { |
| 188 | + console.warn(\`Failed to scrape Example \${index + 1} (\${url}): \${result.error}\`); |
| 189 | + return \`[Failed to retrieve content for Example \${index + 1}]\`; |
| 190 | + } |
| 191 | +
|
| 192 | + return result.data.content || \`[No content found for Example \${index + 1}]\`; |
| 193 | + } |
| 194 | +
|
| 195 | + // Builds the comprehensive context message for the AI agent from all scraped content |
| 196 | + private buildAIPrompt( |
| 197 | + draft: string, |
| 198 | + examplesContent: string, |
| 199 | + websiteContent: string, |
| 200 | + repoContent: string |
| 201 | + ): { systemPrompt: string, message: string } { |
| 202 | + const systemPrompt = \`You are an expert copywriter specializing in YC (Y Combinator) launch posts (Bookface/Hacker News style). |
| 203 | +Your goal is to rewrite a user's draft to match the high-impact, clear, and developer-focused style of successful YC launches. |
| 204 | +Analyze the provided 'Style Examples' to understand the tone, structure, and formatting. |
| 205 | +Use the 'Company Website' and 'GitHub Repository' content to ensure technical accuracy and depth. |
| 206 | +The final output should be a polished, ready-to-publish launch post.\`; |
| 207 | +
|
| 208 | + const message = \`Here is the context for the launch post: |
| 209 | +
|
| 210 | +=== STYLE EXAMPLES (Use these for tone and structure) === |
| 211 | +
|
| 212 | +\${examplesContent} |
| 213 | +
|
| 214 | +=== COMPANY WEBSITE (Use this for product details) === |
| 215 | +
|
| 216 | +\${websiteContent} |
| 217 | +
|
| 218 | +=== GITHUB REPOSITORY (Use this for technical details) === |
| 219 | +
|
| 220 | +\${repoContent} |
| 221 | +
|
| 222 | +=== CURRENT DRAFT (Rewrite this) === |
| 223 | +
|
| 224 | +\${draft} |
| 225 | +
|
| 226 | +Please generate the best possible YC launch post based on the above.\`; |
| 227 | +
|
| 228 | + return { systemPrompt, message }; |
| 229 | + } |
| 230 | +
|
| 231 | + // Uses the AI Agent to synthesize all scraped information and the draft into a final post |
| 232 | + private async generatePost(systemPrompt: string, message: string): Promise<string> { |
| 233 | + // Using gemini-3-pro-preview for high-quality reasoning and creative writing capabilities. |
| 234 | + const agent = new AIAgentBubble({ |
| 235 | + model: { model: 'google/gemini-3-pro-preview' }, |
| 236 | + systemPrompt: systemPrompt, |
| 237 | + message: message |
| 238 | + }); |
| 239 | +
|
| 240 | + const agentResult = await agent.action(); |
| 241 | +
|
| 242 | + if (!agentResult.success) { |
| 243 | + throw new Error(\`AI generation failed: \${agentResult.error}\`); |
| 244 | + } |
| 245 | +
|
| 246 | + return agentResult.data.response; |
| 247 | + } |
| 248 | +
|
| 249 | + // Creates a Google Doc with the generated launch post content |
| 250 | + private async createDoc(content: string, docName: string): Promise<string> { |
| 251 | + // The doc is uploaded as plain text and automatically converted to Google Docs format. |
| 252 | + // Returns the webViewLink URL where users can view and edit the document. |
| 253 | + const googleDrive = new GoogleDriveBubble({ |
| 254 | + operation: 'upload_file', |
| 255 | + name: docName, |
| 256 | + content: content, |
| 257 | + mimeType: 'text/plain', |
| 258 | + convert_to_google_docs: true |
| 259 | + }); |
| 260 | +
|
| 261 | + const driveResult = await googleDrive.action(); |
| 262 | +
|
| 263 | + if (!driveResult.success) { |
| 264 | + throw new Error(\`Failed to create Google Doc: \${driveResult.error}\`); |
| 265 | + } |
| 266 | +
|
| 267 | + if (!driveResult.data.file?.webViewLink) { |
| 268 | + throw new Error('Google Doc was created but no view link was returned'); |
| 269 | + } |
| 270 | +
|
| 271 | + return driveResult.data.file.webViewLink; |
| 272 | + } |
| 273 | +
|
| 274 | + // Main workflow orchestration |
| 275 | + async handle(payload: YCLaunchGeneratorPayload): Promise<Output> { |
| 276 | + const { |
| 277 | + exampleUrls = [], |
| 278 | + githubRepoUrl, |
| 279 | + companyWebsiteUrl, |
| 280 | + currentDraft, |
| 281 | + docName = 'YC Launch Post - Final Draft' |
| 282 | + } = payload; |
| 283 | +
|
| 284 | + this.validateInput(payload); |
| 285 | +
|
| 286 | + const websiteContent = await this.scrapeWebsite(companyWebsiteUrl); |
| 287 | +
|
| 288 | + const repoContent = await this.scrapeRepo(githubRepoUrl); |
| 289 | +
|
| 290 | + // Scrape all example YC launch posts to extract tone, structure, and formatting patterns |
| 291 | + const exampleScrapers: Promise<string>[] = []; |
| 292 | +
|
| 293 | + for (let i = 0; i < exampleUrls.length; i++) { |
| 294 | + exampleScrapers.push(this.scrapeExample(exampleUrls[i], i)); |
| 295 | + } |
| 296 | +
|
| 297 | + const exampleContents = await Promise.all(exampleScrapers); |
| 298 | +
|
| 299 | + const examplesContent = exampleContents.join("\\n\\n---\\n\\n"); |
| 300 | +
|
| 301 | + // Build AI prompt with all gathered context |
| 302 | + const { systemPrompt, message } = this.buildAIPrompt( |
| 303 | + currentDraft, |
| 304 | + examplesContent, |
| 305 | + websiteContent, |
| 306 | + repoContent |
| 307 | + ); |
| 308 | +
|
| 309 | + const generatedPost = await this.generatePost(systemPrompt, message); |
| 310 | +
|
| 311 | + const docUrl = await this.createDoc(generatedPost, docName); |
| 312 | +
|
| 313 | + return { |
| 314 | + generatedLaunchPost: generatedPost, |
| 315 | + docUrl: docUrl, |
| 316 | + processed: true |
| 317 | + }; |
| 318 | + } |
| 319 | +} |
| 320 | +`; |
| 321 | + |
| 322 | + const validationResult = await getValidationResponse(code); |
| 323 | + const stepGraph = await validateGraph(validationResult); |
| 324 | + // Assert that the workflow was correctly parsed for Promise.all with array.push pattern |
| 325 | + expect(validationResult.workflow).toBeDefined(); |
| 326 | + expect(Array.isArray(validationResult.workflow?.root)).toBe(true); |
| 327 | + expect(validationResult.workflow?.root.length).toBeGreaterThan(0); |
| 328 | + |
| 329 | + const stepsInMain = stepGraph.steps.filter( |
| 330 | + (step) => step.id === 'step-main' |
| 331 | + ); |
| 332 | + expect(stepsInMain.length).toBe(0); |
| 333 | + }); |
| 334 | + }); |
| 335 | +}); |
0 commit comments