@matware/e2e-runner 1.3.0 β 1.3.1
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/.claude-plugin/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +61 -526
- package/bin/cli.js +5 -4
- package/commands/capture.md +45 -0
- package/package.json +1 -1
- package/src/actions.js +151 -0
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +125 -7
- package/src/dashboard.js +75 -8
- package/src/db.js +63 -7
- package/src/index.js +6 -4
- package/src/learner-sqlite.js +154 -0
- package/src/learner.js +70 -3
- package/src/mcp-tools.js +251 -32
- package/src/narrate.js +28 -0
- package/src/pool-manager.js +22 -16
- package/src/pool.js +301 -31
- package/src/reporter.js +4 -1
- package/src/runner.js +335 -55
- package/src/visual-diff.js +446 -0
- package/templates/dashboard/js/api.js +2 -0
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +40 -2
- package/templates/dashboard/js/view-runs.js +161 -57
- package/templates/dashboard/js/websocket.js +6 -0
- package/templates/dashboard/styles/components.css +7 -0
- package/templates/dashboard/styles/view-live.css +24 -1
- package/templates/dashboard/styles/view-runs.css +36 -0
- package/templates/dashboard/template.html +24 -9
- package/templates/dashboard.html +322 -310
package/bin/cli.js
CHANGED
|
@@ -264,7 +264,7 @@ async function cmdRun() {
|
|
|
264
264
|
|
|
265
265
|
// Verify pool connectivity
|
|
266
266
|
log('π', `Checking Chrome Pool${poolUrls.length > 1 ? 's' : ''}...`);
|
|
267
|
-
const pressure = await waitForAnyPool(poolUrls);
|
|
267
|
+
const pressure = await waitForAnyPool(poolUrls, 30000, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
|
|
268
268
|
log('β
', `Pool ready (${pressure.running}/${pressure.maxConcurrent} sessions, queued: ${pressure.queued})`);
|
|
269
269
|
|
|
270
270
|
// Wire up live progress to dashboard if running
|
|
@@ -351,7 +351,7 @@ async function cmdPool() {
|
|
|
351
351
|
|
|
352
352
|
case 'status': {
|
|
353
353
|
const statusPoolUrls = getPoolUrls(config);
|
|
354
|
-
const aggregated = await getAggregatedPoolStatus(statusPoolUrls);
|
|
354
|
+
const aggregated = await getAggregatedPoolStatus(statusPoolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
|
|
355
355
|
console.log(`\n${C.bold}Chrome Pool Status:${C.reset}\n`);
|
|
356
356
|
|
|
357
357
|
if (statusPoolUrls.length > 1) {
|
|
@@ -494,11 +494,12 @@ async function cmdCapture() {
|
|
|
494
494
|
|
|
495
495
|
const capturePoolUrls = getPoolUrls(config);
|
|
496
496
|
log('π', 'Checking Chrome Pool...');
|
|
497
|
-
|
|
497
|
+
const captureDriverOpts = { poolDriver: config.poolDriver, maxSessions: config.maxSessions };
|
|
498
|
+
await waitForAnyPool(capturePoolUrls, 30000, captureDriverOpts);
|
|
498
499
|
|
|
499
500
|
let browser;
|
|
500
501
|
try {
|
|
501
|
-
const capturePool = await selectPool(capturePoolUrls);
|
|
502
|
+
const capturePool = await selectPool(capturePoolUrls, 2000, 60000, captureDriverOpts);
|
|
502
503
|
browser = await connectToPool(capturePool);
|
|
503
504
|
const page = await browser.newPage();
|
|
504
505
|
await page.setViewport(config.viewport);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Capture a screenshot of any URL with automatic authentication
|
|
3
|
+
user_invocable: true
|
|
4
|
+
allowed_tools:
|
|
5
|
+
- mcp__e2e-runner__e2e_pool_status
|
|
6
|
+
- mcp__e2e-runner__e2e_capture
|
|
7
|
+
- mcp__e2e-runner__e2e_analyze
|
|
8
|
+
- mcp__e2e-runner__e2e_screenshot
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Quick Capture
|
|
12
|
+
|
|
13
|
+
Take a screenshot of any URL in one step. Handles pool checks and authentication automatically.
|
|
14
|
+
|
|
15
|
+
## Workflow
|
|
16
|
+
|
|
17
|
+
1. **Check pool** β Call `e2e_pool_status` to confirm the Chrome pool is running. If not available, tell the user to run `npx e2e-runner pool start` via CLI and stop.
|
|
18
|
+
|
|
19
|
+
2. **Capture** β Call `e2e_capture` with:
|
|
20
|
+
- `url`: The URL from the user's request (REQUIRED)
|
|
21
|
+
- `cwd`: The current working directory (REQUIRED β always pass this)
|
|
22
|
+
- `fullPage`: true if user says "full page", "full", "complete", or "toda la pΓ‘gina"
|
|
23
|
+
- `selector`: CSS selector if user wants to wait for a specific element
|
|
24
|
+
- `delay`: milliseconds if user says "wait", "delay", or "espera"
|
|
25
|
+
- `waitUntil`: "domcontentloaded" if user mentions WebSocket, SSE, or real-time apps
|
|
26
|
+
- `filename`: if user specifies a name
|
|
27
|
+
|
|
28
|
+
**Authentication is automatic**: the tool reads `authToken`, `authLoginEndpoint`, and `authCredentials` from the project's `e2e.config.js`. You do NOT need to pass `authToken` unless the user explicitly provides one.
|
|
29
|
+
|
|
30
|
+
3. **Show result** β The tool returns the screenshot as an inline image. Show it to the user with the file path.
|
|
31
|
+
|
|
32
|
+
## Arguments
|
|
33
|
+
|
|
34
|
+
The user passes the URL after the command:
|
|
35
|
+
- `/e2e-runner:capture http://localhost:3000/dashboard` β capture that URL
|
|
36
|
+
- `/e2e-runner:capture http://localhost/concept-maps --full-page` β full page capture
|
|
37
|
+
- `/e2e-runner:capture http://localhost/admin --delay 3000` β wait 3s before capture
|
|
38
|
+
|
|
39
|
+
If no URL is provided, ask the user for one.
|
|
40
|
+
|
|
41
|
+
## Important
|
|
42
|
+
|
|
43
|
+
- Do NOT try to manually authenticate, fetch tokens, write test files, or use curl. The tool handles auth automatically from project config.
|
|
44
|
+
- Do NOT use the `e2e_run` tool β this is a screenshot capture, not a test run.
|
|
45
|
+
- Keep it simple: one `e2e_pool_status` call + one `e2e_capture` call. That's it.
|
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import { assertVisualMatch } from './visual-diff.js';
|
|
11
13
|
|
|
12
14
|
/** All recognized action types β single source of truth for validation. */
|
|
13
15
|
export const KNOWN_ACTION_TYPES = new Set([
|
|
@@ -22,6 +24,8 @@ export const KNOWN_ACTION_TYPES = new Set([
|
|
|
22
24
|
'set_storage', 'click_icon', 'click_menu_item', 'click_in_context',
|
|
23
25
|
'assert_text_in', 'assert_no_text',
|
|
24
26
|
'gql', 'wait_network_idle',
|
|
27
|
+
'open_tab', 'switch_tab', 'close_tab', 'assert_tab_count', 'wait_for_tab',
|
|
28
|
+
'assert_visual',
|
|
25
29
|
]);
|
|
26
30
|
|
|
27
31
|
function sleep(ms) {
|
|
@@ -752,6 +756,153 @@ export async function executeAction(page, action, config) {
|
|
|
752
756
|
break;
|
|
753
757
|
}
|
|
754
758
|
|
|
759
|
+
// ββ Visual regression βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
760
|
+
|
|
761
|
+
case 'assert_visual': {
|
|
762
|
+
// Compares a live screenshot against a golden reference image.
|
|
763
|
+
//
|
|
764
|
+
// value: golden image filename (relative to screenshotsDir or goldenDir) β required
|
|
765
|
+
// selector: optional CSS selector β screenshot only that element instead of full page
|
|
766
|
+
// text: optional max diff percentage as string, e.g. "0.02" (default: config.verificationThreshold or 0.02)
|
|
767
|
+
//
|
|
768
|
+
// Additional fields via action object:
|
|
769
|
+
// fullPage: boolean (default: true)
|
|
770
|
+
// maskRegions: [{ x, y, width, height }] β regions to ignore (timestamps, avatars, etc.)
|
|
771
|
+
// threshold: number β pixel color sensitivity 0-1 (default: 0.1)
|
|
772
|
+
//
|
|
773
|
+
// Returns: { diffPercentage, differentPixels, totalPixels, diffImagePath, baselinePath, currentPath }
|
|
774
|
+
|
|
775
|
+
if (!value) throw new Error('assert_visual requires "value" (golden image filename)');
|
|
776
|
+
|
|
777
|
+
// Resolve golden image path
|
|
778
|
+
const goldenDir = config.goldenDir || path.join(config.screenshotsDir, 'golden');
|
|
779
|
+
const goldenPath = path.isAbsolute(value) ? value : path.join(goldenDir, value);
|
|
780
|
+
|
|
781
|
+
if (!fs.existsSync(goldenPath)) {
|
|
782
|
+
// First run: save current screenshot as the golden reference
|
|
783
|
+
if (!fs.existsSync(goldenDir)) fs.mkdirSync(goldenDir, { recursive: true });
|
|
784
|
+
const screenshotOpts = { path: goldenPath, fullPage: action.fullPage !== false };
|
|
785
|
+
if (selector) {
|
|
786
|
+
const el = await page.$(selector);
|
|
787
|
+
if (!el) throw new Error(`assert_visual: selector "${selector}" not found`);
|
|
788
|
+
await el.screenshot(screenshotOpts);
|
|
789
|
+
} else {
|
|
790
|
+
await page.screenshot(screenshotOpts);
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
goldenCreated: true,
|
|
794
|
+
goldenPath,
|
|
795
|
+
message: `Golden image saved: ${path.basename(goldenPath)}. Re-run to compare.`,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Capture current screenshot
|
|
800
|
+
const safeName = path.basename(value, path.extname(value));
|
|
801
|
+
const currentPath = path.join(screenshotsDir, `current-${safeName}-${Date.now()}.png`);
|
|
802
|
+
const screenshotOpts = { path: currentPath, fullPage: action.fullPage !== false };
|
|
803
|
+
if (selector) {
|
|
804
|
+
const el = await page.$(selector);
|
|
805
|
+
if (!el) throw new Error(`assert_visual: selector "${selector}" not found`);
|
|
806
|
+
await el.screenshot(screenshotOpts);
|
|
807
|
+
} else {
|
|
808
|
+
await page.screenshot(screenshotOpts);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Compare
|
|
812
|
+
const maxDiff = text ? parseFloat(text) : (config.verificationThreshold || 0.02);
|
|
813
|
+
const diffPath = path.join(screenshotsDir, `diff-${safeName}-${Date.now()}.png`);
|
|
814
|
+
const compareResult = assertVisualMatch(goldenPath, currentPath, maxDiff, {
|
|
815
|
+
threshold: action.threshold || 0.1,
|
|
816
|
+
maskRegions: action.maskRegions || [],
|
|
817
|
+
diffOutputPath: diffPath,
|
|
818
|
+
includeAntiAlias: action.includeAntiAlias || false,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
if (!compareResult.passed) {
|
|
822
|
+
const pct = (compareResult.diffPercentage * 100).toFixed(2);
|
|
823
|
+
const maxPct = (maxDiff * 100).toFixed(2);
|
|
824
|
+
throw new Error(
|
|
825
|
+
`assert_visual failed: ${pct}% pixels differ (threshold: ${maxPct}%). ` +
|
|
826
|
+
`${compareResult.differentPixels}/${compareResult.totalPixels} pixels changed. ` +
|
|
827
|
+
`Diff: ${path.basename(diffPath)}`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
diffPercentage: compareResult.diffPercentage,
|
|
833
|
+
differentPixels: compareResult.differentPixels,
|
|
834
|
+
totalPixels: compareResult.totalPixels,
|
|
835
|
+
diffImagePath: compareResult.diffImagePath,
|
|
836
|
+
baselinePath: goldenPath,
|
|
837
|
+
currentPath,
|
|
838
|
+
screenshot: diffPath,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ββ Multi-tab actions βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
843
|
+
// These actions are intercepted by the runner (runTest) which manages the
|
|
844
|
+
// tab registry and swaps the active page. The actual tab lifecycle happens
|
|
845
|
+
// in runner.js β these cases handle the in-page parts only.
|
|
846
|
+
|
|
847
|
+
case 'open_tab': {
|
|
848
|
+
// Opens a new tab and navigates to the given URL.
|
|
849
|
+
// value: URL (absolute or relative to baseUrl) β required
|
|
850
|
+
// text: optional label for the tab (used by switch_tab)
|
|
851
|
+
// The runner intercepts this to create the page and register it.
|
|
852
|
+
// If we reach here, it means the runner already created the page and
|
|
853
|
+
// we just need to navigate.
|
|
854
|
+
const tabUrl = value.startsWith('http') ? value : `${baseUrl}${value}`;
|
|
855
|
+
await page.goto(tabUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
case 'switch_tab': {
|
|
860
|
+
// Switches to another open tab. The runner handles the actual page swap.
|
|
861
|
+
// This case is a no-op β the runner already switched the page reference.
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
case 'close_tab': {
|
|
866
|
+
// Closes the current tab. The runner handles page cleanup and switching.
|
|
867
|
+
// This case is a no-op β the runner closes the page and swaps back.
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
case 'assert_tab_count': {
|
|
872
|
+
// Asserts the number of open tabs.
|
|
873
|
+
// value: expected count (number or operator expression like ">=2")
|
|
874
|
+
// The runner injects __tabCount into the action result before we get here.
|
|
875
|
+
// If we reach here directly, we use browser context pages.
|
|
876
|
+
const tabCount = action.__tabCount;
|
|
877
|
+
if (tabCount === undefined) {
|
|
878
|
+
throw new Error('assert_tab_count: tab count not available (action must be run via runner)');
|
|
879
|
+
}
|
|
880
|
+
const opMatch = value.match(/^(>=|<=|>|<)\s*(\d+)$/);
|
|
881
|
+
if (opMatch) {
|
|
882
|
+
const [, op, numStr] = opMatch;
|
|
883
|
+
const expected = parseInt(numStr);
|
|
884
|
+
const passed = op === '>' ? tabCount > expected
|
|
885
|
+
: op === '>=' ? tabCount >= expected
|
|
886
|
+
: op === '<' ? tabCount < expected
|
|
887
|
+
: tabCount <= expected;
|
|
888
|
+
if (!passed) {
|
|
889
|
+
throw new Error(`assert_tab_count failed: ${tabCount} tabs open, expected ${op}${expected}`);
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
const expected = parseInt(value);
|
|
893
|
+
if (tabCount !== expected) {
|
|
894
|
+
throw new Error(`assert_tab_count failed: ${tabCount} tabs open, expected ${expected}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
case 'wait_for_tab': {
|
|
901
|
+
// Waits for a new tab/popup to appear. The runner handles this.
|
|
902
|
+
// This case is a no-op β the runner already waited and registered the new tab.
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
|
|
755
906
|
default:
|
|
756
907
|
throw new Error(`Unknown action type: "${type}"`);
|
|
757
908
|
}
|
package/src/ai-generate.js
CHANGED
|
@@ -87,6 +87,16 @@ Smart interaction actions:
|
|
|
87
87
|
- click_menu_item: click a menu item by text. Searches [role="menuitem"], .dropdown-item, .menu-item, [class*="MenuItem"]. Optional "selector" scopes the search
|
|
88
88
|
- click_in_context: click a child element within a container identified by text. "text" finds the container, "selector" is the child to click. Picks the smallest matching container
|
|
89
89
|
|
|
90
|
+
Visual regression:
|
|
91
|
+
- assert_visual: compare current page against a golden reference screenshot. "value" is the golden filename (e.g. "login-page.png"). First run auto-saves the golden. "text" is optional max diff percentage (default "0.02" = 2%). "selector" captures only that element. "maskRegions" ignores dynamic areas: [{ "x": 10, "y": 5, "width": 200, "height": 30 }]. Example: { "type": "assert_visual", "value": "dashboard.png", "text": "0.05" }
|
|
92
|
+
|
|
93
|
+
Multi-tab actions (for OAuth, popups, admin+user flows):
|
|
94
|
+
- open_tab: open a new tab with URL in "value". Optional "text" assigns a label for switch_tab. Example: { "type": "open_tab", "value": "/admin", "text": "admin" }
|
|
95
|
+
- switch_tab: switch to a tab by label, title regex, URL substring, or index. Example: { "type": "switch_tab", "value": "admin" }
|
|
96
|
+
- close_tab: close current tab or a named tab ("value" = label). Automatically switches to previous tab. Example: { "type": "close_tab", "value": "admin" }
|
|
97
|
+
- wait_for_tab: wait for a popup/new tab opened by the page (window.open, target=_blank). Optional "text" labels it. Example: { "type": "wait_for_tab", "text": "oauth" }
|
|
98
|
+
- assert_tab_count: verify number of open tabs. "value" is count or operator. Example: { "type": "assert_tab_count", "value": "2" }
|
|
99
|
+
|
|
90
100
|
Assertion action reference:
|
|
91
101
|
- assert_text: checks if text appears anywhere in the page body
|
|
92
102
|
- assert_element_text: checks textContent of a specific element (use "value": "exact" for strict match)
|
|
@@ -239,6 +249,77 @@ Existing suites: ${existingSuites.join(', ') || 'none'}`;
|
|
|
239
249
|
};
|
|
240
250
|
}
|
|
241
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Generates a hindsight hint for a failed test result.
|
|
254
|
+
* Sends the error + action context to Claude API and returns a concrete fix suggestion.
|
|
255
|
+
* Returns null if API key is unavailable or on any error.
|
|
256
|
+
*/
|
|
257
|
+
export async function generateHindsightHint(failedResult, config = {}) {
|
|
258
|
+
const apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
|
|
259
|
+
if (!apiKey) return null;
|
|
260
|
+
|
|
261
|
+
const model = config.hintsModel || config.anthropicModel || 'claude-sonnet-4-5-20250929';
|
|
262
|
+
const lastActions = (failedResult.actions || []).slice(-8);
|
|
263
|
+
const failedAction = lastActions.find(a => a.success === false);
|
|
264
|
+
|
|
265
|
+
const consoleErrors = (failedResult.consoleLogs || [])
|
|
266
|
+
.filter(l => l.type === 'error')
|
|
267
|
+
.slice(-5)
|
|
268
|
+
.map(l => l.text);
|
|
269
|
+
|
|
270
|
+
const networkErrors = (failedResult.networkErrors || [])
|
|
271
|
+
.slice(-5)
|
|
272
|
+
.map(e => `${e.url} (${e.error})`);
|
|
273
|
+
|
|
274
|
+
const prompt = `Analyze this failed E2E test and suggest a concrete fix.
|
|
275
|
+
|
|
276
|
+
TEST: "${failedResult.name}"
|
|
277
|
+
ERROR: ${failedResult.error}
|
|
278
|
+
|
|
279
|
+
LAST ACTIONS:
|
|
280
|
+
${lastActions.map((a, i) => ` ${i + 1}. ${a.type}${a.selector ? ' selector=' + a.selector : ''}${a.text ? ' text=' + a.text : ''}${a.value ? ' value=' + (a.value.length > 80 ? a.value.slice(0, 80) + '...' : a.value) : ''} β ${a.success === false ? 'FAILED: ' + a.error : 'OK'} (${a.duration}ms)`).join('\n')}
|
|
281
|
+
|
|
282
|
+
${failedAction ? `FAILED ACTION: ${JSON.stringify({ type: failedAction.type, selector: failedAction.selector, text: failedAction.text, value: failedAction.value?.slice?.(0, 200) })}` : ''}
|
|
283
|
+
${consoleErrors.length ? `CONSOLE ERRORS:\n${consoleErrors.join('\n')}` : ''}
|
|
284
|
+
${networkErrors.length ? `NETWORK ERRORS:\n${networkErrors.join('\n')}` : ''}
|
|
285
|
+
|
|
286
|
+
Respond with ONLY a JSON object: { "suggestion": "concrete fix description", "confidence": "high"|"medium"|"low", "fixType": "selector"|"wait"|"timeout"|"logic"|"infra"|"data" }`;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
291
|
+
|
|
292
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: {
|
|
295
|
+
'Content-Type': 'application/json',
|
|
296
|
+
'x-api-key': apiKey,
|
|
297
|
+
'anthropic-version': '2023-06-01',
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
model,
|
|
301
|
+
max_tokens: 1024,
|
|
302
|
+
system: 'You are an E2E test debugging expert. Given a failed test, suggest the most likely fix. Be specific: name exact selectors, wait times, or code changes. Keep suggestions under 100 words.',
|
|
303
|
+
messages: [{ role: 'user', content: prompt }],
|
|
304
|
+
}),
|
|
305
|
+
signal: controller.signal,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
clearTimeout(timeout);
|
|
309
|
+
|
|
310
|
+
if (!response.ok) return null;
|
|
311
|
+
const result = await response.json();
|
|
312
|
+
const text = result.content?.[0]?.text;
|
|
313
|
+
if (!text) return null;
|
|
314
|
+
|
|
315
|
+
const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
316
|
+
const hint = JSON.parse(cleaned);
|
|
317
|
+
return { test: failedResult.name, ...hint };
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
242
323
|
/**
|
|
243
324
|
* Checks if the Anthropic API key is available.
|
|
244
325
|
* @returns {boolean}
|