cbrowser 5.3.0 ā 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/dist/browser.d.ts +87 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +626 -0
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +459 -2
- package/dist/cli.js.map +1 -1
- package/dist/types.d.ts +90 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -14,6 +14,13 @@ exports.huntBugs = huntBugs;
|
|
|
14
14
|
exports.crossBrowserDiff = crossBrowserDiff;
|
|
15
15
|
exports.applyChaos = applyChaos;
|
|
16
16
|
exports.runChaosTest = runChaosTest;
|
|
17
|
+
exports.comparePersonas = comparePersonas;
|
|
18
|
+
exports.formatComparisonReport = formatComparisonReport;
|
|
19
|
+
exports.parseNLInstruction = parseNLInstruction;
|
|
20
|
+
exports.parseNLTestSuite = parseNLTestSuite;
|
|
21
|
+
exports.runNLTestSuite = runNLTestSuite;
|
|
22
|
+
exports.formatNLTestReport = formatNLTestReport;
|
|
23
|
+
exports.runNLTestFile = runNLTestFile;
|
|
17
24
|
const playwright_1 = require("playwright");
|
|
18
25
|
const fs_1 = require("fs");
|
|
19
26
|
const path_1 = require("path");
|
|
@@ -2671,4 +2678,623 @@ async function runChaosTest(browser, url, chaos, actions = []) {
|
|
|
2671
2678
|
};
|
|
2672
2679
|
}
|
|
2673
2680
|
}
|
|
2681
|
+
/**
|
|
2682
|
+
* Run the same journey with multiple personas and compare results.
|
|
2683
|
+
* This runs personas in parallel (up to maxConcurrency) for efficiency.
|
|
2684
|
+
*/
|
|
2685
|
+
async function comparePersonas(options) {
|
|
2686
|
+
const { startUrl, goal, personas, maxSteps = 20, maxConcurrency = 3, headless = true, } = options;
|
|
2687
|
+
const startTime = Date.now();
|
|
2688
|
+
const results = [];
|
|
2689
|
+
console.log(`\nš Comparing ${personas.length} personas...`);
|
|
2690
|
+
console.log(` URL: ${startUrl}`);
|
|
2691
|
+
console.log(` Goal: ${goal}`);
|
|
2692
|
+
console.log(` Concurrency: ${maxConcurrency}\n`);
|
|
2693
|
+
// Process personas in batches
|
|
2694
|
+
for (let i = 0; i < personas.length; i += maxConcurrency) {
|
|
2695
|
+
const batch = personas.slice(i, i + maxConcurrency);
|
|
2696
|
+
console.log(`š¦ Batch ${Math.floor(i / maxConcurrency) + 1}: ${batch.join(", ")}`);
|
|
2697
|
+
const batchPromises = batch.map(async (personaName) => {
|
|
2698
|
+
const browser = new CBrowser({ headless });
|
|
2699
|
+
try {
|
|
2700
|
+
const persona = (0, personas_js_1.getPersona)(personaName) || personas_js_1.BUILTIN_PERSONAS["first-timer"];
|
|
2701
|
+
const journeyStart = Date.now();
|
|
2702
|
+
// Run the journey
|
|
2703
|
+
const journey = await browser.journey({
|
|
2704
|
+
persona: personaName,
|
|
2705
|
+
startUrl,
|
|
2706
|
+
goal,
|
|
2707
|
+
maxSteps,
|
|
2708
|
+
});
|
|
2709
|
+
// Calculate average reaction time from persona config
|
|
2710
|
+
const timing = persona.humanBehavior?.timing;
|
|
2711
|
+
const avgReactionTime = timing
|
|
2712
|
+
? (timing.reactionTime.min + timing.reactionTime.max) / 2
|
|
2713
|
+
: 500;
|
|
2714
|
+
// Calculate error rate from persona config
|
|
2715
|
+
const errors = persona.humanBehavior?.errors;
|
|
2716
|
+
const errorRate = errors
|
|
2717
|
+
? (errors.misClickRate + errors.typoRate) / 2
|
|
2718
|
+
: 0.05;
|
|
2719
|
+
const result = {
|
|
2720
|
+
persona: personaName,
|
|
2721
|
+
description: persona.description,
|
|
2722
|
+
techLevel: persona.demographics.tech_level || "intermediate",
|
|
2723
|
+
device: persona.demographics.device || "desktop",
|
|
2724
|
+
success: journey.success,
|
|
2725
|
+
totalTime: journey.totalTime,
|
|
2726
|
+
stepCount: journey.steps.length,
|
|
2727
|
+
frictionCount: journey.frictionPoints.length,
|
|
2728
|
+
frictionPoints: journey.frictionPoints,
|
|
2729
|
+
avgReactionTime,
|
|
2730
|
+
errorRate,
|
|
2731
|
+
screenshots: {
|
|
2732
|
+
start: journey.steps[0]?.screenshot || "",
|
|
2733
|
+
end: journey.steps[journey.steps.length - 1]?.screenshot || "",
|
|
2734
|
+
},
|
|
2735
|
+
};
|
|
2736
|
+
console.log(` ā ${personaName}: ${journey.success ? "SUCCESS" : "FAILED"} (${journey.totalTime}ms, ${journey.frictionPoints.length} friction)`);
|
|
2737
|
+
return result;
|
|
2738
|
+
}
|
|
2739
|
+
catch (e) {
|
|
2740
|
+
console.log(` ā ${personaName}: ERROR - ${e.message}`);
|
|
2741
|
+
return {
|
|
2742
|
+
persona: personaName,
|
|
2743
|
+
description: "Unknown",
|
|
2744
|
+
techLevel: "unknown",
|
|
2745
|
+
device: "unknown",
|
|
2746
|
+
success: false,
|
|
2747
|
+
totalTime: 0,
|
|
2748
|
+
stepCount: 0,
|
|
2749
|
+
frictionCount: 1,
|
|
2750
|
+
frictionPoints: [`Error: ${e.message}`],
|
|
2751
|
+
avgReactionTime: 0,
|
|
2752
|
+
errorRate: 0,
|
|
2753
|
+
screenshots: { start: "", end: "" },
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
finally {
|
|
2757
|
+
await browser.close();
|
|
2758
|
+
}
|
|
2759
|
+
});
|
|
2760
|
+
const batchResults = await Promise.all(batchPromises);
|
|
2761
|
+
results.push(...batchResults);
|
|
2762
|
+
}
|
|
2763
|
+
// Generate summary
|
|
2764
|
+
const successfulResults = results.filter((r) => r.success);
|
|
2765
|
+
const failedResults = results.filter((r) => !r.success);
|
|
2766
|
+
const sortedByTime = [...successfulResults].sort((a, b) => a.totalTime - b.totalTime);
|
|
2767
|
+
const sortedByFriction = [...results].sort((a, b) => b.frictionCount - a.frictionCount);
|
|
2768
|
+
// Collect all friction points
|
|
2769
|
+
const allFrictionPoints = results.flatMap((r) => r.frictionPoints);
|
|
2770
|
+
const frictionCounts = allFrictionPoints.reduce((acc, fp) => {
|
|
2771
|
+
acc[fp] = (acc[fp] || 0) + 1;
|
|
2772
|
+
return acc;
|
|
2773
|
+
}, {});
|
|
2774
|
+
const commonFriction = Object.entries(frictionCounts)
|
|
2775
|
+
.filter(([_, count]) => count > 1)
|
|
2776
|
+
.sort((a, b) => b[1] - a[1])
|
|
2777
|
+
.slice(0, 5)
|
|
2778
|
+
.map(([fp]) => fp);
|
|
2779
|
+
// Generate recommendations
|
|
2780
|
+
const recommendations = [];
|
|
2781
|
+
if (failedResults.length > 0) {
|
|
2782
|
+
recommendations.push(`ā ļø ${failedResults.length} persona(s) failed to complete the journey: ${failedResults.map((r) => r.persona).join(", ")}`);
|
|
2783
|
+
}
|
|
2784
|
+
if (sortedByFriction[0]?.frictionCount > 0) {
|
|
2785
|
+
recommendations.push(`š§ "${sortedByFriction[0].persona}" experienced the most friction (${sortedByFriction[0].frictionCount} points) - review for accessibility improvements`);
|
|
2786
|
+
}
|
|
2787
|
+
const beginnerPersonas = results.filter((r) => r.techLevel === "beginner");
|
|
2788
|
+
const expertPersonas = results.filter((r) => r.techLevel === "expert");
|
|
2789
|
+
if (beginnerPersonas.length > 0 && expertPersonas.length > 0) {
|
|
2790
|
+
const avgBeginnerTime = beginnerPersonas.reduce((sum, r) => sum + r.totalTime, 0) / beginnerPersonas.length;
|
|
2791
|
+
const avgExpertTime = expertPersonas.reduce((sum, r) => sum + r.totalTime, 0) / expertPersonas.length;
|
|
2792
|
+
if (avgBeginnerTime > avgExpertTime * 3) {
|
|
2793
|
+
recommendations.push(`š Beginners take ${(avgBeginnerTime / avgExpertTime).toFixed(1)}x longer than experts - consider adding more guidance`);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
const mobilePersonas = results.filter((r) => r.device === "mobile");
|
|
2797
|
+
const desktopPersonas = results.filter((r) => r.device === "desktop");
|
|
2798
|
+
if (mobilePersonas.length > 0 && desktopPersonas.length > 0) {
|
|
2799
|
+
const mobileFriction = mobilePersonas.reduce((sum, r) => sum + r.frictionCount, 0) / mobilePersonas.length;
|
|
2800
|
+
const desktopFriction = desktopPersonas.reduce((sum, r) => sum + r.frictionCount, 0) / desktopPersonas.length;
|
|
2801
|
+
if (mobileFriction > desktopFriction * 2) {
|
|
2802
|
+
recommendations.push(`š± Mobile users experience ${(mobileFriction / desktopFriction).toFixed(1)}x more friction - review mobile UX`);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
if (commonFriction.length > 0) {
|
|
2806
|
+
recommendations.push(`šÆ Common friction points across personas: ${commonFriction.slice(0, 3).join("; ")}`);
|
|
2807
|
+
}
|
|
2808
|
+
if (recommendations.length === 0) {
|
|
2809
|
+
recommendations.push("ā
All personas completed the journey without significant issues");
|
|
2810
|
+
}
|
|
2811
|
+
const avgTime = successfulResults.length > 0
|
|
2812
|
+
? successfulResults.reduce((sum, r) => sum + r.totalTime, 0) / successfulResults.length
|
|
2813
|
+
: 0;
|
|
2814
|
+
const comparison = {
|
|
2815
|
+
url: startUrl,
|
|
2816
|
+
goal,
|
|
2817
|
+
timestamp: new Date().toISOString(),
|
|
2818
|
+
duration: Date.now() - startTime,
|
|
2819
|
+
personas: results,
|
|
2820
|
+
summary: {
|
|
2821
|
+
totalPersonas: personas.length,
|
|
2822
|
+
successCount: successfulResults.length,
|
|
2823
|
+
failureCount: failedResults.length,
|
|
2824
|
+
fastestPersona: sortedByTime[0]?.persona || "N/A",
|
|
2825
|
+
slowestPersona: sortedByTime[sortedByTime.length - 1]?.persona || "N/A",
|
|
2826
|
+
mostFriction: sortedByFriction[0]?.persona || "N/A",
|
|
2827
|
+
leastFriction: sortedByFriction[sortedByFriction.length - 1]?.persona || "N/A",
|
|
2828
|
+
avgCompletionTime: Math.round(avgTime),
|
|
2829
|
+
commonFrictionPoints: commonFriction,
|
|
2830
|
+
},
|
|
2831
|
+
recommendations,
|
|
2832
|
+
};
|
|
2833
|
+
return comparison;
|
|
2834
|
+
}
|
|
2835
|
+
/**
|
|
2836
|
+
* Generate a formatted comparison report.
|
|
2837
|
+
*/
|
|
2838
|
+
function formatComparisonReport(comparison) {
|
|
2839
|
+
const lines = [];
|
|
2840
|
+
lines.push("");
|
|
2841
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
|
|
2842
|
+
lines.push("ā MULTI-PERSONA COMPARISON REPORT ā");
|
|
2843
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
|
|
2844
|
+
lines.push("");
|
|
2845
|
+
lines.push(`š URL: ${comparison.url}`);
|
|
2846
|
+
lines.push(`šÆ Goal: ${comparison.goal}`);
|
|
2847
|
+
lines.push(`ā±ļø Total Duration: ${(comparison.duration / 1000).toFixed(1)}s`);
|
|
2848
|
+
lines.push(`š
Timestamp: ${comparison.timestamp}`);
|
|
2849
|
+
lines.push("");
|
|
2850
|
+
// Results table
|
|
2851
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
|
|
2852
|
+
lines.push("ā Persona ā Success ā Time ā Steps ā Friction ā Key Issues ā");
|
|
2853
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤");
|
|
2854
|
+
for (const result of comparison.personas) {
|
|
2855
|
+
const name = result.persona.padEnd(19).slice(0, 19);
|
|
2856
|
+
const success = result.success ? "ā".padEnd(8) : "ā".padEnd(8);
|
|
2857
|
+
const time = `${(result.totalTime / 1000).toFixed(1)}s`.padEnd(8);
|
|
2858
|
+
const steps = `${result.stepCount}`.padEnd(8);
|
|
2859
|
+
const friction = `${result.frictionCount}`.padEnd(8);
|
|
2860
|
+
const issues = (result.frictionPoints[0] || "-").slice(0, 27).padEnd(27);
|
|
2861
|
+
lines.push(`ā ${name} ā ${success} ā ${time} ā ${steps} ā ${friction} ā ${issues} ā`);
|
|
2862
|
+
}
|
|
2863
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
|
|
2864
|
+
lines.push("");
|
|
2865
|
+
// Summary
|
|
2866
|
+
lines.push("š SUMMARY");
|
|
2867
|
+
lines.push("ā".repeat(60));
|
|
2868
|
+
lines.push(` Total Personas: ${comparison.summary.totalPersonas}`);
|
|
2869
|
+
lines.push(` Success Rate: ${comparison.summary.successCount}/${comparison.summary.totalPersonas} (${Math.round((comparison.summary.successCount / comparison.summary.totalPersonas) * 100)}%)`);
|
|
2870
|
+
lines.push(` Avg Completion Time: ${(comparison.summary.avgCompletionTime / 1000).toFixed(1)}s`);
|
|
2871
|
+
lines.push(` Fastest: ${comparison.summary.fastestPersona}`);
|
|
2872
|
+
lines.push(` Slowest: ${comparison.summary.slowestPersona}`);
|
|
2873
|
+
lines.push(` Most Friction: ${comparison.summary.mostFriction}`);
|
|
2874
|
+
lines.push(` Least Friction: ${comparison.summary.leastFriction}`);
|
|
2875
|
+
lines.push("");
|
|
2876
|
+
// Recommendations
|
|
2877
|
+
lines.push("š” RECOMMENDATIONS");
|
|
2878
|
+
lines.push("ā".repeat(60));
|
|
2879
|
+
for (const rec of comparison.recommendations) {
|
|
2880
|
+
lines.push(` ${rec}`);
|
|
2881
|
+
}
|
|
2882
|
+
lines.push("");
|
|
2883
|
+
return lines.join("\n");
|
|
2884
|
+
}
|
|
2885
|
+
// =========================================================================
|
|
2886
|
+
// Natural Language Test Suites
|
|
2887
|
+
// =========================================================================
|
|
2888
|
+
/**
|
|
2889
|
+
* Parse a single natural language instruction into an NLTestStep.
|
|
2890
|
+
*
|
|
2891
|
+
* Supported patterns:
|
|
2892
|
+
* - "go to https://..." / "navigate to https://..." / "open https://..."
|
|
2893
|
+
* - "click [the] <target>" / "press <target>"
|
|
2894
|
+
* - "type '<value>' in[to] <target>" / "fill <target> with '<value>'"
|
|
2895
|
+
* - "select '<option>' from <dropdown>"
|
|
2896
|
+
* - "scroll down/up"
|
|
2897
|
+
* - "wait [for] <seconds> seconds"
|
|
2898
|
+
* - "verify <assertion>" / "assert <assertion>" / "check <assertion>"
|
|
2899
|
+
* - "take screenshot"
|
|
2900
|
+
*/
|
|
2901
|
+
function parseNLInstruction(instruction) {
|
|
2902
|
+
const lower = instruction.toLowerCase().trim();
|
|
2903
|
+
// Navigate patterns
|
|
2904
|
+
const navigateMatch = lower.match(/^(?:go to|navigate to|open|visit)\s+(.+)$/i);
|
|
2905
|
+
if (navigateMatch) {
|
|
2906
|
+
return {
|
|
2907
|
+
instruction,
|
|
2908
|
+
action: "navigate",
|
|
2909
|
+
target: navigateMatch[1].trim(),
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
// Click patterns
|
|
2913
|
+
const clickMatch = lower.match(/^(?:click|tap|press)\s+(?:on\s+)?(?:the\s+)?(.+)$/i);
|
|
2914
|
+
if (clickMatch) {
|
|
2915
|
+
return {
|
|
2916
|
+
instruction,
|
|
2917
|
+
action: "click",
|
|
2918
|
+
target: clickMatch[1].trim(),
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
// Fill patterns: "type 'value' in target" or "fill target with 'value'"
|
|
2922
|
+
const typeMatch = lower.match(/^(?:type|enter)\s+['"](.+?)['"]\s+(?:in|into)\s+(?:the\s+)?(.+)$/i);
|
|
2923
|
+
if (typeMatch) {
|
|
2924
|
+
return {
|
|
2925
|
+
instruction,
|
|
2926
|
+
action: "fill",
|
|
2927
|
+
value: typeMatch[1],
|
|
2928
|
+
target: typeMatch[2].trim(),
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
const fillMatch = lower.match(/^fill\s+(?:the\s+)?(.+?)\s+with\s+['"](.+?)['"]$/i);
|
|
2932
|
+
if (fillMatch) {
|
|
2933
|
+
return {
|
|
2934
|
+
instruction,
|
|
2935
|
+
action: "fill",
|
|
2936
|
+
target: fillMatch[1].trim(),
|
|
2937
|
+
value: fillMatch[2],
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
// Select patterns
|
|
2941
|
+
const selectMatch = lower.match(/^select\s+['"](.+?)['"]\s+(?:from|in)\s+(?:the\s+)?(.+)$/i);
|
|
2942
|
+
if (selectMatch) {
|
|
2943
|
+
return {
|
|
2944
|
+
instruction,
|
|
2945
|
+
action: "select",
|
|
2946
|
+
value: selectMatch[1],
|
|
2947
|
+
target: selectMatch[2].trim(),
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
// Scroll patterns
|
|
2951
|
+
const scrollMatch = lower.match(/^scroll\s+(up|down|left|right)(?:\s+(\d+)\s+(?:times|pixels))?$/i);
|
|
2952
|
+
if (scrollMatch) {
|
|
2953
|
+
return {
|
|
2954
|
+
instruction,
|
|
2955
|
+
action: "scroll",
|
|
2956
|
+
target: scrollMatch[1],
|
|
2957
|
+
value: scrollMatch[2] || "3",
|
|
2958
|
+
};
|
|
2959
|
+
}
|
|
2960
|
+
// Wait patterns
|
|
2961
|
+
const waitMatch = lower.match(/^wait\s+(?:for\s+)?(\d+(?:\.\d+)?)\s*(?:seconds?|s)$/i);
|
|
2962
|
+
if (waitMatch) {
|
|
2963
|
+
return {
|
|
2964
|
+
instruction,
|
|
2965
|
+
action: "wait",
|
|
2966
|
+
value: waitMatch[1],
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
// Wait for text pattern
|
|
2970
|
+
const waitForMatch = lower.match(/^wait\s+(?:for|until)\s+['"](.+?)['"]\s+(?:appears?|is visible|shows?)$/i);
|
|
2971
|
+
if (waitForMatch) {
|
|
2972
|
+
return {
|
|
2973
|
+
instruction,
|
|
2974
|
+
action: "wait",
|
|
2975
|
+
target: waitForMatch[1],
|
|
2976
|
+
};
|
|
2977
|
+
}
|
|
2978
|
+
// Assert/verify patterns
|
|
2979
|
+
const assertPatterns = [
|
|
2980
|
+
// Title assertions
|
|
2981
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:page\s+)?title\s+(?:contains?|has)\s+['"](.+?)['"]$/i, type: "title", assertType: "contains" },
|
|
2982
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:page\s+)?title\s+(?:is|equals?)\s+['"](.+?)['"]$/i, type: "title", assertType: "equals" },
|
|
2983
|
+
// URL assertions
|
|
2984
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?url\s+(?:contains?|has)\s+['"](.+?)['"]$/i, type: "url", assertType: "contains" },
|
|
2985
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?url\s+(?:is|equals?)\s+['"](.+?)['"]$/i, type: "url", assertType: "equals" },
|
|
2986
|
+
// Content assertions
|
|
2987
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:page\s+)?(?:contains?|has|shows?)\s+['"](.+?)['"]$/i, type: "content", assertType: "contains" },
|
|
2988
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?['"](.+?)['"]\s+(?:is\s+)?(?:visible|displayed|shown)$/i, type: "content", assertType: "contains" },
|
|
2989
|
+
// Element exists
|
|
2990
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?['"](.+?)['"]\s+exists?$/i, type: "element", assertType: "exists" },
|
|
2991
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:there\s+is\s+)?(?:a|an)\s+['"](.+?)['"]$/i, type: "element", assertType: "exists" },
|
|
2992
|
+
// Count assertions
|
|
2993
|
+
{ pattern: /^(?:verify|assert|check)\s+(?:that\s+)?(?:there\s+are\s+)?(\d+)\s+(.+?)$/i, type: "count", assertType: "count" },
|
|
2994
|
+
];
|
|
2995
|
+
for (const { pattern, type, assertType } of assertPatterns) {
|
|
2996
|
+
const match = lower.match(pattern);
|
|
2997
|
+
if (match) {
|
|
2998
|
+
return {
|
|
2999
|
+
instruction,
|
|
3000
|
+
action: "assert",
|
|
3001
|
+
target: type === "count" ? match[2] : match[1],
|
|
3002
|
+
value: type === "count" ? match[1] : undefined,
|
|
3003
|
+
assertionType: assertType,
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
// Screenshot pattern
|
|
3008
|
+
if (/^(?:take\s+(?:a\s+)?screenshot|screenshot|capture\s+(?:the\s+)?(?:page|screen))$/i.test(lower)) {
|
|
3009
|
+
return {
|
|
3010
|
+
instruction,
|
|
3011
|
+
action: "screenshot",
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
// Unknown - return as-is for AI-powered interpretation later
|
|
3015
|
+
return {
|
|
3016
|
+
instruction,
|
|
3017
|
+
action: "unknown",
|
|
3018
|
+
target: instruction,
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* Parse a natural language test suite from text.
|
|
3023
|
+
*
|
|
3024
|
+
* Format:
|
|
3025
|
+
* ```
|
|
3026
|
+
* # Test: Login Flow
|
|
3027
|
+
* go to https://example.com
|
|
3028
|
+
* click the login button
|
|
3029
|
+
* type "user@example.com" in email field
|
|
3030
|
+
* type "password123" in password field
|
|
3031
|
+
* click submit
|
|
3032
|
+
* verify url contains "/dashboard"
|
|
3033
|
+
*
|
|
3034
|
+
* # Test: Search Functionality
|
|
3035
|
+
* go to https://example.com
|
|
3036
|
+
* type "test query" in search box
|
|
3037
|
+
* click search button
|
|
3038
|
+
* verify page contains "results"
|
|
3039
|
+
* ```
|
|
3040
|
+
*/
|
|
3041
|
+
function parseNLTestSuite(text, suiteName = "Unnamed Suite") {
|
|
3042
|
+
const lines = text.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("//"));
|
|
3043
|
+
const tests = [];
|
|
3044
|
+
let currentTest = null;
|
|
3045
|
+
for (const line of lines) {
|
|
3046
|
+
// Check for test header: "# Test: Name" or "## Name" or "Test: Name"
|
|
3047
|
+
const testHeaderMatch = line.match(/^(?:#\s*)?(?:test:\s*)?(.+)$/i);
|
|
3048
|
+
if (line.startsWith("#") || line.toLowerCase().startsWith("test:")) {
|
|
3049
|
+
// Save previous test if exists
|
|
3050
|
+
if (currentTest && currentTest.steps.length > 0) {
|
|
3051
|
+
tests.push(currentTest);
|
|
3052
|
+
}
|
|
3053
|
+
const name = testHeaderMatch?.[1]?.replace(/^#+\s*/, "").replace(/^test:\s*/i, "").trim() || "Unnamed Test";
|
|
3054
|
+
currentTest = {
|
|
3055
|
+
name,
|
|
3056
|
+
steps: [],
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
else if (line.length > 0) {
|
|
3060
|
+
// Parse as instruction
|
|
3061
|
+
if (!currentTest) {
|
|
3062
|
+
// Create default test if no header found
|
|
3063
|
+
currentTest = {
|
|
3064
|
+
name: "Default Test",
|
|
3065
|
+
steps: [],
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
const step = parseNLInstruction(line);
|
|
3069
|
+
currentTest.steps.push(step);
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
// Save final test
|
|
3073
|
+
if (currentTest && currentTest.steps.length > 0) {
|
|
3074
|
+
tests.push(currentTest);
|
|
3075
|
+
}
|
|
3076
|
+
return { name: suiteName, tests };
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Run a natural language test suite.
|
|
3080
|
+
*/
|
|
3081
|
+
async function runNLTestSuite(suite, options = {}) {
|
|
3082
|
+
const { stepTimeout = 30000, continueOnFailure = true, screenshotOnFailure = true, headless = true, } = options;
|
|
3083
|
+
const startTime = Date.now();
|
|
3084
|
+
const testResults = [];
|
|
3085
|
+
console.log(`\nš§Ŗ Running Test Suite: ${suite.name}`);
|
|
3086
|
+
console.log(` Tests: ${suite.tests.length}`);
|
|
3087
|
+
console.log(` Continue on failure: ${continueOnFailure}`);
|
|
3088
|
+
console.log("");
|
|
3089
|
+
const browser = new CBrowser({
|
|
3090
|
+
headless,
|
|
3091
|
+
});
|
|
3092
|
+
try {
|
|
3093
|
+
await browser.launch();
|
|
3094
|
+
for (const test of suite.tests) {
|
|
3095
|
+
console.log(`\nš Test: ${test.name}`);
|
|
3096
|
+
const testStartTime = Date.now();
|
|
3097
|
+
const stepResults = [];
|
|
3098
|
+
let testPassed = true;
|
|
3099
|
+
let testError;
|
|
3100
|
+
for (const step of test.steps) {
|
|
3101
|
+
console.log(` ā ${step.instruction}`);
|
|
3102
|
+
const stepStartTime = Date.now();
|
|
3103
|
+
let stepPassed = true;
|
|
3104
|
+
let stepError;
|
|
3105
|
+
let screenshot;
|
|
3106
|
+
let actualValue;
|
|
3107
|
+
try {
|
|
3108
|
+
// Execute the step based on action type
|
|
3109
|
+
switch (step.action) {
|
|
3110
|
+
case "navigate": {
|
|
3111
|
+
await browser.navigate(step.target || "");
|
|
3112
|
+
break;
|
|
3113
|
+
}
|
|
3114
|
+
case "click": {
|
|
3115
|
+
const result = await browser.smartClick(step.target || "");
|
|
3116
|
+
if (!result.success) {
|
|
3117
|
+
throw new Error(`Failed to click: ${step.target}`);
|
|
3118
|
+
}
|
|
3119
|
+
break;
|
|
3120
|
+
}
|
|
3121
|
+
case "fill": {
|
|
3122
|
+
await browser.fill(step.target || "", step.value || "");
|
|
3123
|
+
break;
|
|
3124
|
+
}
|
|
3125
|
+
case "select": {
|
|
3126
|
+
await browser.fill(step.target || "", step.value || "");
|
|
3127
|
+
break;
|
|
3128
|
+
}
|
|
3129
|
+
case "scroll": {
|
|
3130
|
+
const direction = step.target?.toLowerCase() === "up" ? -500 : 500;
|
|
3131
|
+
// Use private page access through cast
|
|
3132
|
+
const page = browser.page;
|
|
3133
|
+
if (page) {
|
|
3134
|
+
await page.evaluate((d) => window.scrollBy(0, d), direction);
|
|
3135
|
+
}
|
|
3136
|
+
break;
|
|
3137
|
+
}
|
|
3138
|
+
case "wait": {
|
|
3139
|
+
if (step.target) {
|
|
3140
|
+
// Wait for text to appear - use private page access
|
|
3141
|
+
const page = browser.page;
|
|
3142
|
+
if (page) {
|
|
3143
|
+
await page.waitForSelector(`text=${step.target}`, { timeout: stepTimeout });
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
else {
|
|
3147
|
+
// Wait for duration
|
|
3148
|
+
const ms = parseFloat(step.value || "1") * 1000;
|
|
3149
|
+
await new Promise(r => setTimeout(r, ms));
|
|
3150
|
+
}
|
|
3151
|
+
break;
|
|
3152
|
+
}
|
|
3153
|
+
case "assert": {
|
|
3154
|
+
const assertResult = await browser.assert(step.instruction);
|
|
3155
|
+
stepPassed = assertResult.passed;
|
|
3156
|
+
actualValue = String(assertResult.actual);
|
|
3157
|
+
if (!assertResult.passed) {
|
|
3158
|
+
stepError = assertResult.message;
|
|
3159
|
+
}
|
|
3160
|
+
break;
|
|
3161
|
+
}
|
|
3162
|
+
case "screenshot": {
|
|
3163
|
+
screenshot = await browser.screenshot();
|
|
3164
|
+
break;
|
|
3165
|
+
}
|
|
3166
|
+
case "unknown": {
|
|
3167
|
+
// Try to interpret as a click or fill
|
|
3168
|
+
console.log(` ā ļø Unknown instruction, attempting smart interpretation...`);
|
|
3169
|
+
const result = await browser.smartClick(step.target || step.instruction);
|
|
3170
|
+
if (!result.success) {
|
|
3171
|
+
throw new Error(`Could not interpret: ${step.instruction}`);
|
|
3172
|
+
}
|
|
3173
|
+
break;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
console.log(` ā Passed (${Date.now() - stepStartTime}ms)`);
|
|
3177
|
+
}
|
|
3178
|
+
catch (e) {
|
|
3179
|
+
stepPassed = false;
|
|
3180
|
+
stepError = e.message;
|
|
3181
|
+
testPassed = false;
|
|
3182
|
+
testError = testError || e.message;
|
|
3183
|
+
console.log(` ā Failed: ${e.message}`);
|
|
3184
|
+
if (screenshotOnFailure) {
|
|
3185
|
+
try {
|
|
3186
|
+
screenshot = await browser.screenshot();
|
|
3187
|
+
}
|
|
3188
|
+
catch { }
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
stepResults.push({
|
|
3192
|
+
instruction: step.instruction,
|
|
3193
|
+
action: step.action,
|
|
3194
|
+
passed: stepPassed,
|
|
3195
|
+
duration: Date.now() - stepStartTime,
|
|
3196
|
+
error: stepError,
|
|
3197
|
+
screenshot,
|
|
3198
|
+
actualValue,
|
|
3199
|
+
});
|
|
3200
|
+
// Stop test if step failed and not continuing on failure
|
|
3201
|
+
if (!stepPassed && !continueOnFailure) {
|
|
3202
|
+
break;
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
testResults.push({
|
|
3206
|
+
name: test.name,
|
|
3207
|
+
passed: testPassed,
|
|
3208
|
+
duration: Date.now() - testStartTime,
|
|
3209
|
+
stepResults,
|
|
3210
|
+
error: testError,
|
|
3211
|
+
});
|
|
3212
|
+
console.log(` ${testPassed ? "ā
" : "ā"} ${test.name}: ${testPassed ? "PASSED" : "FAILED"}`);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
finally {
|
|
3216
|
+
await browser.close();
|
|
3217
|
+
}
|
|
3218
|
+
const passed = testResults.filter(t => t.passed).length;
|
|
3219
|
+
const failed = testResults.filter(t => !t.passed).length;
|
|
3220
|
+
const result = {
|
|
3221
|
+
name: suite.name,
|
|
3222
|
+
timestamp: new Date().toISOString(),
|
|
3223
|
+
duration: Date.now() - startTime,
|
|
3224
|
+
testResults,
|
|
3225
|
+
summary: {
|
|
3226
|
+
total: suite.tests.length,
|
|
3227
|
+
passed,
|
|
3228
|
+
failed,
|
|
3229
|
+
skipped: 0,
|
|
3230
|
+
passRate: suite.tests.length > 0 ? (passed / suite.tests.length) * 100 : 0,
|
|
3231
|
+
},
|
|
3232
|
+
};
|
|
3233
|
+
return result;
|
|
3234
|
+
}
|
|
3235
|
+
/**
|
|
3236
|
+
* Format a test suite result as a report.
|
|
3237
|
+
*/
|
|
3238
|
+
function formatNLTestReport(result) {
|
|
3239
|
+
const lines = [];
|
|
3240
|
+
lines.push("");
|
|
3241
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
|
|
3242
|
+
lines.push("ā NATURAL LANGUAGE TEST REPORT ā");
|
|
3243
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
|
|
3244
|
+
lines.push("");
|
|
3245
|
+
lines.push(`š Suite: ${result.name}`);
|
|
3246
|
+
lines.push(`ā±ļø Duration: ${(result.duration / 1000).toFixed(1)}s`);
|
|
3247
|
+
lines.push(`š
Timestamp: ${result.timestamp}`);
|
|
3248
|
+
lines.push("");
|
|
3249
|
+
// Summary stats
|
|
3250
|
+
const passEmoji = result.summary.passRate === 100 ? "š" : result.summary.passRate >= 80 ? "ā
" : "ā ļø";
|
|
3251
|
+
lines.push(`${passEmoji} Pass Rate: ${result.summary.passed}/${result.summary.total} (${result.summary.passRate.toFixed(0)}%)`);
|
|
3252
|
+
lines.push("");
|
|
3253
|
+
// Results table
|
|
3254
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāā");
|
|
3255
|
+
lines.push("ā Test ā Status ā Duration ā Error ā");
|
|
3256
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāā¤");
|
|
3257
|
+
for (const test of result.testResults) {
|
|
3258
|
+
const name = test.name.padEnd(37).slice(0, 37);
|
|
3259
|
+
const status = test.passed ? "ā PASS".padEnd(8) : "ā FAIL".padEnd(8);
|
|
3260
|
+
const duration = `${(test.duration / 1000).toFixed(1)}s`.padEnd(8);
|
|
3261
|
+
const error = (test.error || "-").slice(0, 18).padEnd(18);
|
|
3262
|
+
lines.push(`ā ${name} ā ${status} ā ${duration} ā ${error} ā`);
|
|
3263
|
+
}
|
|
3264
|
+
lines.push("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāā");
|
|
3265
|
+
lines.push("");
|
|
3266
|
+
// Failed test details
|
|
3267
|
+
const failedTests = result.testResults.filter(t => !t.passed);
|
|
3268
|
+
if (failedTests.length > 0) {
|
|
3269
|
+
lines.push("ā FAILED TESTS");
|
|
3270
|
+
lines.push("ā".repeat(60));
|
|
3271
|
+
for (const test of failedTests) {
|
|
3272
|
+
lines.push(`\n š ${test.name}`);
|
|
3273
|
+
const failedSteps = test.stepResults.filter(s => !s.passed);
|
|
3274
|
+
for (const step of failedSteps) {
|
|
3275
|
+
lines.push(` ā ${step.instruction}`);
|
|
3276
|
+
if (step.error) {
|
|
3277
|
+
lines.push(` Error: ${step.error}`);
|
|
3278
|
+
}
|
|
3279
|
+
if (step.screenshot) {
|
|
3280
|
+
lines.push(` Screenshot: ${step.screenshot}`);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
lines.push("");
|
|
3285
|
+
}
|
|
3286
|
+
return lines.join("\n");
|
|
3287
|
+
}
|
|
3288
|
+
/**
|
|
3289
|
+
* Run a natural language test suite from a file.
|
|
3290
|
+
*/
|
|
3291
|
+
async function runNLTestFile(filepath, options = {}) {
|
|
3292
|
+
if (!(0, fs_1.existsSync)(filepath)) {
|
|
3293
|
+
throw new Error(`Test file not found: ${filepath}`);
|
|
3294
|
+
}
|
|
3295
|
+
const content = (0, fs_1.readFileSync)(filepath, "utf-8");
|
|
3296
|
+
const suiteName = filepath.split("/").pop()?.replace(/\.[^.]+$/, "") || "Test Suite";
|
|
3297
|
+
const suite = parseNLTestSuite(content, suiteName);
|
|
3298
|
+
return runNLTestSuite(suite, options);
|
|
3299
|
+
}
|
|
2674
3300
|
//# sourceMappingURL=browser.js.map
|