From a603d47a78b10a803a2b67c19647f2d4d0b342e8 Mon Sep 17 00:00:00 2001 From: Irakli Grigolia Date: Sun, 21 Dec 2025 11:02:31 -0500 Subject: [PATCH 1/5] ci: Optimize test performance with parallel execution and Playwright browser caching Add thread pool configuration to unit tests with maxThreads=4 for faster execution. Implement Playwright browser caching in CI workflow to avoid reinstalling browsers on every run. Increase Playwright workers from 1 to 2 to leverage GitHub runner's dual-core CPU. Disable slowMo delay in CI while keeping 500ms locally for debugging. Add FlashcardReviewInterface modal with "Start Review" button showing due card count in --- .github/workflows/test.yml | 16 +++++++++++-- playwright.config.ts | 8 +++---- .../flashcards/FlashcardDeckManager.tsx | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c31281..7059ac9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: run: npm ci - name: Run unit and integration tests - run: npm test -- --run --reporter=verbose + run: npm test -- --run --reporter=verbose --pool=threads --poolOptions.threads.maxThreads=4 env: CI: true @@ -71,9 +71,21 @@ jobs: - name: Install dependencies run: npm ci + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium + - name: Install Playwright deps only (if cached) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + - name: Start dev server run: | npm run dev & @@ -93,7 +105,7 @@ jobs: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - name: Run Playwright tests - run: npx playwright test --project=chromium + run: npx playwright test --project=chromium --workers=2 env: CI: true # Add any required env variables for e2e tests diff --git a/playwright.config.ts b/playwright.config.ts index cf44dae..e0036f3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,8 +11,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + /* Use multiple workers for faster CI - GitHub runners have 2 cores */ + workers: process.env.CI ? 2 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -20,9 +20,9 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:8080', - /* Slow down actions for debugging (set to 0 for normal speed) */ + /* Slow down actions for debugging locally (disabled in CI for speed) */ launchOptions: { - slowMo: 500, // 500ms delay between actions - remove or set to 0 for fast tests + slowMo: process.env.CI ? 0 : 500, // 500ms delay locally for debugging, 0 in CI for speed }, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ diff --git a/src/components/flashcards/FlashcardDeckManager.tsx b/src/components/flashcards/FlashcardDeckManager.tsx index 0c91b6b..7ec9040 100644 --- a/src/components/flashcards/FlashcardDeckManager.tsx +++ b/src/components/flashcards/FlashcardDeckManager.tsx @@ -29,11 +29,13 @@ import { Star, Clock, Brain, + Play, } from "lucide-react"; import { useFlashcards } from "@/hooks/useFlashcards"; import type { FlashcardDeck } from "@/types/api"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { FlashcardReviewInterface } from "./FlashcardReviewInterface"; interface FlashcardDeckManagerProps { userId: string; @@ -43,6 +45,7 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { const navigate = useNavigate(); const { flashcards, + dueCards, removeFromFlashcards, isRemovingFromFlashcards, isLoading, @@ -52,6 +55,7 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { const [masteryFilter, setMasteryFilter] = useState("all"); const [selectedCard, setSelectedCard] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showReviewModal, setShowReviewModal] = useState(false); // Filter flashcards based on search and mastery level const filteredCards = flashcards.filter((card) => { @@ -147,6 +151,18 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => {
{helper}
No data available
+ Analyzing registration and conversion velocity over time +
No data available for the selected period
+
Member since {memberSince}
Spaced repetition knowledge system
+ Example: https://leetcode.com/problems/reverse-integer/ +
+ Fetching problem from LeetCode and generating test cases & + solutions... +
+ This may take a moment +
+ These test cases were generated by AI. Review and edit as needed + before importing. +
+ {tc.explanation} +
+ These solutions were generated by AI. Review and edit as needed + before importing. +
([\s\S]*?)<\/code><\/pre>/gi, "\n```\n$1\n```\n") + .replace(/(.*?)<\/code>/gi, "`$1`") + // Convert headers + .replace(/]*>(.*?)<\/h[1-6]>/gi, "\n## $1\n") + // Convert lists + .replace(/]*>/gi, "\n") + .replace(/<\/ul>/gi, "\n") + .replace(/]*>(.*?)<\/li>/gi, "- $1\n") + // Convert bold/strong + .replace(/(.*?)<\/strong>/gi, "**$1**") + .replace(/(.*?)<\/b>/gi, "**$1**") + // Convert italic/emphasis + .replace(/(.*?)<\/em>/gi, "*$1*") + .replace(/(.*?)<\/i>/gi, "*$1*") + // Convert subscript/superscript + .replace(/(.*?)<\/sup>/gi, "^$1") + .replace(/(.*?)<\/sub>/gi, "_$1") + // Convert paragraphs and line breaks + .replace(/]*>/gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(//gi, "\n") + // Remove remaining HTML tags + .replace(/<[^>]+>/g, "") + // Decode HTML entities + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/"/g, '"') + .replace(/'/g, "'") + // Clean up whitespace + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return md; +} + +/** + * Extract constraints from HTML content + */ +function extractConstraints(content: string): string[] { + const constraints: string[] = []; + + // Look for constraints section + const constraintsMatch = content.match(/Constraints:<\/strong>([\s\S]*?)(?:|$)/i); + if (constraintsMatch) { + const constraintsHtml = constraintsMatch[1]; + const listItems = constraintsHtml.match(/]*>([\s\S]*?)<\/li>/gi) || []; + + for (const item of listItems) { + const text = item + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/(.*?)<\/sup>/gi, "^$1") + .replace(/\s+/g, " ") + .trim(); + if (text) { + constraints.push(text); + } + } + } + + return constraints; +} + +/** + * Extract examples from HTML content + */ +function extractExamples(content: string): Array<{ input: string; output: string; explanation?: string }> { + const examples: Array<{ input: string; output: string; explanation?: string }> = []; + + // Match example blocks + const examplePattern = /]*>Example\s*\d*:?<\/strong>([\s\S]*?)(?=]*>Example|Constraints|$)/gi; + let match; + + while ((match = examplePattern.exec(content)) !== null) { + const exampleContent = match[1]; + + // Extract input + const inputMatch = exampleContent.match(/Input:<\/strong>\s*([\s\S]*?)(?=Output|$)/i); + // Extract output + const outputMatch = exampleContent.match(/Output:<\/strong>\s*([\s\S]*?)(?=Explanation|Example|Constraints|$)/i); + // Extract explanation (optional) + const explanationMatch = exampleContent.match(/Explanation:<\/strong>\s*([\s\S]*?)(?=|$)/i); + + if (inputMatch && outputMatch) { + const input = inputMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + + const output = outputMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + + const example: { input: string; output: string; explanation?: string } = { input, output }; + + if (explanationMatch) { + example.explanation = explanationMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + examples.push(example); + } + } + + return examples; +} + +/** + * Extract Python function signature from code snippets + */ +function extractPythonSignature(snippets: Array<{ lang: string; langSlug: string; code: string }>): string { + const pythonSnippet = snippets.find(s => s.langSlug === "python3" || s.langSlug === "python"); + return pythonSnippet?.code || ""; +} + +/** + * Map LeetCode tags to SimplyAlgo category + */ +function mapCategory(tags: Array<{ name: string; slug: string }>): string | null { + for (const tag of tags) { + const mapped = CATEGORY_MAPPING[tag.slug]; + if (mapped) { + return mapped; + } + } + return null; +} + +// OpenRouter client instance (initialized on first use) +let openrouterClient: OpenAI | null = null; + +function getOpenRouterClient(): OpenAI { + if (!openrouterClient) { + const apiKey = Deno.env.get("OPENROUTER_API_KEY"); + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is not set"); + } + const siteUrl = Deno.env.get("OPENROUTER_SITE_URL") || "https://simplyalgo.dev"; + const appName = Deno.env.get("OPENROUTER_APP_NAME") || "SimplyAlgo"; + + openrouterClient = new OpenAI({ + apiKey, + baseURL: "https://openrouter.ai/api/v1", + defaultHeaders: { + "HTTP-Referer": siteUrl, + "X-Title": appName, + }, + }); + } + return openrouterClient; +} + +/** + * Call OpenRouter API with Gemini to generate content + * Uses OpenAI SDK with OpenRouter baseURL (same pattern as ai-chat) + */ +async function callOpenRouter(prompt: string, maxTokens: number = 4000): Promise { + const client = getOpenRouterClient(); + // Use a known working model - hardcoded to avoid invalid env var + const model = "google/gemini-2.0-flash-001"; + + const response = await client.chat.completions.create({ + model, + messages: [{ role: "user", content: prompt }], + max_tokens: maxTokens, + temperature: 0.7, + }); + + let content = response.choices[0]?.message?.content || ""; + + // Clean markdown code blocks if present + if (content.startsWith("```json")) { + content = content.replace(/```json\n?/g, "").replace(/```\n?$/g, ""); + } else if (content.startsWith("```")) { + content = content.replace(/```\n?/g, ""); + } + + return content.trim(); +} + +/** + * Generate test cases using AI + */ +async function generateTestCases( + title: string, + description: string, + functionSignature: string, + constraints: string[], + examples: Array<{ input: string; output: string; explanation?: string }> +): Promise { + const prompt = `You are an expert algorithm engineer. Given this LeetCode problem, generate 3 additional test cases. + +PROBLEM: ${title} + +DESCRIPTION: +${description} + +FUNCTION SIGNATURE: +\`\`\`python +${functionSignature} +\`\`\` + +CONSTRAINTS: +${constraints.map(c => `- ${c}`).join("\n")} + +EXISTING EXAMPLES: +${examples.map((e, i) => `Example ${i + 1}: Input: ${e.input}, Output: ${e.output}`).join("\n")} + +Generate 3 additional test cases that cover edge cases such as: +1. Edge case (empty input, single element, boundary values) +2. Large/stress test case (near constraint limits) +3. Tricky case (corner cases that might trip up solutions) + +Return ONLY valid JSON in this exact format: +{ + "test_cases": [ + { + "input": "string representation of input (e.g., 'nums = [1,2,3], target = 6')", + "expected_output": "string representation (e.g., '[0, 2]')", + "input_json": , + "expected_json": , + "explanation": "Why this test case is important" + } + ] +} + +Important: +- input_json should be a JSON object with named parameters matching the function signature +- For example, if function is "def twoSum(nums, target)", input_json should be {"nums": [1,2,3], "target": 6} +- expected_json should be the raw expected value (array, number, string, etc)`; + + try { + const response = await callOpenRouter(prompt); + const parsed = JSON.parse(response); + return parsed.test_cases || []; + } catch (error) { + console.error("Failed to generate test cases:", error); + return []; + } +} + +/** + * Generate solutions using AI + */ +async function generateSolutions( + title: string, + description: string, + functionSignature: string, + constraints: string[] +): Promise { + const prompt = `You are an expert algorithm engineer. Given this LeetCode problem, generate 3 Python solutions. + +PROBLEM: ${title} + +DESCRIPTION: +${description} + +FUNCTION SIGNATURE: +\`\`\`python +${functionSignature} +\`\`\` + +CONSTRAINTS: +${constraints.map(c => `- ${c}`).join("\n")} + +Generate 3 different solutions: +1. Brute Force - Simple, easy to understand but not optimal +2. Optimal Solution - Best time/space complexity +3. Alternative Approach - Different technique (if applicable, otherwise another valid approach) + +Return ONLY valid JSON in this exact format: +{ + "solutions": [ + { + "title": "Brute Force", + "approach_type": "brute_force", + "code": "def solution(...):\\n # Complete Python code here", + "time_complexity": "O(...)", + "space_complexity": "O(...)", + "explanation": "Markdown explanation of the approach, why it works, and trade-offs" + }, + { + "title": "Optimal Solution", + "approach_type": "optimal", + ... + }, + { + "title": "Alternative Approach", + "approach_type": "alternative", + ... + } + ] +} + +Requirements: +- Code must be complete and runnable +- Use the exact function signature from the problem +- Include proper indentation (use \\n for newlines, 4 spaces for indentation) +- Explanations should be clear and mention key insights`; + + try { + const response = await callOpenRouter(prompt, 6000); + const parsed = JSON.parse(response); + return parsed.solutions || []; + } catch (error) { + console.error("Failed to generate solutions:", error); + return []; + } +} + +serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + // Verify authentication + const supabaseClient = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_ANON_KEY") ?? "", + { + global: { + headers: { Authorization: req.headers.get("Authorization")! }, + }, + } + ); + + const { data: { user }, error: authError } = await supabaseClient.auth.getUser(); + + if (authError || !user) { + return new Response( + JSON.stringify({ error: "Unauthorized" }), + { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Check if user is admin by email + const ADMIN_EMAILS = [ + "tazigrigolia@gmail.com", + "ivaneroshenko@gmail.com" + ]; + + if (!user.email || !ADMIN_EMAILS.includes(user.email)) { + return new Response( + JSON.stringify({ error: "Admin access required" }), + { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Parse request + const { url } = await req.json(); + + if (!url) { + return new Response( + JSON.stringify({ error: "URL is required" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Extract slug from URL + const slug = extractSlug(url); + if (!slug) { + return new Response( + JSON.stringify({ error: "Invalid LeetCode URL" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Check if problem already exists + const { data: existingProblem } = await supabaseClient + .from("problems") + .select("id, title") + .eq("id", slug) + .single(); + + if (existingProblem) { + return new Response( + JSON.stringify({ + error: "Problem already exists", + existingProblem: { id: existingProblem.id, title: existingProblem.title } + }), + { + status: 409, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + console.log(`[leetcode-import] Fetching problem: ${slug}`); + + // Fetch from LeetCode + const leetcodeProblem = await fetchLeetCodeProblem(slug); + + // Transform problem data + const description = htmlToMarkdown(leetcodeProblem.content); + const constraints = extractConstraints(leetcodeProblem.content); + const examples = extractExamples(leetcodeProblem.content); + const functionSignature = extractPythonSignature(leetcodeProblem.codeSnippets); + const categoryName = mapCategory(leetcodeProblem.topicTags); + const companies = leetcodeProblem.companyTags?.map(c => c.name) || []; + + // Look up category ID by name + let categoryId: string | null = null; + if (categoryName) { + const { data: category } = await supabaseClient + .from("categories") + .select("id, name") + .ilike("name", categoryName) + .single(); + categoryId = category?.id || null; + } + + // Fetch all categories for the response (so UI can show dropdown if mapping failed) + const { data: allCategories } = await supabaseClient + .from("categories") + .select("id, name") + .order("name"); + + const warnings: string[] = []; + + // Generate AI content + console.log(`[leetcode-import] Generating test cases for: ${slug}`); + const generatedTestCases = await generateTestCases( + leetcodeProblem.title, + description, + functionSignature, + constraints, + examples + ); + + if (generatedTestCases.length === 0) { + warnings.push("Failed to generate test cases. You can add them manually."); + } + + console.log(`[leetcode-import] Generating solutions for: ${slug}`); + const generatedSolutions = await generateSolutions( + leetcodeProblem.title, + description, + functionSignature, + constraints + ); + + if (generatedSolutions.length === 0) { + warnings.push("Failed to generate solutions. You can add them manually."); + } + + if (!categoryId) { + warnings.push(`Could not map category from tags: ${leetcodeProblem.topicTags.map(t => t.name).join(", ")}. Please select manually.`); + } + + // Build response + const result = { + problem: { + id: leetcodeProblem.titleSlug, + title: leetcodeProblem.title, + difficulty: leetcodeProblem.difficulty, + category_id: categoryId, + description, + function_signature: functionSignature, + companies, + examples, + constraints, + hints: leetcodeProblem.hints || [], + recommended_time_complexity: null, + recommended_space_complexity: null, + }, + testCases: generatedTestCases, + solutions: generatedSolutions, + warnings, + categories: allCategories || [], + }; + + console.log(`[leetcode-import] Successfully processed: ${slug}`); + + return new Response( + JSON.stringify(result), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + + } catch (error) { + console.error("[leetcode-import] Error:", error); + + const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + + return new Response( + JSON.stringify({ error: errorMessage }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } +}); diff --git a/tests/e2e/survey/survey.spec.ts b/tests/e2e/survey/survey.spec.ts index 1a3e9b1..1efdf51 100644 --- a/tests/e2e/survey/survey.spec.ts +++ b/tests/e2e/survey/survey.spec.ts @@ -6,6 +6,18 @@ import { AuthHelper } from '../../utils/test-helpers'; // Question steps (require selection): 1, 2, 3, 4, 6, 7, 8, 9, 10, 13, 14, 15 // Non-question steps (auto-continue): 5, 11, 12, 16, 17, 18, 19, 20 +const waitForStepTransition = async ( + page: Page, + urlPattern: RegExp, + textPattern: RegExp, + timeout = 90000, +) => { + await Promise.any([ + page.waitForURL(urlPattern, { timeout, waitUntil: 'domcontentloaded' }), + page.getByText(textPattern).waitFor({ state: 'visible', timeout }), + ]); +}; + test.describe('Survey Flow', () => { let authHelper: AuthHelper; @@ -313,17 +325,15 @@ test.describe('Full Survey Completion', () => { await progressContinueButton.click(); } // Wait for either the URL to change OR the Step 18 content to appear. - await Promise.race([ - page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), - expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), - ]); + const congratulationsHeading = page.getByText(/Congratulations/i); + await waitForStepTransition(page, /\/survey\/18/, /Congratulations/i, 90000); // Step 18: Congratulations (non-question step) - await expect(page.getByText(/Congratulations/i)).toBeVisible(); + await expect(congratulationsHeading).toBeVisible(); for (let i = 0; i < 3; i++) { await page.getByRole('button', { name: /Continue/i }).click(); + await page.waitForURL(/\/survey\/19/, { timeout: 500 }).catch(() => null); if (/\/survey\/19/.test(page.url())) break; - await page.waitForTimeout(500); } // Step 19: Customized Results (non-question step) @@ -506,10 +516,7 @@ test.describe('Full Survey Completion', () => { if (await stripeProgressContinueButton.isVisible().catch(() => false)) { await stripeProgressContinueButton.click(); } - await Promise.race([ - page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), - expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), - ]); + await waitForStepTransition(page, /\/survey\/18/, /Congratulations/i, 90000); // Step 18 (congratulations) await expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 60000 }); @@ -540,7 +547,13 @@ test.describe('Full Survey Completion', () => { await startButton.click(); // Wait for checkout to load or redirect - await page.waitForTimeout(5000); + await Promise.any([ + page.waitForNavigation({ timeout: 5000 }), + page.waitForSelector('text=Complete Your Subscription', { timeout: 5000 }), + page.waitForSelector('iframe[name*="__privateStripeFrame"]', { timeout: 5000 }), + page.waitForSelector('text=/error|failed|unable/i', { timeout: 5000 }), + page.waitForURL(/\/dashboard/, { timeout: 5000 }), + ]).catch(() => null); // Check possible outcomes after clicking Start My Journey: // 1. Embedded checkout UI ("Complete Your Subscription") From 2eba022080dc31304856ebbea4a5546651cbea5f Mon Sep 17 00:00:00 2001 From: ieroshenko Date: Tue, 20 Jan 2026 17:43:37 -0800 Subject: [PATCH 5/5] Enable horizontal scroll for test cases go beyond chat bar horizontally --- .../ProblemSolverTestResultsPanel.tsx | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx b/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx index 22ea22b..7961b8b 100644 --- a/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx +++ b/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx @@ -27,29 +27,32 @@ export const ProblemSolverTestResultsPanel = ({ const active = testResults[activeTestCase]; return ( - - + + Test Results - - {testResults.map((result, index) => { - let buttonClass = - 'flex items-center space-x-2 px-3 py-1.5 text-xs font-medium transition-all rounded border-2 '; - if (activeTestCase === index) { - buttonClass += result.passed - ? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600' - : 'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600'; - } else { - buttonClass += result.passed - ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100 dark:bg-green-900/10 dark:text-green-500 dark:border-green-800 dark:hover:bg-green-900/20' - : 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100 dark:bg-red-900/10 dark:text-red-500 dark:border-red-800 dark:hover:bg-red-900/20'; - } - return ( - setActiveTestCase(index)} className={buttonClass}> - {result.passed ? : } - Case {index + 1} - - ); - })} + {/* Horizontal scroll when there are many test cases */} + + + {testResults.map((result, index) => { + let buttonClass = + 'flex items-center space-x-2 px-3 py-1.5 text-xs font-medium transition-all rounded border-2 '; + if (activeTestCase === index) { + buttonClass += result.passed + ? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600' + : 'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600'; + } else { + buttonClass += result.passed + ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100 dark:bg-green-900/10 dark:text-green-500 dark:border-green-800 dark:hover:bg-green-900/20' + : 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100 dark:bg-red-900/10 dark:text-red-500 dark:border-red-800 dark:hover:bg-red-900/20'; + } + return ( + setActiveTestCase(index)} className={buttonClass}> + {result.passed ? : } + Case {index + 1} + + ); + })} +
(.*?)<\/code>/gi, "`$1`") + // Convert headers + .replace(/]*>(.*?)<\/h[1-6]>/gi, "\n## $1\n") + // Convert lists + .replace(/]*>/gi, "\n") + .replace(/<\/ul>/gi, "\n") + .replace(/]*>(.*?)<\/li>/gi, "- $1\n") + // Convert bold/strong + .replace(/(.*?)<\/strong>/gi, "**$1**") + .replace(/(.*?)<\/b>/gi, "**$1**") + // Convert italic/emphasis + .replace(/(.*?)<\/em>/gi, "*$1*") + .replace(/(.*?)<\/i>/gi, "*$1*") + // Convert subscript/superscript + .replace(/(.*?)<\/sup>/gi, "^$1") + .replace(/(.*?)<\/sub>/gi, "_$1") + // Convert paragraphs and line breaks + .replace(/]*>/gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(//gi, "\n") + // Remove remaining HTML tags + .replace(/<[^>]+>/g, "") + // Decode HTML entities + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/"/g, '"') + .replace(/'/g, "'") + // Clean up whitespace + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return md; +} + +/** + * Extract constraints from HTML content + */ +function extractConstraints(content: string): string[] { + const constraints: string[] = []; + + // Look for constraints section + const constraintsMatch = content.match(/Constraints:<\/strong>([\s\S]*?)(?:|$)/i); + if (constraintsMatch) { + const constraintsHtml = constraintsMatch[1]; + const listItems = constraintsHtml.match(/]*>([\s\S]*?)<\/li>/gi) || []; + + for (const item of listItems) { + const text = item + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/(.*?)<\/sup>/gi, "^$1") + .replace(/\s+/g, " ") + .trim(); + if (text) { + constraints.push(text); + } + } + } + + return constraints; +} + +/** + * Extract examples from HTML content + */ +function extractExamples(content: string): Array<{ input: string; output: string; explanation?: string }> { + const examples: Array<{ input: string; output: string; explanation?: string }> = []; + + // Match example blocks + const examplePattern = /]*>Example\s*\d*:?<\/strong>([\s\S]*?)(?=]*>Example|Constraints|$)/gi; + let match; + + while ((match = examplePattern.exec(content)) !== null) { + const exampleContent = match[1]; + + // Extract input + const inputMatch = exampleContent.match(/Input:<\/strong>\s*([\s\S]*?)(?=Output|$)/i); + // Extract output + const outputMatch = exampleContent.match(/Output:<\/strong>\s*([\s\S]*?)(?=Explanation|Example|Constraints|$)/i); + // Extract explanation (optional) + const explanationMatch = exampleContent.match(/Explanation:<\/strong>\s*([\s\S]*?)(?=|$)/i); + + if (inputMatch && outputMatch) { + const input = inputMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + + const output = outputMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + + const example: { input: string; output: string; explanation?: string } = { input, output }; + + if (explanationMatch) { + example.explanation = explanationMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + examples.push(example); + } + } + + return examples; +} + +/** + * Extract Python function signature from code snippets + */ +function extractPythonSignature(snippets: Array<{ lang: string; langSlug: string; code: string }>): string { + const pythonSnippet = snippets.find(s => s.langSlug === "python3" || s.langSlug === "python"); + return pythonSnippet?.code || ""; +} + +/** + * Map LeetCode tags to SimplyAlgo category + */ +function mapCategory(tags: Array<{ name: string; slug: string }>): string | null { + for (const tag of tags) { + const mapped = CATEGORY_MAPPING[tag.slug]; + if (mapped) { + return mapped; + } + } + return null; +} + +// OpenRouter client instance (initialized on first use) +let openrouterClient: OpenAI | null = null; + +function getOpenRouterClient(): OpenAI { + if (!openrouterClient) { + const apiKey = Deno.env.get("OPENROUTER_API_KEY"); + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is not set"); + } + const siteUrl = Deno.env.get("OPENROUTER_SITE_URL") || "https://simplyalgo.dev"; + const appName = Deno.env.get("OPENROUTER_APP_NAME") || "SimplyAlgo"; + + openrouterClient = new OpenAI({ + apiKey, + baseURL: "https://openrouter.ai/api/v1", + defaultHeaders: { + "HTTP-Referer": siteUrl, + "X-Title": appName, + }, + }); + } + return openrouterClient; +} + +/** + * Call OpenRouter API with Gemini to generate content + * Uses OpenAI SDK with OpenRouter baseURL (same pattern as ai-chat) + */ +async function callOpenRouter(prompt: string, maxTokens: number = 4000): Promise { + const client = getOpenRouterClient(); + // Use a known working model - hardcoded to avoid invalid env var + const model = "google/gemini-2.0-flash-001"; + + const response = await client.chat.completions.create({ + model, + messages: [{ role: "user", content: prompt }], + max_tokens: maxTokens, + temperature: 0.7, + }); + + let content = response.choices[0]?.message?.content || ""; + + // Clean markdown code blocks if present + if (content.startsWith("```json")) { + content = content.replace(/```json\n?/g, "").replace(/```\n?$/g, ""); + } else if (content.startsWith("```")) { + content = content.replace(/```\n?/g, ""); + } + + return content.trim(); +} + +/** + * Generate test cases using AI + */ +async function generateTestCases( + title: string, + description: string, + functionSignature: string, + constraints: string[], + examples: Array<{ input: string; output: string; explanation?: string }> +): Promise { + const prompt = `You are an expert algorithm engineer. Given this LeetCode problem, generate 3 additional test cases. + +PROBLEM: ${title} + +DESCRIPTION: +${description} + +FUNCTION SIGNATURE: +\`\`\`python +${functionSignature} +\`\`\` + +CONSTRAINTS: +${constraints.map(c => `- ${c}`).join("\n")} + +EXISTING EXAMPLES: +${examples.map((e, i) => `Example ${i + 1}: Input: ${e.input}, Output: ${e.output}`).join("\n")} + +Generate 3 additional test cases that cover edge cases such as: +1. Edge case (empty input, single element, boundary values) +2. Large/stress test case (near constraint limits) +3. Tricky case (corner cases that might trip up solutions) + +Return ONLY valid JSON in this exact format: +{ + "test_cases": [ + { + "input": "string representation of input (e.g., 'nums = [1,2,3], target = 6')", + "expected_output": "string representation (e.g., '[0, 2]')", + "input_json": , + "expected_json": , + "explanation": "Why this test case is important" + } + ] +} + +Important: +- input_json should be a JSON object with named parameters matching the function signature +- For example, if function is "def twoSum(nums, target)", input_json should be {"nums": [1,2,3], "target": 6} +- expected_json should be the raw expected value (array, number, string, etc)`; + + try { + const response = await callOpenRouter(prompt); + const parsed = JSON.parse(response); + return parsed.test_cases || []; + } catch (error) { + console.error("Failed to generate test cases:", error); + return []; + } +} + +/** + * Generate solutions using AI + */ +async function generateSolutions( + title: string, + description: string, + functionSignature: string, + constraints: string[] +): Promise { + const prompt = `You are an expert algorithm engineer. Given this LeetCode problem, generate 3 Python solutions. + +PROBLEM: ${title} + +DESCRIPTION: +${description} + +FUNCTION SIGNATURE: +\`\`\`python +${functionSignature} +\`\`\` + +CONSTRAINTS: +${constraints.map(c => `- ${c}`).join("\n")} + +Generate 3 different solutions: +1. Brute Force - Simple, easy to understand but not optimal +2. Optimal Solution - Best time/space complexity +3. Alternative Approach - Different technique (if applicable, otherwise another valid approach) + +Return ONLY valid JSON in this exact format: +{ + "solutions": [ + { + "title": "Brute Force", + "approach_type": "brute_force", + "code": "def solution(...):\\n # Complete Python code here", + "time_complexity": "O(...)", + "space_complexity": "O(...)", + "explanation": "Markdown explanation of the approach, why it works, and trade-offs" + }, + { + "title": "Optimal Solution", + "approach_type": "optimal", + ... + }, + { + "title": "Alternative Approach", + "approach_type": "alternative", + ... + } + ] +} + +Requirements: +- Code must be complete and runnable +- Use the exact function signature from the problem +- Include proper indentation (use \\n for newlines, 4 spaces for indentation) +- Explanations should be clear and mention key insights`; + + try { + const response = await callOpenRouter(prompt, 6000); + const parsed = JSON.parse(response); + return parsed.solutions || []; + } catch (error) { + console.error("Failed to generate solutions:", error); + return []; + } +} + +serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + // Verify authentication + const supabaseClient = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_ANON_KEY") ?? "", + { + global: { + headers: { Authorization: req.headers.get("Authorization")! }, + }, + } + ); + + const { data: { user }, error: authError } = await supabaseClient.auth.getUser(); + + if (authError || !user) { + return new Response( + JSON.stringify({ error: "Unauthorized" }), + { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Check if user is admin by email + const ADMIN_EMAILS = [ + "tazigrigolia@gmail.com", + "ivaneroshenko@gmail.com" + ]; + + if (!user.email || !ADMIN_EMAILS.includes(user.email)) { + return new Response( + JSON.stringify({ error: "Admin access required" }), + { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Parse request + const { url } = await req.json(); + + if (!url) { + return new Response( + JSON.stringify({ error: "URL is required" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Extract slug from URL + const slug = extractSlug(url); + if (!slug) { + return new Response( + JSON.stringify({ error: "Invalid LeetCode URL" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Check if problem already exists + const { data: existingProblem } = await supabaseClient + .from("problems") + .select("id, title") + .eq("id", slug) + .single(); + + if (existingProblem) { + return new Response( + JSON.stringify({ + error: "Problem already exists", + existingProblem: { id: existingProblem.id, title: existingProblem.title } + }), + { + status: 409, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + console.log(`[leetcode-import] Fetching problem: ${slug}`); + + // Fetch from LeetCode + const leetcodeProblem = await fetchLeetCodeProblem(slug); + + // Transform problem data + const description = htmlToMarkdown(leetcodeProblem.content); + const constraints = extractConstraints(leetcodeProblem.content); + const examples = extractExamples(leetcodeProblem.content); + const functionSignature = extractPythonSignature(leetcodeProblem.codeSnippets); + const categoryName = mapCategory(leetcodeProblem.topicTags); + const companies = leetcodeProblem.companyTags?.map(c => c.name) || []; + + // Look up category ID by name + let categoryId: string | null = null; + if (categoryName) { + const { data: category } = await supabaseClient + .from("categories") + .select("id, name") + .ilike("name", categoryName) + .single(); + categoryId = category?.id || null; + } + + // Fetch all categories for the response (so UI can show dropdown if mapping failed) + const { data: allCategories } = await supabaseClient + .from("categories") + .select("id, name") + .order("name"); + + const warnings: string[] = []; + + // Generate AI content + console.log(`[leetcode-import] Generating test cases for: ${slug}`); + const generatedTestCases = await generateTestCases( + leetcodeProblem.title, + description, + functionSignature, + constraints, + examples + ); + + if (generatedTestCases.length === 0) { + warnings.push("Failed to generate test cases. You can add them manually."); + } + + console.log(`[leetcode-import] Generating solutions for: ${slug}`); + const generatedSolutions = await generateSolutions( + leetcodeProblem.title, + description, + functionSignature, + constraints + ); + + if (generatedSolutions.length === 0) { + warnings.push("Failed to generate solutions. You can add them manually."); + } + + if (!categoryId) { + warnings.push(`Could not map category from tags: ${leetcodeProblem.topicTags.map(t => t.name).join(", ")}. Please select manually.`); + } + + // Build response + const result = { + problem: { + id: leetcodeProblem.titleSlug, + title: leetcodeProblem.title, + difficulty: leetcodeProblem.difficulty, + category_id: categoryId, + description, + function_signature: functionSignature, + companies, + examples, + constraints, + hints: leetcodeProblem.hints || [], + recommended_time_complexity: null, + recommended_space_complexity: null, + }, + testCases: generatedTestCases, + solutions: generatedSolutions, + warnings, + categories: allCategories || [], + }; + + console.log(`[leetcode-import] Successfully processed: ${slug}`); + + return new Response( + JSON.stringify(result), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + + } catch (error) { + console.error("[leetcode-import] Error:", error); + + const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + + return new Response( + JSON.stringify({ error: errorMessage }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } +}); diff --git a/tests/e2e/survey/survey.spec.ts b/tests/e2e/survey/survey.spec.ts index 1a3e9b1..1efdf51 100644 --- a/tests/e2e/survey/survey.spec.ts +++ b/tests/e2e/survey/survey.spec.ts @@ -6,6 +6,18 @@ import { AuthHelper } from '../../utils/test-helpers'; // Question steps (require selection): 1, 2, 3, 4, 6, 7, 8, 9, 10, 13, 14, 15 // Non-question steps (auto-continue): 5, 11, 12, 16, 17, 18, 19, 20 +const waitForStepTransition = async ( + page: Page, + urlPattern: RegExp, + textPattern: RegExp, + timeout = 90000, +) => { + await Promise.any([ + page.waitForURL(urlPattern, { timeout, waitUntil: 'domcontentloaded' }), + page.getByText(textPattern).waitFor({ state: 'visible', timeout }), + ]); +}; + test.describe('Survey Flow', () => { let authHelper: AuthHelper; @@ -313,17 +325,15 @@ test.describe('Full Survey Completion', () => { await progressContinueButton.click(); } // Wait for either the URL to change OR the Step 18 content to appear. - await Promise.race([ - page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), - expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), - ]); + const congratulationsHeading = page.getByText(/Congratulations/i); + await waitForStepTransition(page, /\/survey\/18/, /Congratulations/i, 90000); // Step 18: Congratulations (non-question step) - await expect(page.getByText(/Congratulations/i)).toBeVisible(); + await expect(congratulationsHeading).toBeVisible(); for (let i = 0; i < 3; i++) { await page.getByRole('button', { name: /Continue/i }).click(); + await page.waitForURL(/\/survey\/19/, { timeout: 500 }).catch(() => null); if (/\/survey\/19/.test(page.url())) break; - await page.waitForTimeout(500); } // Step 19: Customized Results (non-question step) @@ -506,10 +516,7 @@ test.describe('Full Survey Completion', () => { if (await stripeProgressContinueButton.isVisible().catch(() => false)) { await stripeProgressContinueButton.click(); } - await Promise.race([ - page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), - expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), - ]); + await waitForStepTransition(page, /\/survey\/18/, /Congratulations/i, 90000); // Step 18 (congratulations) await expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 60000 }); @@ -540,7 +547,13 @@ test.describe('Full Survey Completion', () => { await startButton.click(); // Wait for checkout to load or redirect - await page.waitForTimeout(5000); + await Promise.any([ + page.waitForNavigation({ timeout: 5000 }), + page.waitForSelector('text=Complete Your Subscription', { timeout: 5000 }), + page.waitForSelector('iframe[name*="__privateStripeFrame"]', { timeout: 5000 }), + page.waitForSelector('text=/error|failed|unable/i', { timeout: 5000 }), + page.waitForURL(/\/dashboard/, { timeout: 5000 }), + ]).catch(() => null); // Check possible outcomes after clicking Start My Journey: // 1. Embedded checkout UI ("Complete Your Subscription") From 2eba022080dc31304856ebbea4a5546651cbea5f Mon Sep 17 00:00:00 2001 From: ieroshenko Date: Tue, 20 Jan 2026 17:43:37 -0800 Subject: [PATCH 5/5] Enable horizontal scroll for test cases go beyond chat bar horizontally --- .../ProblemSolverTestResultsPanel.tsx | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx b/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx index 22ea22b..7961b8b 100644 --- a/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx +++ b/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx @@ -27,29 +27,32 @@ export const ProblemSolverTestResultsPanel = ({ const active = testResults[activeTestCase]; return ( - - + + Test Results - - {testResults.map((result, index) => { - let buttonClass = - 'flex items-center space-x-2 px-3 py-1.5 text-xs font-medium transition-all rounded border-2 '; - if (activeTestCase === index) { - buttonClass += result.passed - ? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600' - : 'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600'; - } else { - buttonClass += result.passed - ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100 dark:bg-green-900/10 dark:text-green-500 dark:border-green-800 dark:hover:bg-green-900/20' - : 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100 dark:bg-red-900/10 dark:text-red-500 dark:border-red-800 dark:hover:bg-red-900/20'; - } - return ( - setActiveTestCase(index)} className={buttonClass}> - {result.passed ? : } - Case {index + 1} - - ); - })} + {/* Horizontal scroll when there are many test cases */} + + + {testResults.map((result, index) => { + let buttonClass = + 'flex items-center space-x-2 px-3 py-1.5 text-xs font-medium transition-all rounded border-2 '; + if (activeTestCase === index) { + buttonClass += result.passed + ? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600' + : 'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600'; + } else { + buttonClass += result.passed + ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100 dark:bg-green-900/10 dark:text-green-500 dark:border-green-800 dark:hover:bg-green-900/20' + : 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100 dark:bg-red-900/10 dark:text-red-500 dark:border-red-800 dark:hover:bg-red-900/20'; + } + return ( + setActiveTestCase(index)} className={buttonClass}> + {result.passed ? : } + Case {index + 1} + + ); + })} +
]*>/gi, "\n") + .replace(/<\/p>/gi, "\n") + .replace(//gi, "\n") + // Remove remaining HTML tags + .replace(/<[^>]+>/g, "") + // Decode HTML entities + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/"/g, '"') + .replace(/'/g, "'") + // Clean up whitespace + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return md; +} + +/** + * Extract constraints from HTML content + */ +function extractConstraints(content: string): string[] { + const constraints: string[] = []; + + // Look for constraints section + const constraintsMatch = content.match(/Constraints:<\/strong>([\s\S]*?)(?:|$)/i); + if (constraintsMatch) { + const constraintsHtml = constraintsMatch[1]; + const listItems = constraintsHtml.match(/]*>([\s\S]*?)<\/li>/gi) || []; + + for (const item of listItems) { + const text = item + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/(.*?)<\/sup>/gi, "^$1") + .replace(/\s+/g, " ") + .trim(); + if (text) { + constraints.push(text); + } + } + } + + return constraints; +} + +/** + * Extract examples from HTML content + */ +function extractExamples(content: string): Array<{ input: string; output: string; explanation?: string }> { + const examples: Array<{ input: string; output: string; explanation?: string }> = []; + + // Match example blocks + const examplePattern = /]*>Example\s*\d*:?<\/strong>([\s\S]*?)(?=]*>Example|Constraints|$)/gi; + let match; + + while ((match = examplePattern.exec(content)) !== null) { + const exampleContent = match[1]; + + // Extract input + const inputMatch = exampleContent.match(/Input:<\/strong>\s*([\s\S]*?)(?=Output|$)/i); + // Extract output + const outputMatch = exampleContent.match(/Output:<\/strong>\s*([\s\S]*?)(?=Explanation|Example|Constraints|$)/i); + // Extract explanation (optional) + const explanationMatch = exampleContent.match(/Explanation:<\/strong>\s*([\s\S]*?)(?=|$)/i); + + if (inputMatch && outputMatch) { + const input = inputMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + + const output = outputMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + + const example: { input: string; output: string; explanation?: string } = { input, output }; + + if (explanationMatch) { + example.explanation = explanationMatch[1] + .replace(/<[^>]+>/g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + examples.push(example); + } + } + + return examples; +} + +/** + * Extract Python function signature from code snippets + */ +function extractPythonSignature(snippets: Array<{ lang: string; langSlug: string; code: string }>): string { + const pythonSnippet = snippets.find(s => s.langSlug === "python3" || s.langSlug === "python"); + return pythonSnippet?.code || ""; +} + +/** + * Map LeetCode tags to SimplyAlgo category + */ +function mapCategory(tags: Array<{ name: string; slug: string }>): string | null { + for (const tag of tags) { + const mapped = CATEGORY_MAPPING[tag.slug]; + if (mapped) { + return mapped; + } + } + return null; +} + +// OpenRouter client instance (initialized on first use) +let openrouterClient: OpenAI | null = null; + +function getOpenRouterClient(): OpenAI { + if (!openrouterClient) { + const apiKey = Deno.env.get("OPENROUTER_API_KEY"); + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is not set"); + } + const siteUrl = Deno.env.get("OPENROUTER_SITE_URL") || "https://simplyalgo.dev"; + const appName = Deno.env.get("OPENROUTER_APP_NAME") || "SimplyAlgo"; + + openrouterClient = new OpenAI({ + apiKey, + baseURL: "https://openrouter.ai/api/v1", + defaultHeaders: { + "HTTP-Referer": siteUrl, + "X-Title": appName, + }, + }); + } + return openrouterClient; +} + +/** + * Call OpenRouter API with Gemini to generate content + * Uses OpenAI SDK with OpenRouter baseURL (same pattern as ai-chat) + */ +async function callOpenRouter(prompt: string, maxTokens: number = 4000): Promise { + const client = getOpenRouterClient(); + // Use a known working model - hardcoded to avoid invalid env var + const model = "google/gemini-2.0-flash-001"; + + const response = await client.chat.completions.create({ + model, + messages: [{ role: "user", content: prompt }], + max_tokens: maxTokens, + temperature: 0.7, + }); + + let content = response.choices[0]?.message?.content || ""; + + // Clean markdown code blocks if present + if (content.startsWith("```json")) { + content = content.replace(/```json\n?/g, "").replace(/```\n?$/g, ""); + } else if (content.startsWith("```")) { + content = content.replace(/```\n?/g, ""); + } + + return content.trim(); +} + +/** + * Generate test cases using AI + */ +async function generateTestCases( + title: string, + description: string, + functionSignature: string, + constraints: string[], + examples: Array<{ input: string; output: string; explanation?: string }> +): Promise { + const prompt = `You are an expert algorithm engineer. Given this LeetCode problem, generate 3 additional test cases. + +PROBLEM: ${title} + +DESCRIPTION: +${description} + +FUNCTION SIGNATURE: +\`\`\`python +${functionSignature} +\`\`\` + +CONSTRAINTS: +${constraints.map(c => `- ${c}`).join("\n")} + +EXISTING EXAMPLES: +${examples.map((e, i) => `Example ${i + 1}: Input: ${e.input}, Output: ${e.output}`).join("\n")} + +Generate 3 additional test cases that cover edge cases such as: +1. Edge case (empty input, single element, boundary values) +2. Large/stress test case (near constraint limits) +3. Tricky case (corner cases that might trip up solutions) + +Return ONLY valid JSON in this exact format: +{ + "test_cases": [ + { + "input": "string representation of input (e.g., 'nums = [1,2,3], target = 6')", + "expected_output": "string representation (e.g., '[0, 2]')", + "input_json": , + "expected_json": , + "explanation": "Why this test case is important" + } + ] +} + +Important: +- input_json should be a JSON object with named parameters matching the function signature +- For example, if function is "def twoSum(nums, target)", input_json should be {"nums": [1,2,3], "target": 6} +- expected_json should be the raw expected value (array, number, string, etc)`; + + try { + const response = await callOpenRouter(prompt); + const parsed = JSON.parse(response); + return parsed.test_cases || []; + } catch (error) { + console.error("Failed to generate test cases:", error); + return []; + } +} + +/** + * Generate solutions using AI + */ +async function generateSolutions( + title: string, + description: string, + functionSignature: string, + constraints: string[] +): Promise { + const prompt = `You are an expert algorithm engineer. Given this LeetCode problem, generate 3 Python solutions. + +PROBLEM: ${title} + +DESCRIPTION: +${description} + +FUNCTION SIGNATURE: +\`\`\`python +${functionSignature} +\`\`\` + +CONSTRAINTS: +${constraints.map(c => `- ${c}`).join("\n")} + +Generate 3 different solutions: +1. Brute Force - Simple, easy to understand but not optimal +2. Optimal Solution - Best time/space complexity +3. Alternative Approach - Different technique (if applicable, otherwise another valid approach) + +Return ONLY valid JSON in this exact format: +{ + "solutions": [ + { + "title": "Brute Force", + "approach_type": "brute_force", + "code": "def solution(...):\\n # Complete Python code here", + "time_complexity": "O(...)", + "space_complexity": "O(...)", + "explanation": "Markdown explanation of the approach, why it works, and trade-offs" + }, + { + "title": "Optimal Solution", + "approach_type": "optimal", + ... + }, + { + "title": "Alternative Approach", + "approach_type": "alternative", + ... + } + ] +} + +Requirements: +- Code must be complete and runnable +- Use the exact function signature from the problem +- Include proper indentation (use \\n for newlines, 4 spaces for indentation) +- Explanations should be clear and mention key insights`; + + try { + const response = await callOpenRouter(prompt, 6000); + const parsed = JSON.parse(response); + return parsed.solutions || []; + } catch (error) { + console.error("Failed to generate solutions:", error); + return []; + } +} + +serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + // Verify authentication + const supabaseClient = createClient( + Deno.env.get("SUPABASE_URL") ?? "", + Deno.env.get("SUPABASE_ANON_KEY") ?? "", + { + global: { + headers: { Authorization: req.headers.get("Authorization")! }, + }, + } + ); + + const { data: { user }, error: authError } = await supabaseClient.auth.getUser(); + + if (authError || !user) { + return new Response( + JSON.stringify({ error: "Unauthorized" }), + { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Check if user is admin by email + const ADMIN_EMAILS = [ + "tazigrigolia@gmail.com", + "ivaneroshenko@gmail.com" + ]; + + if (!user.email || !ADMIN_EMAILS.includes(user.email)) { + return new Response( + JSON.stringify({ error: "Admin access required" }), + { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Parse request + const { url } = await req.json(); + + if (!url) { + return new Response( + JSON.stringify({ error: "URL is required" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Extract slug from URL + const slug = extractSlug(url); + if (!slug) { + return new Response( + JSON.stringify({ error: "Invalid LeetCode URL" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + // Check if problem already exists + const { data: existingProblem } = await supabaseClient + .from("problems") + .select("id, title") + .eq("id", slug) + .single(); + + if (existingProblem) { + return new Response( + JSON.stringify({ + error: "Problem already exists", + existingProblem: { id: existingProblem.id, title: existingProblem.title } + }), + { + status: 409, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } + + console.log(`[leetcode-import] Fetching problem: ${slug}`); + + // Fetch from LeetCode + const leetcodeProblem = await fetchLeetCodeProblem(slug); + + // Transform problem data + const description = htmlToMarkdown(leetcodeProblem.content); + const constraints = extractConstraints(leetcodeProblem.content); + const examples = extractExamples(leetcodeProblem.content); + const functionSignature = extractPythonSignature(leetcodeProblem.codeSnippets); + const categoryName = mapCategory(leetcodeProblem.topicTags); + const companies = leetcodeProblem.companyTags?.map(c => c.name) || []; + + // Look up category ID by name + let categoryId: string | null = null; + if (categoryName) { + const { data: category } = await supabaseClient + .from("categories") + .select("id, name") + .ilike("name", categoryName) + .single(); + categoryId = category?.id || null; + } + + // Fetch all categories for the response (so UI can show dropdown if mapping failed) + const { data: allCategories } = await supabaseClient + .from("categories") + .select("id, name") + .order("name"); + + const warnings: string[] = []; + + // Generate AI content + console.log(`[leetcode-import] Generating test cases for: ${slug}`); + const generatedTestCases = await generateTestCases( + leetcodeProblem.title, + description, + functionSignature, + constraints, + examples + ); + + if (generatedTestCases.length === 0) { + warnings.push("Failed to generate test cases. You can add them manually."); + } + + console.log(`[leetcode-import] Generating solutions for: ${slug}`); + const generatedSolutions = await generateSolutions( + leetcodeProblem.title, + description, + functionSignature, + constraints + ); + + if (generatedSolutions.length === 0) { + warnings.push("Failed to generate solutions. You can add them manually."); + } + + if (!categoryId) { + warnings.push(`Could not map category from tags: ${leetcodeProblem.topicTags.map(t => t.name).join(", ")}. Please select manually.`); + } + + // Build response + const result = { + problem: { + id: leetcodeProblem.titleSlug, + title: leetcodeProblem.title, + difficulty: leetcodeProblem.difficulty, + category_id: categoryId, + description, + function_signature: functionSignature, + companies, + examples, + constraints, + hints: leetcodeProblem.hints || [], + recommended_time_complexity: null, + recommended_space_complexity: null, + }, + testCases: generatedTestCases, + solutions: generatedSolutions, + warnings, + categories: allCategories || [], + }; + + console.log(`[leetcode-import] Successfully processed: ${slug}`); + + return new Response( + JSON.stringify(result), + { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + + } catch (error) { + console.error("[leetcode-import] Error:", error); + + const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + + return new Response( + JSON.stringify({ error: errorMessage }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + } + ); + } +}); diff --git a/tests/e2e/survey/survey.spec.ts b/tests/e2e/survey/survey.spec.ts index 1a3e9b1..1efdf51 100644 --- a/tests/e2e/survey/survey.spec.ts +++ b/tests/e2e/survey/survey.spec.ts @@ -6,6 +6,18 @@ import { AuthHelper } from '../../utils/test-helpers'; // Question steps (require selection): 1, 2, 3, 4, 6, 7, 8, 9, 10, 13, 14, 15 // Non-question steps (auto-continue): 5, 11, 12, 16, 17, 18, 19, 20 +const waitForStepTransition = async ( + page: Page, + urlPattern: RegExp, + textPattern: RegExp, + timeout = 90000, +) => { + await Promise.any([ + page.waitForURL(urlPattern, { timeout, waitUntil: 'domcontentloaded' }), + page.getByText(textPattern).waitFor({ state: 'visible', timeout }), + ]); +}; + test.describe('Survey Flow', () => { let authHelper: AuthHelper; @@ -313,17 +325,15 @@ test.describe('Full Survey Completion', () => { await progressContinueButton.click(); } // Wait for either the URL to change OR the Step 18 content to appear. - await Promise.race([ - page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), - expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), - ]); + const congratulationsHeading = page.getByText(/Congratulations/i); + await waitForStepTransition(page, /\/survey\/18/, /Congratulations/i, 90000); // Step 18: Congratulations (non-question step) - await expect(page.getByText(/Congratulations/i)).toBeVisible(); + await expect(congratulationsHeading).toBeVisible(); for (let i = 0; i < 3; i++) { await page.getByRole('button', { name: /Continue/i }).click(); + await page.waitForURL(/\/survey\/19/, { timeout: 500 }).catch(() => null); if (/\/survey\/19/.test(page.url())) break; - await page.waitForTimeout(500); } // Step 19: Customized Results (non-question step) @@ -506,10 +516,7 @@ test.describe('Full Survey Completion', () => { if (await stripeProgressContinueButton.isVisible().catch(() => false)) { await stripeProgressContinueButton.click(); } - await Promise.race([ - page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), - expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), - ]); + await waitForStepTransition(page, /\/survey\/18/, /Congratulations/i, 90000); // Step 18 (congratulations) await expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 60000 }); @@ -540,7 +547,13 @@ test.describe('Full Survey Completion', () => { await startButton.click(); // Wait for checkout to load or redirect - await page.waitForTimeout(5000); + await Promise.any([ + page.waitForNavigation({ timeout: 5000 }), + page.waitForSelector('text=Complete Your Subscription', { timeout: 5000 }), + page.waitForSelector('iframe[name*="__privateStripeFrame"]', { timeout: 5000 }), + page.waitForSelector('text=/error|failed|unable/i', { timeout: 5000 }), + page.waitForURL(/\/dashboard/, { timeout: 5000 }), + ]).catch(() => null); // Check possible outcomes after clicking Start My Journey: // 1. Embedded checkout UI ("Complete Your Subscription") From 2eba022080dc31304856ebbea4a5546651cbea5f Mon Sep 17 00:00:00 2001 From: ieroshenko Date: Tue, 20 Jan 2026 17:43:37 -0800 Subject: [PATCH 5/5] Enable horizontal scroll for test cases go beyond chat bar horizontally --- .../ProblemSolverTestResultsPanel.tsx | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx b/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx index 22ea22b..7961b8b 100644 --- a/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx +++ b/src/features/problem-solver/components/ProblemSolverTestResultsPanel.tsx @@ -27,29 +27,32 @@ export const ProblemSolverTestResultsPanel = ({ const active = testResults[activeTestCase]; return ( - - + + Test Results - - {testResults.map((result, index) => { - let buttonClass = - 'flex items-center space-x-2 px-3 py-1.5 text-xs font-medium transition-all rounded border-2 '; - if (activeTestCase === index) { - buttonClass += result.passed - ? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600' - : 'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600'; - } else { - buttonClass += result.passed - ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100 dark:bg-green-900/10 dark:text-green-500 dark:border-green-800 dark:hover:bg-green-900/20' - : 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100 dark:bg-red-900/10 dark:text-red-500 dark:border-red-800 dark:hover:bg-red-900/20'; - } - return ( - setActiveTestCase(index)} className={buttonClass}> - {result.passed ? : } - Case {index + 1} - - ); - })} + {/* Horizontal scroll when there are many test cases */} + + + {testResults.map((result, index) => { + let buttonClass = + 'flex items-center space-x-2 px-3 py-1.5 text-xs font-medium transition-all rounded border-2 '; + if (activeTestCase === index) { + buttonClass += result.passed + ? 'bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-600' + : 'bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-400 dark:border-red-600'; + } else { + buttonClass += result.passed + ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100 dark:bg-green-900/10 dark:text-green-500 dark:border-green-800 dark:hover:bg-green-900/20' + : 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100 dark:bg-red-900/10 dark:text-red-500 dark:border-red-800 dark:hover:bg-red-900/20'; + } + return ( + setActiveTestCase(index)} className={buttonClass}> + {result.passed ? : } + Case {index + 1} + + ); + })} +