qiksy-mcp 1.2.0 → 1.2.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/AGENT-GUIDE.md +12 -4
- package/package.json +7 -3
- package/server.mjs +250 -24
package/AGENT-GUIDE.md
CHANGED
|
@@ -82,10 +82,18 @@ sentence rather than working around it.
|
|
|
82
82
|
|
|
83
83
|
## What it cannot do — say so, don't substitute
|
|
84
84
|
|
|
85
|
-
- **No screenshots.** `qa_snapshot` is a tree, not pixels.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
- **No screenshots.** `qa_snapshot` is a tree, not pixels. A judgement that genuinely needs
|
|
86
|
+
to SEE the rendering — is this the right shade, does that shadow look right — cannot be
|
|
87
|
+
made from here.
|
|
88
|
+
- **No arbitrary JavaScript.** No `evaluate`, by design (a Web Store requirement). Every verb
|
|
89
|
+
is a closed vocabulary: you name an element, never a program.
|
|
90
|
+
- **Styles, geometry and web storage ARE available** — `qa_styles` (computed styles, the box,
|
|
91
|
+
and the CSS custom properties in scope), `qa_snapshot({geometry:true})` (every node's box in
|
|
92
|
+
CSS pixels), `qa_storage` (localStorage / sessionStorage, read free, write behind Agent
|
|
93
|
+
control). This guide used to list all three as impossible. That was a wrong inference from
|
|
94
|
+
the no-eval rule, and it cost real capability: an agent told a thing cannot be done does not
|
|
95
|
+
try it. So: no pixels, but plenty of numbers — "this button renders #3B82F6 at 15px with
|
|
96
|
+
22px padding, and its box overlaps the one next to it" needs no screenshot at all.
|
|
89
97
|
- **No `<iframe>` contents.** Payment forms (Stripe, 3-D Secure) live in frames the
|
|
90
98
|
content script does not enter. Fill up to them, not inside them.
|
|
91
99
|
- **No captcha.** Stop and hand it back.
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy-mcp",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "MCP bridge for the Qiksy browser extension
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "MCP bridge for the Qiksy browser extension \u2014 expose live QA findings, forms, network and session to any MCP-capable coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"qiksy-mcp": "server.mjs"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
9
|
+
"files": [
|
|
10
|
+
"server.mjs",
|
|
11
|
+
"README.md",
|
|
12
|
+
"AGENT-GUIDE.md"
|
|
13
|
+
],
|
|
10
14
|
"engines": {
|
|
11
15
|
"node": ">=18"
|
|
12
16
|
},
|
package/server.mjs
CHANGED
|
@@ -26,7 +26,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
26
26
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
27
27
|
import { z } from 'zod';
|
|
28
28
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
29
|
-
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
30
|
+
import { basename, extname, dirname, isAbsolute, resolve } from 'node:path';
|
|
30
31
|
import { fileURLToPath } from 'node:url';
|
|
31
32
|
|
|
32
33
|
const log = (...a) => console.error('[qiksy-mcp]', ...a);
|
|
@@ -494,20 +495,58 @@ window or tab focus, and never steals the pointer. That is the point of the prod
|
|
|
494
495
|
happens inside the existing (even backgrounded) tab, so prefer it over anything that would pop
|
|
495
496
|
a new browser up in the user's face.
|
|
496
497
|
|
|
497
|
-
|
|
498
|
-
- qa_snapshot
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
498
|
+
WHAT YOU CAN SEE (all free, none of it needs Agent control):
|
|
499
|
+
- qa_snapshot — the whole page as an accessibility tree with stable refs. Pass geometry:true
|
|
500
|
+
and every node also carries its box in CSS pixels, which is how you answer "this is cut off
|
|
501
|
+
/ these overlap / that tap target is 28px" without a screenshot.
|
|
502
|
+
- qa_read — the RENDERED text of the page or one element. Reach for it whenever the answer is
|
|
503
|
+
longer than a label: qa_snapshot caps names at 80 characters, so a one-time code in a mail
|
|
504
|
+
tab, a full error paragraph or a generated invoice number is invisible to it.
|
|
505
|
+
- qa_styles — computed styles, the element's box, and the CSS custom properties in scope.
|
|
506
|
+
"color: rgb(37,99,235)" says what is drawn; "--accent: #2563EB" says which token it came
|
|
507
|
+
from, and only the second one is a bug report. This is how you check a build against a design.
|
|
508
|
+
- qa_storage — read the app's localStorage / sessionStorage. Feature flags, cached state, and
|
|
509
|
+
usually the session token live there.
|
|
510
|
+
- qa_findings / qa_export / qa_status — what Qiksy itself observed: console and JS errors,
|
|
511
|
+
failed requests WITH their server error bodies, a11y and markup defects, and a "Speed &
|
|
512
|
+
stability" group fed by the browser's own measurements (layout shift with the element that
|
|
513
|
+
moved, slow interactions split into input delay / handler / paint, main-thread blocks with
|
|
514
|
+
the script responsible).
|
|
515
|
+
|
|
516
|
+
WHAT YOU CAN DO (Qiksy Pro + the one-time "Agent control" consent in the popup; a refusal
|
|
517
|
+
there is the user's to clear — report it in one sentence instead of working around it):
|
|
518
|
+
- qa_click / qa_press — and they ANSWER with what changed. After the page settles you get the
|
|
519
|
+
diff: what appeared, disappeared, updated. Do not follow an action with a qa_snapshot just
|
|
520
|
+
to find out whether it worked; \`changed: null\` means the page did not move, which is itself
|
|
521
|
+
the answer.
|
|
522
|
+
- qa_type — one field. qa_type_many — a whole form in ONE call. Prefer refs over selectors in
|
|
523
|
+
the batch: a form built from a repeated component gives several inputs the identical
|
|
524
|
+
cssPath, and selectors would then pour every value into the first match.
|
|
525
|
+
- qa_upload — attach a file. \`path\` for a file on the machine (THIS process reads it, so the
|
|
526
|
+
bytes never enter your context), \`fixture\` for a hostile one built in the page at no cost
|
|
527
|
+
over the wire: empty, oversize 12MB, wrong-type, corrupt, long-name, unicode-name,
|
|
528
|
+
svg-script, double-ext. Each returns the question it asks, as \`asks\`.
|
|
529
|
+
- qa_storage with op set/remove/clear, qa_navigate, qa_open_isolated, qa_close_tab.
|
|
530
|
+
- qa_report — the session report. ALWAYS pass \`path\`: it is a self-contained HTML document
|
|
531
|
+
with embedded screenshots, routinely hundreds of kilobytes, and you get back a receipt
|
|
532
|
+
instead of the bytes. Pass \`spec\` with the ticket's acceptance criteria and your verdict on
|
|
533
|
+
each, and it becomes a deliverable rather than a log.
|
|
534
|
+
|
|
535
|
+
WHAT IS GENUINELY NOT HERE: no screenshots over this bridge, and no arbitrary JavaScript
|
|
536
|
+
execution — the second is a Web Store requirement, not an oversight, and it is why every verb
|
|
537
|
+
above is a closed vocabulary: you name an element, never a program. When a task truly needs
|
|
538
|
+
pixels or eval, say it is outside the bridge; never silently substitute another browser tool.
|
|
539
|
+
(Styles, geometry and web storage USED to be listed here as impossible. They were not; that
|
|
540
|
+
was a wrong inference from the no-eval rule, and they have their own tools now.)
|
|
541
|
+
|
|
542
|
+
TABS: the agent session is a SET the human opts into — the "Agent drives this tab" switch in
|
|
543
|
+
the popup, or right-click → "Qiksy — test this tab". Attached tabs stay in the session while
|
|
544
|
+
the user looks at something else, so work continues in a background tab, and a tab opened BY
|
|
545
|
+
an attached tab joins it automatically (that is how an identity-provider sign-in stays in the
|
|
546
|
+
flow). qa_tabs lists the session; pass a tabId to target one. Tabs from qa_open_isolated are
|
|
547
|
+
separate logins on the same site — that is how you drive several accounts at once. If the set
|
|
548
|
+
is empty it falls back to the active tab; if a tool answers out-of-scope, ask the human to
|
|
549
|
+
attach the tab rather than reaching around it.
|
|
511
550
|
|
|
512
551
|
Several agent windows, one browser: each MCP client starts its own copy of this server, but
|
|
513
552
|
only one can own the loopback port. The first to bind it holds the extension socket; the others
|
|
@@ -642,6 +681,158 @@ server.registerTool(
|
|
|
642
681
|
},
|
|
643
682
|
);
|
|
644
683
|
|
|
684
|
+
server.registerTool(
|
|
685
|
+
'qa_read',
|
|
686
|
+
{
|
|
687
|
+
title: 'Read the text of a page (or one element)',
|
|
688
|
+
description:
|
|
689
|
+
'Return the RENDERED text of the page under test, or of a single element named by a qa_snapshot ref or a CSS selector. ' +
|
|
690
|
+
'This is the companion to qa_snapshot: that one maps what you can ACT on (roles, names, refs) and caps every name at 80 characters, ' +
|
|
691
|
+
'so anything longer — a one-time code in an email, an error paragraph, a generated invoice number — is invisible to it. ' +
|
|
692
|
+
'Uses innerText, so hidden branches and a mail thread\'s collapsed quoted history stay out. Read-only: it executes nothing and returns text, capped (default 4000 chars, max 20000).',
|
|
693
|
+
inputSchema: {
|
|
694
|
+
tabId: tabIdArg,
|
|
695
|
+
ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred when you already have one'),
|
|
696
|
+
selector: z.string().optional().describe('CSS selector; omit both to read the whole page body'),
|
|
697
|
+
maxChars: z.number().optional().describe('Cap the returned text (default 4000, max 20000)'),
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
async ({ tabId, ref, selector, maxChars }, extra) => {
|
|
701
|
+
const bar = loader(extra, 'reading the page text');
|
|
702
|
+
try {
|
|
703
|
+
return asText(await callExtension('qa_read', { tabId, ref, selector, maxChars }, 30_000));
|
|
704
|
+
} catch (e) {
|
|
705
|
+
return asError(e);
|
|
706
|
+
} finally {
|
|
707
|
+
bar.stop();
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
/** Enough to cover what an upload field actually accepts; anything else falls back to octet-stream. */
|
|
713
|
+
const MIME = {
|
|
714
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
715
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
|
|
716
|
+
'.csv': 'text/csv', '.txt': 'text/plain', '.json': 'application/json',
|
|
717
|
+
'.xls': 'application/vnd.ms-excel',
|
|
718
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
719
|
+
'.doc': 'application/msword',
|
|
720
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
721
|
+
'.zip': 'application/zip',
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
server.registerTool(
|
|
725
|
+
'qa_upload',
|
|
726
|
+
{
|
|
727
|
+
title: 'Attach a file to an upload field (Pro)',
|
|
728
|
+
description:
|
|
729
|
+
"Put a file into an <input type=file> — including the hidden ones behind a styled dropzone, which a click can never reach. " +
|
|
730
|
+
'Three ways to choose what goes in: `fixture` for a hostile edge-case file (the upload counterpart of naughty strings — empty, oversize, wrong-type, corrupt, unicode name, unsanitised SVG…), `path` for a file on this machine, or `content` as base64. ' +
|
|
731
|
+
'Pass `content` as base64 to attach a file YOU generated (a PDF fixture, a malformed CSV, an oversized image); omit it and Qiksy attaches a neutral test file matched to the field\'s `accept`. ' +
|
|
732
|
+
'A content script has no filesystem access and the OS file picker is a modal an agent must never open, so bytes over this bridge are the only way a file from outside the browser gets in. ' +
|
|
733
|
+
'Delivery fires both paths uploaders use — assigning `input.files` + change, and a synthetic drop on the nearest dropzone — because react-dropzone-style components listen only for the second.',
|
|
734
|
+
inputSchema: {
|
|
735
|
+
tabId: tabIdArg,
|
|
736
|
+
ref: z.string().optional().describe('A ref from qa_snapshot'),
|
|
737
|
+
selector: z.string().optional().describe("CSS selector of the file input or its dropzone; omit to use the page's first file input"),
|
|
738
|
+
name: z.string().optional().describe("File name, e.g. 'report.pdf'"),
|
|
739
|
+
mimeType: z.string().optional().describe("MIME type, e.g. 'application/pdf'"),
|
|
740
|
+
fixture: z
|
|
741
|
+
.enum(['image', 'pdf', 'csv', 'empty', 'oversize', 'wrong-type', 'corrupt', 'long-name', 'unicode-name', 'svg-script', 'double-ext'])
|
|
742
|
+
.optional()
|
|
743
|
+
.describe(
|
|
744
|
+
'A HOSTILE fixture built in the page, so it costs nothing to send: empty (0 bytes) · oversize (12MB) · ' +
|
|
745
|
+
'wrong-type (a PNG named .pdf — is content checked or only the extension?) · corrupt (truncated PNG) · ' +
|
|
746
|
+
'long-name (255 chars) · unicode-name (RTL + emoji + CJK) · svg-script (is an uploaded SVG sanitised?) · ' +
|
|
747
|
+
'double-ext (invoice.pdf.exe — does the UI show the whole name?). The answer comes back as `asks`.',
|
|
748
|
+
),
|
|
749
|
+
path: z.string().optional().describe('Absolute path to a file ON THIS MACHINE — the server reads and encodes it for you. Use this instead of `content` for anything bigger than a few hundred bytes.'),
|
|
750
|
+
content: z.string().optional().describe('Base64 file content, for a file you are holding in memory. Prefer `path`.'),
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
async ({ tabId, ref, selector, name, mimeType, path, content, fixture }, extra) => {
|
|
754
|
+
const bar = loader(extra, 'attaching the file');
|
|
755
|
+
try {
|
|
756
|
+
/* READING THE FILE HAPPENS HERE, and that is the whole point of the option.
|
|
757
|
+
*
|
|
758
|
+
* This process is ordinary Node on the user's machine — it has a filesystem, which the
|
|
759
|
+
* content script pointedly does not. Passing 15,000 characters of base64 through a tool
|
|
760
|
+
* call to move a 12KB image is absurd when one side can simply open the file; a modest
|
|
761
|
+
* PDF or screenshot makes it worse than absurd. So the agent names a path and the bytes
|
|
762
|
+
* never enter the conversation at all. */
|
|
763
|
+
let body = content;
|
|
764
|
+
let fileName = name;
|
|
765
|
+
let type = mimeType;
|
|
766
|
+
if (path) {
|
|
767
|
+
body = readFileSync(path).toString('base64');
|
|
768
|
+
fileName = fileName || basename(path);
|
|
769
|
+
type = type || MIME[extname(path).toLowerCase()] || 'application/octet-stream';
|
|
770
|
+
}
|
|
771
|
+
return asText(await callExtension('qa_upload', { tabId, ref, selector, name: fileName, mimeType: type, content: body, fixture }, 30_000));
|
|
772
|
+
} catch (e) {
|
|
773
|
+
return asError(e);
|
|
774
|
+
} finally {
|
|
775
|
+
bar.stop();
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
server.registerTool(
|
|
781
|
+
'qa_storage',
|
|
782
|
+
{
|
|
783
|
+
title: 'Read or set the page\'s web storage',
|
|
784
|
+
description:
|
|
785
|
+
"Read and write the app's own localStorage / sessionStorage — flip a feature flag, expire a token to test the re-auth path, or prove what the app actually persisted after a save. " +
|
|
786
|
+
"Without `op` (or with op:'list'/'get') it READS and is free like every other read on this bridge; op:'set'|'remove'|'clear' WRITES and needs Pro + the Agent control consent, because changing a stored token or flag changes what the app does just as surely as clicking does. " +
|
|
787
|
+
'Closed vocabulary — it executes nothing you supply, it names a key. Listing truncates long values at 300 chars; ask for one key by name to get it whole.',
|
|
788
|
+
inputSchema: {
|
|
789
|
+
tabId: tabIdArg,
|
|
790
|
+
area: z.enum(['local', 'session']).optional().describe("Which store (default 'local')"),
|
|
791
|
+
op: z.enum(['list', 'get', 'set', 'remove', 'clear']).optional().describe("Default 'list' (or 'get' when a key is given)"),
|
|
792
|
+
key: z.string().optional().describe('The key to read, set or remove'),
|
|
793
|
+
value: z.string().optional().describe("The value, for op:'set'"),
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
async ({ tabId, area, op, key, value }, extra) => {
|
|
797
|
+
const bar = loader(extra, 'reading web storage');
|
|
798
|
+
try {
|
|
799
|
+
return asText(await callExtension('qa_storage', { tabId, area, op, key, value }, 30_000));
|
|
800
|
+
} catch (e) {
|
|
801
|
+
return asError(e);
|
|
802
|
+
} finally {
|
|
803
|
+
bar.stop();
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
server.registerTool(
|
|
809
|
+
'qa_styles',
|
|
810
|
+
{
|
|
811
|
+
title: 'Computed styles and box of an element',
|
|
812
|
+
description:
|
|
813
|
+
'The COMPUTED styles of one element — colour, type, spacing, border, shadow, layout — plus its box in CSS pixels, and the CSS custom properties (design tokens) in scope for it. ' +
|
|
814
|
+
'This is how you check a built page against a design: it answers "this button renders #3B82F6 at 15px with 22px padding" without a screenshot and without measuring anything by eye. ' +
|
|
815
|
+
'Target it by a qa_snapshot ref (preferred) or a CSS selector. Read-only and executes NOTHING: the property list is fixed in the extension, so you name an element, never a program. ' +
|
|
816
|
+
'Pair it with a design source (a Figma file, a token JSON) to report the diff, and with qa_spotlight to show the human which element you mean.',
|
|
817
|
+
inputSchema: {
|
|
818
|
+
tabId: tabIdArg,
|
|
819
|
+
ref: z.string().optional().describe('A ref from qa_snapshot (e.g. "e12") — preferred over selector'),
|
|
820
|
+
selector: z.string().optional().describe('CSS selector of the element'),
|
|
821
|
+
tokens: z.boolean().optional().describe('Include the CSS custom properties in scope (default true)'),
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
async ({ tabId, ref, selector, tokens }, extra) => {
|
|
825
|
+
const bar = loader(extra, 'reading the computed styles');
|
|
826
|
+
try {
|
|
827
|
+
return asText(await callExtension('qa_styles', { tabId, ref, selector, tokens }, 30_000));
|
|
828
|
+
} catch (e) {
|
|
829
|
+
return asError(e);
|
|
830
|
+
} finally {
|
|
831
|
+
bar.stop();
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
);
|
|
835
|
+
|
|
645
836
|
server.registerTool(
|
|
646
837
|
'qa_snapshot',
|
|
647
838
|
{
|
|
@@ -651,12 +842,15 @@ server.registerTool(
|
|
|
651
842
|
'Unlike qa_export — which lists only the FORM fields Qiksy detects and can miss inputs, file uploads, date pickers, selects and buttons — this "sees everything". ' +
|
|
652
843
|
'Pass a ref straight back to qa_click / qa_type / qa_spotlight: a ref is unique and unambiguous, so it works where a CSS selector is duplicated across fields. ' +
|
|
653
844
|
'Built in the live, already-authenticated page in ~milliseconds — no browser launch, no CDP. Re-run it after the page changes to refresh the refs (a ref goes stale when its element is re-rendered).',
|
|
654
|
-
inputSchema: {
|
|
845
|
+
inputSchema: {
|
|
846
|
+
tabId: tabIdArg,
|
|
847
|
+
geometry: z.boolean().optional().describe('Add each node\'s box as "x,y,w,h" in CSS pixels — for overlap, clipping, off-screen and tap-target-size checks. Off by default: it is bulk on every node.'),
|
|
848
|
+
},
|
|
655
849
|
},
|
|
656
|
-
async ({ tabId }, extra) => {
|
|
850
|
+
async ({ tabId, geometry }, extra) => {
|
|
657
851
|
const bar = loader(extra, 'reading the whole page');
|
|
658
852
|
try {
|
|
659
|
-
return asText(await callExtension('qa_snapshot', { tabId }, 30_000));
|
|
853
|
+
return asText(await callExtension('qa_snapshot', { tabId, geometry }, 30_000));
|
|
660
854
|
} catch (e) {
|
|
661
855
|
return asError(e);
|
|
662
856
|
} finally {
|
|
@@ -694,6 +888,14 @@ server.registerTool(
|
|
|
694
888
|
'Requires History recording to have captured steps; otherwise returns a note.',
|
|
695
889
|
inputSchema: {
|
|
696
890
|
tabId: tabIdArg,
|
|
891
|
+
path: z
|
|
892
|
+
.string()
|
|
893
|
+
.optional()
|
|
894
|
+
.describe(
|
|
895
|
+
'Write the report HERE instead of returning it. An absolute path, or one relative to where the bridge process runs. ' +
|
|
896
|
+
'STRONGLY PREFERRED: the report is a self-contained HTML document with embedded screenshots and routinely runs to hundreds of kilobytes — returning it inline spends your context on bytes you were going to save to a file anyway. ' +
|
|
897
|
+
'You get back the path, the size and the report title.',
|
|
898
|
+
),
|
|
697
899
|
spec: z
|
|
698
900
|
.object({
|
|
699
901
|
title: z.string().optional().describe('Report heading, e.g. the ticket key + name'),
|
|
@@ -714,9 +916,29 @@ server.registerTool(
|
|
|
714
916
|
.describe('Acceptance criteria + verdicts to lead the report with'),
|
|
715
917
|
},
|
|
716
918
|
},
|
|
717
|
-
async ({ tabId, spec }) => {
|
|
919
|
+
async ({ tabId, path: outPath, spec }) => {
|
|
718
920
|
try {
|
|
719
|
-
|
|
921
|
+
const res = await callExtension('qa_report', { tabId, spec }, 45_000);
|
|
922
|
+
/* WRITING HAPPENS HERE, on the same reasoning as qa_upload's `path`, inverted.
|
|
923
|
+
*
|
|
924
|
+
* This process is ordinary Node on the user's machine and has a filesystem; the
|
|
925
|
+
* extension does not. A session report is a self-contained HTML document with base64
|
|
926
|
+
* screenshots inside it — hundreds of kilobytes — and handing that back through a tool
|
|
927
|
+
* result burns the agent's context on bytes whose destination is a file. So the agent
|
|
928
|
+
* names a path and only the receipt comes back. */
|
|
929
|
+
if (outPath && res && typeof res.html === 'string') {
|
|
930
|
+
const abs = isAbsolute(outPath) ? outPath : resolve(process.cwd(), outPath);
|
|
931
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
932
|
+
writeFileSync(abs, res.html, 'utf8');
|
|
933
|
+
return asText({
|
|
934
|
+
ok: true,
|
|
935
|
+
path: abs,
|
|
936
|
+
bytes: Buffer.byteLength(res.html, 'utf8'),
|
|
937
|
+
title: spec?.title ?? null,
|
|
938
|
+
note: 'Self-contained HTML — screenshots are embedded, so it opens anywhere and can be attached to a ticket as one file.',
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
return asText(res);
|
|
720
942
|
} catch (e) {
|
|
721
943
|
return asError(e);
|
|
722
944
|
}
|
|
@@ -764,7 +986,7 @@ server.registerTool(
|
|
|
764
986
|
{
|
|
765
987
|
title: 'Click an element (Pro)',
|
|
766
988
|
description:
|
|
767
|
-
'Click an element on the page under test, by a qa_snapshot ref (preferred) or a CSS selector. Sends the full pointer sequence (pointerdown → mouseup → click), so component libraries that listen for pointerdown — Radix, shadcn, MUI — react to it like a real click. Scrolls the element into view first, and refuses a target that matches nothing or has zero size rather than silently doing nothing.' +
|
|
989
|
+
'Click an element on the page under test, by a qa_snapshot ref (preferred) or a CSS selector. Sends the full pointer sequence (pointerdown → mouseup → click), so component libraries that listen for pointerdown — Radix, shadcn, MUI — react to it like a real click. Scrolls the element into view first, and refuses a target that matches nothing or has zero size rather than silently doing nothing. Returns what the click CHANGED — what appeared, disappeared or updated once the page settled — so you do not need a follow-up qa_snapshot to find out whether it did anything. `changed: null` means the page did not move, which is itself the answer.' +
|
|
768
990
|
DRIVE_NOTE,
|
|
769
991
|
inputSchema: {
|
|
770
992
|
tabId: tabIdArg,
|
|
@@ -809,14 +1031,18 @@ server.registerTool(
|
|
|
809
1031
|
{
|
|
810
1032
|
title: 'Fill many fields at once (Pro)',
|
|
811
1033
|
description:
|
|
812
|
-
'Fill a WHOLE form in ONE call instead of a round-trip per field: pass an array of { selector, value, action? }.
|
|
1034
|
+
'Fill a WHOLE form in ONE call instead of a round-trip per field: pass an array of { ref | selector, value, action? }. ' +
|
|
1035
|
+
'PREFER `ref` from qa_snapshot: a form built from a repeated component gives several inputs the identical cssPath, and a batch of selectors would then pour every value into the first match. ' +
|
|
1036
|
+
'action defaults to type (which also handles select, checkbox and radio from the value), or click for buttons and toggles. ' +
|
|
1037
|
+
'Returns a per-field result list plus filled/total — far faster than one qa_type per field on a big form.' +
|
|
813
1038
|
DRIVE_NOTE,
|
|
814
1039
|
inputSchema: {
|
|
815
1040
|
tabId: tabIdArg,
|
|
816
1041
|
fields: z
|
|
817
1042
|
.array(
|
|
818
1043
|
z.object({
|
|
819
|
-
|
|
1044
|
+
ref: z.string().optional().describe('A ref from qa_snapshot — REQUIRED when several fields share one cssPath, which is common on forms built from a repeated component'),
|
|
1045
|
+
selector: z.string().optional().describe('CSS selector of the field — use `ref` instead when the selector is not unique'),
|
|
820
1046
|
value: z.string().optional().describe('Value to set (text input / <select> / checkbox / radio)'),
|
|
821
1047
|
action: z.enum(['type', 'click']).optional().describe('Default "type"; use "click" for buttons and toggles'),
|
|
822
1048
|
}),
|
|
@@ -838,7 +1064,7 @@ server.registerTool(
|
|
|
838
1064
|
{
|
|
839
1065
|
title: 'Press a key (Pro)',
|
|
840
1066
|
description:
|
|
841
|
-
'Dispatch a keyboard key (keydown/keypress/keyup) — Enter to submit, Escape to close, Tab to move focus. Without a selector it goes to whatever currently has focus, like a real keyboard.' +
|
|
1067
|
+
'Dispatch a keyboard key (keydown/keypress/keyup) — Enter to submit, Escape to close, Tab to move focus. Without a selector it goes to whatever currently has focus, like a real keyboard. Like qa_click, it returns what changed once the page settled.' +
|
|
842
1068
|
DRIVE_NOTE,
|
|
843
1069
|
inputSchema: {
|
|
844
1070
|
tabId: tabIdArg,
|