Skip to content

Commit 3eebe61

Browse files
authored
fix (run-time): Promise.all Parsing properly for Dynamic Arrays (#166)
1 parent 36c652f commit 3eebe61

5 files changed

Lines changed: 576 additions & 47 deletions

File tree

apps/bubble-studio/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"lint": "eslint .",
1111
"preview": "vite preview",
1212
"typecheck": "tsc --noEmit",
13-
"test": "vitest"
13+
"test": "vitest --exclude='**/*.integration.test.{ts,tsx,js,jsx}'"
1414
},
1515
"dependencies": {
1616
"@bubblelab/shared-schemas": "workspace:*",
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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

Comments
 (0)