webhands 0.2.0 → 0.4.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 +69 -6
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +607 -9
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +2 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +25 -2
- package/dist/errors.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +734 -9
- package/src/errors.ts +31 -0
package/src/cli.ts
CHANGED
|
@@ -12,8 +12,13 @@ import {
|
|
|
12
12
|
SessionAlreadyActiveError,
|
|
13
13
|
startSessionServer,
|
|
14
14
|
type Cookie,
|
|
15
|
+
type MouseInput,
|
|
15
16
|
type OpenTarget,
|
|
16
17
|
type RunningSessionServer,
|
|
18
|
+
type ScreenshotOptions,
|
|
19
|
+
type ScreenshotScope,
|
|
20
|
+
type ScrollTarget,
|
|
21
|
+
type SelectChoice,
|
|
17
22
|
type Session,
|
|
18
23
|
type SessionServerOptions,
|
|
19
24
|
type Transport,
|
|
@@ -112,6 +117,12 @@ export interface LaunchPolicy {
|
|
|
112
117
|
* to keep the fixed viewport even under stealth.
|
|
113
118
|
*/
|
|
114
119
|
readonly noViewport?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Route ALL traffic and DNS through one SOCKS proxy, as a SOCKS URL
|
|
122
|
+
* (`socks5h://host:1080` or `socks5://user:pass@host:1080`). `socks5h` tunnels
|
|
123
|
+
* DNS too (no leak); `socks5` allows local DNS. Omit for a direct connection.
|
|
124
|
+
*/
|
|
125
|
+
readonly proxy?: string;
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
// --- shared schema fragments ----------------------------------------------
|
|
@@ -169,6 +180,14 @@ const stealthOptions = z.object({
|
|
|
169
180
|
"Drive a browser already installed on the system (e.g. 'chrome', " +
|
|
170
181
|
"'msedge') instead of the bundled Chromium.",
|
|
171
182
|
),
|
|
183
|
+
proxy: z
|
|
184
|
+
.string()
|
|
185
|
+
.optional()
|
|
186
|
+
.describe(
|
|
187
|
+
'Route ALL traffic and DNS through a SOCKS proxy. Give a SOCKS URL: ' +
|
|
188
|
+
'socks5h://host:1080 tunnels DNS through the proxy too (no leak), ' +
|
|
189
|
+
'socks5://host:1080 allows local DNS. A user:pass@ prefix is allowed.',
|
|
190
|
+
),
|
|
172
191
|
// Modelled as a `viewport` boolean so incur's `--no-<flag>` negation gives the
|
|
173
192
|
// task-mandated `--no-viewport`: passing `--no-viewport` sets `viewport=false`
|
|
174
193
|
// (i.e. noViewport=true). Absent => undefined => core decides the default
|
|
@@ -190,6 +209,7 @@ function launchPolicyFrom(options: {
|
|
|
190
209
|
stealth?: boolean;
|
|
191
210
|
'use-system-browser'?: string;
|
|
192
211
|
viewport?: boolean;
|
|
212
|
+
proxy?: string;
|
|
193
213
|
}): LaunchPolicy {
|
|
194
214
|
return {
|
|
195
215
|
stealth: options.stealth === true,
|
|
@@ -202,6 +222,11 @@ function launchPolicyFrom(options: {
|
|
|
202
222
|
// `--no-viewport` (viewport=false) => noViewport:true; `--viewport` => false.
|
|
203
223
|
// Only forward when the flag was given.
|
|
204
224
|
...(options.viewport !== undefined ? {noViewport: !options.viewport} : {}),
|
|
225
|
+
// Only forward --proxy when given, so the policy object stays minimal (the
|
|
226
|
+
// `serve` wiring tests assert it by exact shape).
|
|
227
|
+
...(options.proxy !== undefined && options.proxy !== ''
|
|
228
|
+
? {proxy: options.proxy}
|
|
229
|
+
: {}),
|
|
205
230
|
};
|
|
206
231
|
}
|
|
207
232
|
|
|
@@ -239,6 +264,11 @@ function nextAct() {
|
|
|
239
264
|
description:
|
|
240
265
|
'Type into an element addressed by a Playwright locator string.',
|
|
241
266
|
},
|
|
267
|
+
{
|
|
268
|
+
command: 'query',
|
|
269
|
+
description:
|
|
270
|
+
'Read structured data (attrs/props/visibility) out of matched elements.',
|
|
271
|
+
},
|
|
242
272
|
{
|
|
243
273
|
command: 'eval',
|
|
244
274
|
description: 'Run JavaScript in the page as an escape hatch.',
|
|
@@ -371,6 +401,7 @@ export function createCli(deps: CliDeps = {}) {
|
|
|
371
401
|
stealth: z.boolean(),
|
|
372
402
|
systemBrowser: z.string().optional(),
|
|
373
403
|
noViewport: z.boolean().optional(),
|
|
404
|
+
proxy: z.string().optional(),
|
|
374
405
|
}),
|
|
375
406
|
async run(c) {
|
|
376
407
|
try {
|
|
@@ -395,6 +426,7 @@ export function createCli(deps: CliDeps = {}) {
|
|
|
395
426
|
...(policy.noViewport !== undefined
|
|
396
427
|
? {noViewport: policy.noViewport}
|
|
397
428
|
: {}),
|
|
429
|
+
...(policy.proxy !== undefined ? {proxy: policy.proxy} : {}),
|
|
398
430
|
},
|
|
399
431
|
{
|
|
400
432
|
cta: {
|
|
@@ -641,15 +673,28 @@ export function createCli(deps: CliDeps = {}) {
|
|
|
641
673
|
locator: z
|
|
642
674
|
.string()
|
|
643
675
|
.describe(
|
|
644
|
-
"A raw Playwright locator expression, e.g. getByRole('button', { name: 'Search' })."
|
|
676
|
+
"A raw Playwright locator expression, e.g. getByRole('button', { name: 'Search' }). " +
|
|
677
|
+
'With --by-ref, a durable `ref` from `query --with-refs` instead.',
|
|
678
|
+
),
|
|
679
|
+
}),
|
|
680
|
+
options: connectionOptions.extend({
|
|
681
|
+
'by-ref': z
|
|
682
|
+
.boolean()
|
|
683
|
+
.default(false)
|
|
684
|
+
.describe(
|
|
685
|
+
'Treat the argument as a durable `ref` from `query --with-refs`: ' +
|
|
686
|
+
'resolve it but fail LOUD (stale-ref) if it now matches zero or more ' +
|
|
687
|
+
'than one element, instead of silently clicking the wrong one.',
|
|
645
688
|
),
|
|
646
689
|
}),
|
|
647
|
-
options: connectionOptions,
|
|
648
690
|
output: actionOutput.extend({verb: z.literal('click')}),
|
|
649
691
|
async run(c) {
|
|
650
692
|
try {
|
|
651
693
|
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
652
|
-
await s.page.click(
|
|
694
|
+
await s.page.click(
|
|
695
|
+
locator(c.args.locator),
|
|
696
|
+
c.options['by-ref'] ? {byRef: true} : undefined,
|
|
697
|
+
);
|
|
653
698
|
return c.ok(
|
|
654
699
|
{ok: true as const, verb: 'click' as const},
|
|
655
700
|
{cta: {commands: [nextSnapshot()]}},
|
|
@@ -667,15 +712,31 @@ export function createCli(deps: CliDeps = {}) {
|
|
|
667
712
|
args: z.object({
|
|
668
713
|
locator: z
|
|
669
714
|
.string()
|
|
670
|
-
.describe(
|
|
715
|
+
.describe(
|
|
716
|
+
'A raw Playwright locator expression for the target input. ' +
|
|
717
|
+
'With --by-ref, a durable `ref` from `query --with-refs` instead.',
|
|
718
|
+
),
|
|
671
719
|
text: z.string().describe('The text to type into the element.'),
|
|
672
720
|
}),
|
|
673
|
-
options: connectionOptions
|
|
721
|
+
options: connectionOptions.extend({
|
|
722
|
+
'by-ref': z
|
|
723
|
+
.boolean()
|
|
724
|
+
.default(false)
|
|
725
|
+
.describe(
|
|
726
|
+
'Treat the locator argument as a durable `ref` from `query --with-refs`: ' +
|
|
727
|
+
'resolve it but fail LOUD (stale-ref) if it now matches zero or more ' +
|
|
728
|
+
'than one element, instead of silently typing into the wrong one.',
|
|
729
|
+
),
|
|
730
|
+
}),
|
|
674
731
|
output: actionOutput.extend({verb: z.literal('type')}),
|
|
675
732
|
async run(c) {
|
|
676
733
|
try {
|
|
677
734
|
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
678
|
-
await s.page.type(
|
|
735
|
+
await s.page.type(
|
|
736
|
+
locator(c.args.locator),
|
|
737
|
+
c.args.text,
|
|
738
|
+
c.options['by-ref'] ? {byRef: true} : undefined,
|
|
739
|
+
);
|
|
679
740
|
return c.ok(
|
|
680
741
|
{ok: true as const, verb: 'type' as const},
|
|
681
742
|
{cta: {commands: [nextSnapshot()]}},
|
|
@@ -697,7 +758,19 @@ export function createCli(deps: CliDeps = {}) {
|
|
|
697
758
|
'A JS expression evaluated in the page context (e.g. document.title).',
|
|
698
759
|
),
|
|
699
760
|
}),
|
|
700
|
-
|
|
761
|
+
// The ONE `--frame` flag on the surface (R1): `eval` runs page-world JS and
|
|
762
|
+
// cannot carry a `frameLocator(...)` the way locator-taking verbs do, so it
|
|
763
|
+
// gets an explicit SAME-ORIGIN frame selector. Omitted == top-document eval.
|
|
764
|
+
options: connectionOptions.extend({
|
|
765
|
+
frame: z
|
|
766
|
+
.string()
|
|
767
|
+
.optional()
|
|
768
|
+
.describe(
|
|
769
|
+
'Evaluate inside the named SAME-ORIGIN child frame instead of the top ' +
|
|
770
|
+
"document: a CSS selector for the iframe element (e.g. '#main-iframe'). " +
|
|
771
|
+
'A cross-origin frame is unreachable and fails loud.',
|
|
772
|
+
),
|
|
773
|
+
}),
|
|
701
774
|
output: z.object({
|
|
702
775
|
ok: z.literal(true),
|
|
703
776
|
verb: z.literal('eval'),
|
|
@@ -708,7 +781,12 @@ export function createCli(deps: CliDeps = {}) {
|
|
|
708
781
|
async run(c) {
|
|
709
782
|
try {
|
|
710
783
|
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
711
|
-
const result = await s.page.eval(
|
|
784
|
+
const result = await s.page.eval(
|
|
785
|
+
c.args.expression,
|
|
786
|
+
c.options.frame !== undefined
|
|
787
|
+
? {frame: c.options.frame}
|
|
788
|
+
: undefined,
|
|
789
|
+
);
|
|
712
790
|
return c.ok(
|
|
713
791
|
{ok: true as const, verb: 'eval' as const, result},
|
|
714
792
|
{cta: {commands: [nextSnapshot()]}},
|
|
@@ -720,6 +798,569 @@ export function createCli(deps: CliDeps = {}) {
|
|
|
720
798
|
},
|
|
721
799
|
});
|
|
722
800
|
|
|
801
|
+
// --- Tier-1 read verbs: query + state shorthands (prd broaden-agent-verb-
|
|
802
|
+
// surface, R2/R5). Each is its own incur command, so one definition yields
|
|
803
|
+
// both the CLI command and the MCP tool. List flags (--attr/--prop/--pw) are
|
|
804
|
+
// REPEATABLE, not comma-joined (R5): incur arrays collect each occurrence.
|
|
805
|
+
// There is NO --frame flag (frame scope rides IN the locator string, R1).
|
|
806
|
+
|
|
807
|
+
cli.command('query', {
|
|
808
|
+
description:
|
|
809
|
+
'Read structured data out of the element(s) a Playwright locator matches: ' +
|
|
810
|
+
'one row per match carrying exactly the requested DOM attributes (--attr), ' +
|
|
811
|
+
'live JS properties (--prop), and Playwright extras (--pw visible|bbox).',
|
|
812
|
+
args: z.object({
|
|
813
|
+
locator: z
|
|
814
|
+
.string()
|
|
815
|
+
.describe(
|
|
816
|
+
'A raw Playwright locator expression addressing the element(s) to read. ' +
|
|
817
|
+
"Frame scope rides in the string, e.g. frameLocator('#f').locator('#x').",
|
|
818
|
+
),
|
|
819
|
+
}),
|
|
820
|
+
options: connectionOptions.extend({
|
|
821
|
+
attr: z
|
|
822
|
+
.array(z.string())
|
|
823
|
+
.default([])
|
|
824
|
+
.describe(
|
|
825
|
+
'A DOM ATTRIBUTE to read (getAttribute), e.g. href. Repeatable.',
|
|
826
|
+
),
|
|
827
|
+
prop: z
|
|
828
|
+
.array(z.string())
|
|
829
|
+
.default([])
|
|
830
|
+
.describe(
|
|
831
|
+
'A live JS PROPERTY to read (el[name]), e.g. innerText. Repeatable.',
|
|
832
|
+
),
|
|
833
|
+
pw: z
|
|
834
|
+
.array(z.enum(['visible', 'bbox']))
|
|
835
|
+
.default([])
|
|
836
|
+
.describe(
|
|
837
|
+
'A Playwright extra to include: visible (actionability-grade) or ' +
|
|
838
|
+
'bbox (viewport-pixel box). Repeatable.',
|
|
839
|
+
),
|
|
840
|
+
limit: z.coerce
|
|
841
|
+
.number()
|
|
842
|
+
.optional()
|
|
843
|
+
.describe('Bound the number of rows returned.'),
|
|
844
|
+
'with-refs': z
|
|
845
|
+
.boolean()
|
|
846
|
+
.default(false)
|
|
847
|
+
.describe(
|
|
848
|
+
'Also return a durable `ref` per row — a locator handle you feed back ' +
|
|
849
|
+
'to `click`/`type` --by-ref to act on THAT element even after the ' +
|
|
850
|
+
'list mutates (fixes the .nth() index-drift footgun). Reuses a ' +
|
|
851
|
+
'stable unique attribute (id/data-testid/…) when present, mints ' +
|
|
852
|
+
'a namespaced data-webhands-ref only as a fallback. Off by default: ' +
|
|
853
|
+
'the default query is a pure read and mutates nothing.',
|
|
854
|
+
),
|
|
855
|
+
}),
|
|
856
|
+
output: z.object({
|
|
857
|
+
ok: z.literal(true),
|
|
858
|
+
verb: z.literal('query'),
|
|
859
|
+
rows: z
|
|
860
|
+
.array(
|
|
861
|
+
z.object({
|
|
862
|
+
attrs: z.record(z.string(), z.string().nullable()).optional(),
|
|
863
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
864
|
+
pw: z
|
|
865
|
+
.object({
|
|
866
|
+
visible: z.boolean().optional(),
|
|
867
|
+
bbox: z
|
|
868
|
+
.object({
|
|
869
|
+
x: z.number(),
|
|
870
|
+
y: z.number(),
|
|
871
|
+
width: z.number(),
|
|
872
|
+
height: z.number(),
|
|
873
|
+
})
|
|
874
|
+
.nullable()
|
|
875
|
+
.optional(),
|
|
876
|
+
})
|
|
877
|
+
.optional(),
|
|
878
|
+
ref: z
|
|
879
|
+
.string()
|
|
880
|
+
.optional()
|
|
881
|
+
.describe(
|
|
882
|
+
'A durable locator handle for this element (only with --with-refs); ' +
|
|
883
|
+
'pass it to click/type --by-ref to act on it later.',
|
|
884
|
+
),
|
|
885
|
+
}),
|
|
886
|
+
)
|
|
887
|
+
.describe(
|
|
888
|
+
'One row per matched element, each carrying the asked fields.',
|
|
889
|
+
),
|
|
890
|
+
}),
|
|
891
|
+
async run(c) {
|
|
892
|
+
try {
|
|
893
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
894
|
+
const rows = await s.page.query(locator(c.args.locator), {
|
|
895
|
+
attrs: c.options.attr,
|
|
896
|
+
props: c.options.prop,
|
|
897
|
+
pw: c.options.pw,
|
|
898
|
+
...(c.options.limit !== undefined ? {limit: c.options.limit} : {}),
|
|
899
|
+
...(c.options['with-refs'] ? {refs: true} : {}),
|
|
900
|
+
});
|
|
901
|
+
return c.ok(
|
|
902
|
+
{ok: true as const, verb: 'query' as const, rows},
|
|
903
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
904
|
+
);
|
|
905
|
+
});
|
|
906
|
+
} catch (cause) {
|
|
907
|
+
return fail(c, cause, binary);
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
cli.command('count', {
|
|
913
|
+
description:
|
|
914
|
+
'Count how many elements a Playwright locator matches (a match-set size).',
|
|
915
|
+
args: z.object({
|
|
916
|
+
locator: z.string().describe('A raw Playwright locator expression.'),
|
|
917
|
+
}),
|
|
918
|
+
options: connectionOptions,
|
|
919
|
+
output: z.object({
|
|
920
|
+
ok: z.literal(true),
|
|
921
|
+
verb: z.literal('count'),
|
|
922
|
+
count: z.number().describe('How many elements matched.'),
|
|
923
|
+
}),
|
|
924
|
+
async run(c) {
|
|
925
|
+
try {
|
|
926
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
927
|
+
const count = await s.page.count(locator(c.args.locator));
|
|
928
|
+
return c.ok(
|
|
929
|
+
{ok: true as const, verb: 'count' as const, count},
|
|
930
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
931
|
+
);
|
|
932
|
+
});
|
|
933
|
+
} catch (cause) {
|
|
934
|
+
return fail(c, cause, binary);
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
cli.command('exists', {
|
|
940
|
+
description:
|
|
941
|
+
'Whether a Playwright locator matches at least one element (count > 0).',
|
|
942
|
+
args: z.object({
|
|
943
|
+
locator: z.string().describe('A raw Playwright locator expression.'),
|
|
944
|
+
}),
|
|
945
|
+
options: connectionOptions,
|
|
946
|
+
output: z.object({
|
|
947
|
+
ok: z.literal(true),
|
|
948
|
+
verb: z.literal('exists'),
|
|
949
|
+
exists: z.boolean().describe('Whether any element matched.'),
|
|
950
|
+
}),
|
|
951
|
+
async run(c) {
|
|
952
|
+
try {
|
|
953
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
954
|
+
const exists = await s.page.exists(locator(c.args.locator));
|
|
955
|
+
return c.ok(
|
|
956
|
+
{ok: true as const, verb: 'exists' as const, exists},
|
|
957
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
958
|
+
);
|
|
959
|
+
});
|
|
960
|
+
} catch (cause) {
|
|
961
|
+
return fail(c, cause, binary);
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
cli.command('is-visible', {
|
|
967
|
+
description:
|
|
968
|
+
'Whether the first match is actionability-grade visible (a present-but-hidden ' +
|
|
969
|
+
'element reads false).',
|
|
970
|
+
args: z.object({
|
|
971
|
+
locator: z.string().describe('A raw Playwright locator expression.'),
|
|
972
|
+
}),
|
|
973
|
+
options: connectionOptions,
|
|
974
|
+
output: z.object({
|
|
975
|
+
ok: z.literal(true),
|
|
976
|
+
verb: z.literal('isVisible'),
|
|
977
|
+
visible: z.boolean().describe("The first match's visibility."),
|
|
978
|
+
}),
|
|
979
|
+
async run(c) {
|
|
980
|
+
try {
|
|
981
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
982
|
+
const visible = await s.page.isVisible(locator(c.args.locator));
|
|
983
|
+
return c.ok(
|
|
984
|
+
{ok: true as const, verb: 'isVisible' as const, visible},
|
|
985
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
986
|
+
);
|
|
987
|
+
});
|
|
988
|
+
} catch (cause) {
|
|
989
|
+
return fail(c, cause, binary);
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
cli.command('get-attribute', {
|
|
995
|
+
description:
|
|
996
|
+
'Read a single DOM attribute off the first match (null if absent or no match).',
|
|
997
|
+
args: z.object({
|
|
998
|
+
locator: z.string().describe('A raw Playwright locator expression.'),
|
|
999
|
+
}),
|
|
1000
|
+
options: connectionOptions.extend({
|
|
1001
|
+
name: z
|
|
1002
|
+
.string()
|
|
1003
|
+
.describe('The DOM attribute name to read (e.g. href, data-sitekey).'),
|
|
1004
|
+
}),
|
|
1005
|
+
output: z.object({
|
|
1006
|
+
ok: z.literal(true),
|
|
1007
|
+
verb: z.literal('getAttribute'),
|
|
1008
|
+
name: z.string().describe('The attribute that was read.'),
|
|
1009
|
+
value: z
|
|
1010
|
+
.string()
|
|
1011
|
+
.nullable()
|
|
1012
|
+
.describe('The attribute value, or null if absent / no match.'),
|
|
1013
|
+
}),
|
|
1014
|
+
async run(c) {
|
|
1015
|
+
try {
|
|
1016
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1017
|
+
const value = await s.page.getAttribute(
|
|
1018
|
+
locator(c.args.locator),
|
|
1019
|
+
c.options.name,
|
|
1020
|
+
);
|
|
1021
|
+
return c.ok(
|
|
1022
|
+
{
|
|
1023
|
+
ok: true as const,
|
|
1024
|
+
verb: 'getAttribute' as const,
|
|
1025
|
+
name: c.options.name,
|
|
1026
|
+
value,
|
|
1027
|
+
},
|
|
1028
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1029
|
+
);
|
|
1030
|
+
});
|
|
1031
|
+
} catch (cause) {
|
|
1032
|
+
return fail(c, cause, binary);
|
|
1033
|
+
}
|
|
1034
|
+
},
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
// --- Tier-2 rich input verbs: press / hover / select / scroll / drag (prd
|
|
1038
|
+
// broaden-agent-verb-surface, stories 8-12, R5). Each is its own incur
|
|
1039
|
+
// command, so one definition yields both the CLI command and the MCP tool.
|
|
1040
|
+
// Positional-arg + small-flag, mirroring `click` (R5); `select`/`scroll` use
|
|
1041
|
+
// loud "exactly one of" validation, mirroring `wait`. No --frame flag: frame
|
|
1042
|
+
// scope rides IN the locator string (R1).
|
|
1043
|
+
|
|
1044
|
+
cli.command('press', {
|
|
1045
|
+
description:
|
|
1046
|
+
'Press a keyboard key or chord (e.g. Enter, ArrowLeft, w, Control+A) at a ' +
|
|
1047
|
+
'locator or, with no locator, the focused element.',
|
|
1048
|
+
args: z.object({
|
|
1049
|
+
key: z
|
|
1050
|
+
.string()
|
|
1051
|
+
.describe(
|
|
1052
|
+
'A key or chord in Playwright grammar: a key name (Enter, ArrowLeft, ' +
|
|
1053
|
+
'a) or Modifier+Key (Control+A, Shift+Tab).',
|
|
1054
|
+
),
|
|
1055
|
+
}),
|
|
1056
|
+
options: connectionOptions.extend({
|
|
1057
|
+
locator: z
|
|
1058
|
+
.string()
|
|
1059
|
+
.optional()
|
|
1060
|
+
.describe(
|
|
1061
|
+
'A raw Playwright locator expression to press the key at (focuses it ' +
|
|
1062
|
+
'first). Omit to press at the focused element.',
|
|
1063
|
+
),
|
|
1064
|
+
}),
|
|
1065
|
+
output: actionOutput.extend({verb: z.literal('press')}),
|
|
1066
|
+
async run(c) {
|
|
1067
|
+
try {
|
|
1068
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1069
|
+
const target =
|
|
1070
|
+
c.options.locator !== undefined && c.options.locator !== ''
|
|
1071
|
+
? locator(c.options.locator)
|
|
1072
|
+
: undefined;
|
|
1073
|
+
await s.page.press(c.args.key, target);
|
|
1074
|
+
return c.ok(
|
|
1075
|
+
{ok: true as const, verb: 'press' as const},
|
|
1076
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1077
|
+
);
|
|
1078
|
+
});
|
|
1079
|
+
} catch (cause) {
|
|
1080
|
+
return fail(c, cause, binary);
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
cli.command('hover', {
|
|
1086
|
+
description:
|
|
1087
|
+
'Hover the pointer over the element a Playwright locator addresses ' +
|
|
1088
|
+
'(reveal hover menus / on-hover controls).',
|
|
1089
|
+
args: z.object({
|
|
1090
|
+
locator: z.string().describe('A raw Playwright locator expression.'),
|
|
1091
|
+
}),
|
|
1092
|
+
options: connectionOptions,
|
|
1093
|
+
output: actionOutput.extend({verb: z.literal('hover')}),
|
|
1094
|
+
async run(c) {
|
|
1095
|
+
try {
|
|
1096
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1097
|
+
await s.page.hover(locator(c.args.locator));
|
|
1098
|
+
return c.ok(
|
|
1099
|
+
{ok: true as const, verb: 'hover' as const},
|
|
1100
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1101
|
+
);
|
|
1102
|
+
});
|
|
1103
|
+
} catch (cause) {
|
|
1104
|
+
return fail(c, cause, binary);
|
|
1105
|
+
}
|
|
1106
|
+
},
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
cli.command('select', {
|
|
1110
|
+
description:
|
|
1111
|
+
'Choose an option in the native <select> a Playwright locator addresses, ' +
|
|
1112
|
+
'by --value OR --label (exactly one).',
|
|
1113
|
+
args: z.object({
|
|
1114
|
+
locator: z
|
|
1115
|
+
.string()
|
|
1116
|
+
.describe('A raw Playwright locator expression for the <select>.'),
|
|
1117
|
+
}),
|
|
1118
|
+
options: connectionOptions.extend({
|
|
1119
|
+
value: z
|
|
1120
|
+
.string()
|
|
1121
|
+
.optional()
|
|
1122
|
+
.describe("Match the option's value attribute (value form)."),
|
|
1123
|
+
label: z
|
|
1124
|
+
.string()
|
|
1125
|
+
.optional()
|
|
1126
|
+
.describe("Match the option's visible label text (label form)."),
|
|
1127
|
+
}),
|
|
1128
|
+
output: actionOutput.extend({
|
|
1129
|
+
verb: z.literal('select'),
|
|
1130
|
+
by: z.enum(['value', 'label']),
|
|
1131
|
+
}),
|
|
1132
|
+
async run(c) {
|
|
1133
|
+
const choice = selectChoiceFrom(c.options);
|
|
1134
|
+
if (choice === undefined) {
|
|
1135
|
+
return c.error({
|
|
1136
|
+
code: 'invalid-select',
|
|
1137
|
+
message: 'select needs exactly one of --value <v> or --label <l>.',
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1142
|
+
await s.page.select(locator(c.args.locator), choice);
|
|
1143
|
+
return c.ok(
|
|
1144
|
+
{
|
|
1145
|
+
ok: true as const,
|
|
1146
|
+
verb: 'select' as const,
|
|
1147
|
+
by: 'value' in choice ? ('value' as const) : ('label' as const),
|
|
1148
|
+
},
|
|
1149
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1150
|
+
);
|
|
1151
|
+
});
|
|
1152
|
+
} catch (cause) {
|
|
1153
|
+
return fail(c, cause, binary);
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
cli.command('scroll', {
|
|
1159
|
+
description:
|
|
1160
|
+
'Scroll the page, either --to a Playwright locator (bring it into view) ' +
|
|
1161
|
+
'or --by a dx,dy pixel delta (exactly one).',
|
|
1162
|
+
options: connectionOptions.extend({
|
|
1163
|
+
to: z
|
|
1164
|
+
.string()
|
|
1165
|
+
.optional()
|
|
1166
|
+
.describe(
|
|
1167
|
+
'A raw Playwright locator expression to scroll into view (to form).',
|
|
1168
|
+
),
|
|
1169
|
+
by: z
|
|
1170
|
+
.string()
|
|
1171
|
+
.optional()
|
|
1172
|
+
.describe(
|
|
1173
|
+
'A dx,dy pixel delta to scroll by, e.g. 0,400 (down) or -100,0 (by form).',
|
|
1174
|
+
),
|
|
1175
|
+
}),
|
|
1176
|
+
output: actionOutput.extend({
|
|
1177
|
+
verb: z.literal('scroll'),
|
|
1178
|
+
form: z.enum(['to', 'by']),
|
|
1179
|
+
}),
|
|
1180
|
+
async run(c) {
|
|
1181
|
+
const target = scrollTargetFrom(c.options);
|
|
1182
|
+
if (target === undefined) {
|
|
1183
|
+
return c.error({
|
|
1184
|
+
code: 'invalid-scroll',
|
|
1185
|
+
message:
|
|
1186
|
+
'scroll needs exactly one of --to <locator> or --by <dx,dy> ' +
|
|
1187
|
+
'(dx,dy two numbers, e.g. 0,400).',
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1192
|
+
await s.page.scroll(target);
|
|
1193
|
+
return c.ok(
|
|
1194
|
+
{
|
|
1195
|
+
ok: true as const,
|
|
1196
|
+
verb: 'scroll' as const,
|
|
1197
|
+
form: 'to' in target ? ('to' as const) : ('by' as const),
|
|
1198
|
+
},
|
|
1199
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1200
|
+
);
|
|
1201
|
+
});
|
|
1202
|
+
} catch (cause) {
|
|
1203
|
+
return fail(c, cause, binary);
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
cli.command('drag', {
|
|
1209
|
+
description:
|
|
1210
|
+
'Drag the element a source locator addresses onto the element a target ' +
|
|
1211
|
+
'locator addresses (drag-reorder UIs, drag-slider challenges).',
|
|
1212
|
+
args: z.object({
|
|
1213
|
+
source: z
|
|
1214
|
+
.string()
|
|
1215
|
+
.describe('A raw Playwright locator expression for the drag source.'),
|
|
1216
|
+
target: z
|
|
1217
|
+
.string()
|
|
1218
|
+
.describe('A raw Playwright locator expression for the drop target.'),
|
|
1219
|
+
}),
|
|
1220
|
+
options: connectionOptions,
|
|
1221
|
+
output: actionOutput.extend({verb: z.literal('drag')}),
|
|
1222
|
+
async run(c) {
|
|
1223
|
+
try {
|
|
1224
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1225
|
+
await s.page.drag(locator(c.args.source), locator(c.args.target));
|
|
1226
|
+
return c.ok(
|
|
1227
|
+
{ok: true as const, verb: 'drag' as const},
|
|
1228
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1229
|
+
);
|
|
1230
|
+
});
|
|
1231
|
+
} catch (cause) {
|
|
1232
|
+
return fail(c, cause, binary);
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// --- Tier-4 coordinate + screenshot verbs: mouse / screenshot (prd
|
|
1238
|
+
// broaden-agent-verb-surface, R3/R5, stories 17-19). Each is its own incur
|
|
1239
|
+
// command, so one definition yields both the CLI command and the MCP tool.
|
|
1240
|
+
// The seam stays string/number-typed (ADR-0003 as amended by the Tier-4 ADR):
|
|
1241
|
+
// `mouse` passes plain numbers + an enum, `screenshot` returns a file PATH
|
|
1242
|
+
// (never image bytes). The MCP `screenshot` result surfaces that path as the
|
|
1243
|
+
// attachment-capable `path` field an agent reads/attaches.
|
|
1244
|
+
|
|
1245
|
+
cli.command('mouse', {
|
|
1246
|
+
description:
|
|
1247
|
+
'Coordinate mouse input at VIEWPORT CSS-pixels (Playwright page.mouse, NOT ' +
|
|
1248
|
+
'OS screen coordinates): click / move / down / up at --x,--y. A pixel in a ' +
|
|
1249
|
+
'VIEWPORT screenshot maps directly to these coordinates (the look-then-click ' +
|
|
1250
|
+
'loop); a FULL-PAGE screenshot does NOT.',
|
|
1251
|
+
options: connectionOptions.extend({
|
|
1252
|
+
action: z
|
|
1253
|
+
.enum(['click', 'move', 'down', 'up'])
|
|
1254
|
+
.default('click')
|
|
1255
|
+
.describe('What to do at the coordinate (default: click).'),
|
|
1256
|
+
x: z.coerce.number().describe('Viewport CSS-pixel X (left-relative).'),
|
|
1257
|
+
y: z.coerce.number().describe('Viewport CSS-pixel Y (top-relative).'),
|
|
1258
|
+
button: z
|
|
1259
|
+
.enum(['left', 'right', 'middle'])
|
|
1260
|
+
.default('left')
|
|
1261
|
+
.describe('Which button for click/down/up (default: left).'),
|
|
1262
|
+
}),
|
|
1263
|
+
output: actionOutput.extend({
|
|
1264
|
+
verb: z.literal('mouse'),
|
|
1265
|
+
action: z.enum(['click', 'move', 'down', 'up']),
|
|
1266
|
+
x: z.number(),
|
|
1267
|
+
y: z.number(),
|
|
1268
|
+
}),
|
|
1269
|
+
async run(c) {
|
|
1270
|
+
try {
|
|
1271
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1272
|
+
const input: MouseInput = {
|
|
1273
|
+
action: c.options.action,
|
|
1274
|
+
x: c.options.x,
|
|
1275
|
+
y: c.options.y,
|
|
1276
|
+
button: c.options.button,
|
|
1277
|
+
};
|
|
1278
|
+
await s.page.mouse(input);
|
|
1279
|
+
return c.ok(
|
|
1280
|
+
{
|
|
1281
|
+
ok: true as const,
|
|
1282
|
+
verb: 'mouse' as const,
|
|
1283
|
+
action: c.options.action,
|
|
1284
|
+
x: c.options.x,
|
|
1285
|
+
y: c.options.y,
|
|
1286
|
+
},
|
|
1287
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1288
|
+
);
|
|
1289
|
+
});
|
|
1290
|
+
} catch (cause) {
|
|
1291
|
+
return fail(c, cause, binary);
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
cli.command('screenshot', {
|
|
1297
|
+
description:
|
|
1298
|
+
'Capture the page to a PNG FILE and return its PATH (never image bytes): ' +
|
|
1299
|
+
'--scope viewport (default, coordinate-matched to mouse) | full (whole page, ' +
|
|
1300
|
+
'NOT coordinate-matched) | element (clipped to --locator, REQUIRED for element). ' +
|
|
1301
|
+
'--out overrides the path (validated to stay under the managed dir).',
|
|
1302
|
+
options: connectionOptions.extend({
|
|
1303
|
+
scope: z
|
|
1304
|
+
.enum(['viewport', 'full', 'element'])
|
|
1305
|
+
.default('viewport')
|
|
1306
|
+
.describe(
|
|
1307
|
+
'Region to capture: viewport (default) | full | element (needs --locator).',
|
|
1308
|
+
),
|
|
1309
|
+
locator: z
|
|
1310
|
+
.string()
|
|
1311
|
+
.optional()
|
|
1312
|
+
.describe(
|
|
1313
|
+
'A raw Playwright locator expression to clip to (REQUIRED for --scope ' +
|
|
1314
|
+
'element, rejected otherwise). Frame scope rides in the string.',
|
|
1315
|
+
),
|
|
1316
|
+
out: z
|
|
1317
|
+
.string()
|
|
1318
|
+
.optional()
|
|
1319
|
+
.describe(
|
|
1320
|
+
'Override the output PNG path (validated to stay under the managed dir).',
|
|
1321
|
+
),
|
|
1322
|
+
}),
|
|
1323
|
+
output: z.object({
|
|
1324
|
+
ok: z.literal(true),
|
|
1325
|
+
verb: z.literal('screenshot'),
|
|
1326
|
+
// `path` is the attachment-capable field (R5): a plain file PATH an agent
|
|
1327
|
+
// reads / attaches; no image bytes ever cross the seam.
|
|
1328
|
+
path: z
|
|
1329
|
+
.string()
|
|
1330
|
+
.describe('The PNG file path (read/attach this; never bytes).'),
|
|
1331
|
+
width: z.number().describe('The PNG pixel width.'),
|
|
1332
|
+
height: z.number().describe('The PNG pixel height.'),
|
|
1333
|
+
}),
|
|
1334
|
+
async run(c) {
|
|
1335
|
+
const options = screenshotOptionsFrom(c.options);
|
|
1336
|
+
if (options === undefined) {
|
|
1337
|
+
return c.error({
|
|
1338
|
+
code: 'invalid-screenshot',
|
|
1339
|
+
message:
|
|
1340
|
+
'screenshot --scope element requires --locator <expr>; --locator is ' +
|
|
1341
|
+
'only valid with --scope element.',
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
try {
|
|
1345
|
+
return await withSession(provider, targetFrom(c.options), async (s) => {
|
|
1346
|
+
const shot = await s.page.screenshot(options);
|
|
1347
|
+
return c.ok(
|
|
1348
|
+
{
|
|
1349
|
+
ok: true as const,
|
|
1350
|
+
verb: 'screenshot' as const,
|
|
1351
|
+
path: shot.path,
|
|
1352
|
+
width: shot.width,
|
|
1353
|
+
height: shot.height,
|
|
1354
|
+
},
|
|
1355
|
+
{cta: {commands: [nextSnapshot()]}},
|
|
1356
|
+
);
|
|
1357
|
+
});
|
|
1358
|
+
} catch (cause) {
|
|
1359
|
+
return fail(c, cause, binary);
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1362
|
+
});
|
|
1363
|
+
|
|
723
1364
|
cli.command('wait', {
|
|
724
1365
|
description:
|
|
725
1366
|
'Pace actions by waiting for a timeout, a locator to appear, or the next navigation.',
|
|
@@ -856,8 +1497,11 @@ async function defaultServeSession(
|
|
|
856
1497
|
...(launchPolicy.noViewport !== undefined
|
|
857
1498
|
? {noViewport: launchPolicy.noViewport}
|
|
858
1499
|
: {}),
|
|
1500
|
+
...(launchPolicy.proxy !== undefined ? {proxy: launchPolicy.proxy} : {}),
|
|
859
1501
|
});
|
|
860
|
-
|
|
1502
|
+
// attach reuses the user's browser, but the managed screenshots dir still
|
|
1503
|
+
// honours the home-root override so a test isolates screenshot output.
|
|
1504
|
+
const attach = new PlaywrightAttachTransport([], home);
|
|
861
1505
|
const transport: Transport = {
|
|
862
1506
|
open(t: OpenTarget): Promise<Session> {
|
|
863
1507
|
return t.mode === 'attach' ? attach.open(t) : launch.open(t);
|
|
@@ -885,6 +1529,87 @@ function waitConditionFrom(options: {
|
|
|
885
1529
|
return forms.length === 1 ? forms[0] : undefined;
|
|
886
1530
|
}
|
|
887
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* Turn the `select` option forms into the seam's {@link SelectChoice}, or
|
|
1534
|
+
* `undefined` if zero or both of `--value` / `--label` were given (the command
|
|
1535
|
+
* reports that as a clear error). Mirrors `wait`'s loud "exactly one of"
|
|
1536
|
+
* validation (R5): an empty string counts as absent, so `--value ''` is treated
|
|
1537
|
+
* as not given.
|
|
1538
|
+
*/
|
|
1539
|
+
function selectChoiceFrom(options: {
|
|
1540
|
+
value?: string;
|
|
1541
|
+
label?: string;
|
|
1542
|
+
}): SelectChoice | undefined {
|
|
1543
|
+
const forms: SelectChoice[] = [];
|
|
1544
|
+
if (options.value !== undefined) forms.push({value: options.value});
|
|
1545
|
+
if (options.label !== undefined) forms.push({label: options.label});
|
|
1546
|
+
return forms.length === 1 ? forms[0] : undefined;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Turn the `scroll` option forms into the seam's {@link ScrollTarget}, or
|
|
1551
|
+
* `undefined` if zero or both of `--to` / `--by` were given OR `--by` is not a
|
|
1552
|
+
* valid `dx,dy` pair (the command reports that as a clear error). Mirrors
|
|
1553
|
+
* `wait`'s loud "exactly one of" validation (R5). `--by` is parsed here (two
|
|
1554
|
+
* comma-separated finite numbers) so a malformed delta fails loud rather than
|
|
1555
|
+
* silently scrolling by `NaN`.
|
|
1556
|
+
*/
|
|
1557
|
+
function scrollTargetFrom(options: {
|
|
1558
|
+
to?: string;
|
|
1559
|
+
by?: string;
|
|
1560
|
+
}): ScrollTarget | undefined {
|
|
1561
|
+
const forms: ScrollTarget[] = [];
|
|
1562
|
+
if (options.to !== undefined && options.to !== '') {
|
|
1563
|
+
forms.push({to: locator(options.to)});
|
|
1564
|
+
}
|
|
1565
|
+
if (options.by !== undefined && options.by !== '') {
|
|
1566
|
+
const by = parseDelta(options.by);
|
|
1567
|
+
if (by === undefined) return undefined;
|
|
1568
|
+
forms.push({by});
|
|
1569
|
+
}
|
|
1570
|
+
return forms.length === 1 ? forms[0] : undefined;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Turn the `screenshot` option flags into the seam's {@link ScreenshotOptions},
|
|
1575
|
+
* or `undefined` when the scope/locator pairing is invalid (the command reports
|
|
1576
|
+
* that as a clear error, mirroring `wait`'s loud validation, R5): `--scope
|
|
1577
|
+
* element` REQUIRES `--locator`, and `--locator` is ONLY valid with `--scope
|
|
1578
|
+
* element`. An empty `--locator`/`--out` string counts as absent. The seam
|
|
1579
|
+
* re-validates as the load-bearing check (an untyped RPC client too), so this is
|
|
1580
|
+
* the friendly fail-fast at the CLI edge.
|
|
1581
|
+
*/
|
|
1582
|
+
function screenshotOptionsFrom(options: {
|
|
1583
|
+
scope: ScreenshotScope;
|
|
1584
|
+
locator?: string;
|
|
1585
|
+
out?: string;
|
|
1586
|
+
}): ScreenshotOptions | undefined {
|
|
1587
|
+
const hasLocator = options.locator !== undefined && options.locator !== '';
|
|
1588
|
+
if (options.scope === 'element' && !hasLocator) return undefined;
|
|
1589
|
+
if (options.scope !== 'element' && hasLocator) return undefined;
|
|
1590
|
+
return {
|
|
1591
|
+
scope: options.scope,
|
|
1592
|
+
...(hasLocator ? {locator: locator(options.locator!)} : {}),
|
|
1593
|
+
...(options.out !== undefined && options.out !== ''
|
|
1594
|
+
? {out: options.out}
|
|
1595
|
+
: {}),
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Parse a `dx,dy` pixel-delta string into a `{dx, dy}` pair, or `undefined` if
|
|
1601
|
+
* it is not exactly two comma-separated finite numbers. Used by `scroll --by`
|
|
1602
|
+
* so a malformed delta fails loud instead of scrolling by `NaN`.
|
|
1603
|
+
*/
|
|
1604
|
+
function parseDelta(raw: string): {dx: number; dy: number} | undefined {
|
|
1605
|
+
const parts = raw.split(',');
|
|
1606
|
+
if (parts.length !== 2) return undefined;
|
|
1607
|
+
const dx = Number(parts[0]!.trim());
|
|
1608
|
+
const dy = Number(parts[1]!.trim());
|
|
1609
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return undefined;
|
|
1610
|
+
return {dx, dy};
|
|
1611
|
+
}
|
|
1612
|
+
|
|
888
1613
|
/**
|
|
889
1614
|
* The shared failure path. Map a typed `core` error to its user-facing message
|
|
890
1615
|
* + exact fix command (PRD story 17); fall back to a generic error otherwise.
|