@lacneu/wix-openclaw 0.4.0 → 0.5.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.
@@ -0,0 +1,740 @@
1
+ // Accessibility Scans — Wix runs the scan, this module reads the result.
2
+ //
3
+ // A DIFFERENT DISCIPLINE FROM THE SEO WRITES. Nothing here destroys anything:
4
+ // there is no token, no refusal, no `wouldLose`. The way this module fails a
5
+ // user is by letting an agent report a CLEAN SITE from an incomplete scan.
6
+ // Wix states the rule plainly, and it has three parts:
7
+ //
8
+ // "A page or category is clear only when it has no matching findings, its
9
+ // page status is completed, and its coverage confirms that the relevant
10
+ // checks ran."
11
+ //
12
+ // "A failed page with zero findings is unknown, not clean."
13
+ //
14
+ // So the shape of every answer here carries what it does NOT know:
15
+ // `PARTIALLY_COMPLETED` never collapses into success, a failed page is never
16
+ // reported as zero findings, and a missing finding is never evidence that a
17
+ // rule passed unless `coverage` says the rule ran. That is this module's
18
+ // "redirects do not chain".
19
+ //
20
+ // Contract sourced from the public reference (Introduction, Sample Flows, the
21
+ // Wix agent Skill) and from the published SDK package
22
+ // `@wix/auto_sdk_accessibility_accessibility-scans`, whose own host mapping
23
+ // gives the REST prefix verbatim:
24
+ // srcPath "/accessibility/v1/accessibility-scans" → destPath "/v1/…"
25
+ // No path here is inferred from a convention.
26
+ import { createHash } from "node:crypto";
27
+ import { Type } from "@sinclair/typebox";
28
+ import { defineWixTool } from "./_factory.js";
29
+ import { compactQuery } from "./_query.js";
30
+ import { WixApiError } from "../wix-client.js";
31
+ const SiteIdParam = Type.Optional(Type.String());
32
+ /** Base path of the Accessibility Scans service. */
33
+ const SCANS = "/accessibility/v1/accessibility-scans";
34
+ /** The scan's id, as REST actually names it.
35
+ *
36
+ * The SDK typings call it `_id`, and that is the shape every documented
37
+ * example shows — but the SDK renames three keys on the way back from REST
38
+ * (`id -> _id`, `createdDate -> _createdDate`, `updatedDate -> _updatedDate`).
39
+ * This plugin speaks REST directly, so the wire says `id`. Reading `_id` gave
40
+ * `undefined` on every real call: a run that hands back no scan id leaves the
41
+ * caller with nothing to poll. Both are accepted because they name the same
42
+ * thing and being wrong in this direction costs nothing. */
43
+ function scanIdOf(scan) {
44
+ const rest = scan?.id;
45
+ if (typeof rest === "string" && rest !== "")
46
+ return rest;
47
+ const sdk = scan?._id;
48
+ return typeof sdk === "string" && sdk !== "" ? sdk : undefined;
49
+ }
50
+ /** Terminal statuses, verbatim from the reference. Anything else means the
51
+ * scan is still moving and its numbers are not a result yet. */
52
+ const TERMINAL = new Set([
53
+ "ACCESSIBILITY_SCAN_STATUS_COMPLETED",
54
+ "ACCESSIBILITY_SCAN_STATUS_PARTIALLY_COMPLETED",
55
+ "ACCESSIBILITY_SCAN_STATUS_FAILED",
56
+ ]);
57
+ /** Domain separation for the idempotency key — a CONSTANT, deliberately.
58
+ *
59
+ * This is not a secret and must never behave like one. The SEO tokens are
60
+ * minted per process because an acknowledgement must not survive a restart;
61
+ * this key is the exact opposite: it exists so that a retry after a LOST
62
+ * RESPONSE cannot create a second scan, and a gateway restart or a retry
63
+ * landing on another worker is precisely when that happens. Keying it on the
64
+ * pid made the guarantee evaporate in the only situation it was for. */
65
+ const KEY_DOMAIN = "wix-openclaw/accessibility/idempotency/v1";
66
+ // ---------------------------------------------------------------------------
67
+ // The target. Exactly one of three, and a discriminator that must agree.
68
+ // ---------------------------------------------------------------------------
69
+ /** `targetType` is a DISCRIMINATOR Wix requires to match the populated field.
70
+ * It is built here and never accepted from the caller: a target that says
71
+ * `PAGE` while carrying a collection is rejected by Wix with an error the
72
+ * agent then has to interpret, when the plugin already knew the answer. */
73
+ function buildTarget(p) {
74
+ const chosen = [
75
+ p.fullSite === true ? "fullSite" : undefined,
76
+ p.pageId !== undefined && p.pageId !== "" ? "pageId" : undefined,
77
+ p.pageUrl !== undefined && p.pageUrl !== "" ? "pageUrl" : undefined,
78
+ p.collectionId !== undefined && p.collectionId !== ""
79
+ ? "collectionId"
80
+ : undefined,
81
+ ].filter((x) => x !== undefined);
82
+ if (chosen.length === 0) {
83
+ throw new Error("Refusing to scan: no target. A scan checks exactly one of `fullSite: " +
84
+ "true`, one page (`pageId` or `pageUrl`), or one page collection " +
85
+ "(`collectionId`, from `wix_a11y_list_page_collections`).");
86
+ }
87
+ if (chosen.length > 1) {
88
+ // A SILENT PRECEDENCE WOULD SCAN THE WRONG THING. Picking the first field
89
+ // found would answer about a page while the caller asked about the site,
90
+ // and the answer would look valid.
91
+ throw new Error(`Refusing to scan: ${chosen.length} targets given (${chosen.join(", ")}). ` +
92
+ "A scan checks exactly one target. Split this into one call per target.");
93
+ }
94
+ if (p.fullSite === true) {
95
+ return {
96
+ target: {
97
+ targetType: "SCOPE",
98
+ scope: "ACCESSIBILITY_SCAN_SCOPE_FULL_SITE",
99
+ },
100
+ key: "scope:FULL_SITE",
101
+ label: "the full site",
102
+ };
103
+ }
104
+ if (p.collectionId !== undefined && p.collectionId !== "") {
105
+ return {
106
+ target: {
107
+ targetType: "PAGE_COLLECTION",
108
+ pageCollection: { collectionId: p.collectionId },
109
+ },
110
+ key: `collection:${p.collectionId}`,
111
+ label: `the page collection ${p.collectionId}`,
112
+ };
113
+ }
114
+ // THE IDENTIFIER THAT WAS CHOSEN, not the first field that exists. With an
115
+ // empty `pageId` alongside a real `pageUrl`, `pageId ?? pageUrl` kept the
116
+ // empty string: every URL sharing an `attempt` then derived the SAME key and
117
+ // an empty label, so one page's scan answered for another's.
118
+ const usingId = p.pageId !== undefined && p.pageId !== "";
119
+ const identifier = usingId ? p.pageId : p.pageUrl;
120
+ return {
121
+ target: {
122
+ targetType: "PAGE",
123
+ page: usingId ? { pageId: identifier } : { pageUrl: identifier },
124
+ },
125
+ key: `page:${usingId ? "id" : "url"}:${identifier}`,
126
+ label: `the page ${identifier}`,
127
+ };
128
+ }
129
+ /** The same target, flattened into the dotted query keys a GET takes. */
130
+ function targetQuery(target) {
131
+ const out = {
132
+ "target.targetType": String(target.targetType),
133
+ };
134
+ if (typeof target.scope === "string")
135
+ out["target.scope"] = target.scope;
136
+ const page = target.page;
137
+ if (page?.pageId !== undefined)
138
+ out["target.page.pageId"] = page.pageId;
139
+ if (page?.pageUrl !== undefined)
140
+ out["target.page.pageUrl"] = page.pageUrl;
141
+ const coll = target.pageCollection;
142
+ if (coll?.collectionId !== undefined) {
143
+ out["target.pageCollection.collectionId"] = coll.collectionId;
144
+ }
145
+ return out;
146
+ }
147
+ /** A GUID derived from site + target + attempt.
148
+ *
149
+ * WHY NOT RANDOM. The key exists so a lost response cannot create duplicate
150
+ * work: a retry must send the SAME key. A fresh random key per invocation
151
+ * would satisfy the schema and make the entire idempotency contract inert —
152
+ * every retry a new scan, against a 5-per-minute limit.
153
+ *
154
+ * WHY NOT THE TARGET ALONE. Wix requires a NEW key for a genuinely new scan,
155
+ * which is exactly what verifying a fix is. Deriving from the target only
156
+ * would make the second scan a retry of the first and hand back the stale
157
+ * result as if it were fresh.
158
+ *
159
+ * So the caller names the attempt, and that name is the whole difference
160
+ * between "retry this" and "scan again". */
161
+ function idempotencyKey(site, targetKey, attempt) {
162
+ const h = createHash("sha256")
163
+ .update([KEY_DOMAIN, site, targetKey, attempt].join("\u0000"))
164
+ .digest("hex");
165
+ // Shaped as a v4 GUID: Wix declares `@format GUID`, max length 36.
166
+ const b = h.slice(0, 32).split("");
167
+ b[12] = "4";
168
+ b[16] = "89ab"[parseInt(h[16], 16) % 4];
169
+ const s = b.join("");
170
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // The honesty layer. Everything below exists so no answer can overstate what
174
+ // the scan established.
175
+ // ---------------------------------------------------------------------------
176
+ /** Did the scan produce a result, and is that result whole?
177
+ *
178
+ * `PARTIALLY_COMPLETED` carries usable findings AND failed pages. Collapsing
179
+ * it into "completed" is the single most damaging thing this module could do:
180
+ * the caller reports a site as checked when part of it was never read. */
181
+ function scanCompleteness(scan) {
182
+ const status = typeof scan?.status === "string" ? scan.status : "unknown";
183
+ const progress = (scan?.progress ?? {});
184
+ const failed = typeof progress.failedPageCount === "number"
185
+ ? progress.failedPageCount
186
+ : "unknown";
187
+ const pending = typeof progress.pendingPageCount === "number"
188
+ ? progress.pendingPageCount
189
+ : "unknown";
190
+ const terminal = TERMINAL.has(status);
191
+ if (status === "ACCESSIBILITY_SCAN_STATUS_FAILED") {
192
+ return {
193
+ status,
194
+ terminal,
195
+ resultIsWhole: false,
196
+ unscannedPages: "unknown",
197
+ caveat: "This scan produced NO usable result. Do not present the site as " +
198
+ "checked, and do not request findings. Read `failure`, fix the cause, " +
199
+ "and start a new scan with a new `attempt`.",
200
+ };
201
+ }
202
+ if (status === "ACCESSIBILITY_SCAN_STATUS_PARTIALLY_COMPLETED") {
203
+ return {
204
+ status,
205
+ terminal,
206
+ resultIsWhole: false,
207
+ unscannedPages: failed,
208
+ caveat: "PART OF THIS TARGET WAS NEVER CHECKED. The findings below are real, " +
209
+ "but one or more pages failed to scan. List the page summaries and " +
210
+ "report every failed page: a failed page with zero findings is " +
211
+ "UNKNOWN, not clean.",
212
+ };
213
+ }
214
+ if (status === "ACCESSIBILITY_SCAN_STATUS_COMPLETED") {
215
+ return { status, terminal, resultIsWhole: true, unscannedPages: failed };
216
+ }
217
+ // QUEUED / RUNNING / anything this version does not know.
218
+ // FAILED PAGES COUNT WHILE IT RUNS TOO. Reporting only the pending ones
219
+ // understated how much of the target was still unaccounted for — and this
220
+ // number is the one a caller uses to decide whether to keep waiting.
221
+ const stillUnknown = typeof pending === "number" && typeof failed === "number"
222
+ ? pending + failed
223
+ : "unknown";
224
+ return {
225
+ status,
226
+ terminal,
227
+ resultIsWhole: "unknown",
228
+ unscannedPages: stillUnknown,
229
+ caveat: "This scan has not finished. Its numbers are progress, not a result — " +
230
+ "do not report them as an audit. Poll `wix_a11y_get_scan` until the " +
231
+ "status is terminal.",
232
+ };
233
+ }
234
+ /** Is this page clear? THREE CONDITIONS, never one.
235
+ *
236
+ * `findingCount === 0` alone is the trap: it is also what a page that failed
237
+ * to scan reports, and what a page reports when the relevant checks never
238
+ * ran. Both are `"unknown"`, and saying so is the whole job. */
239
+ function pageVerdict(summary) {
240
+ const status = typeof summary.status === "string" ? summary.status : "unknown";
241
+ const count = typeof summary.findingCount === "number" ? summary.findingCount : undefined;
242
+ const coverage = (summary.coverage ?? {});
243
+ const rules = Array.isArray(coverage.checkedRuleIds)
244
+ ? coverage.checkedRuleIds.length
245
+ : 0;
246
+ if (status === "ACCESSIBILITY_PAGE_SCAN_STATUS_FAILED") {
247
+ return {
248
+ clear: "unknown",
249
+ why: "this page failed to scan — it has zero findings because nothing was checked, not because nothing is wrong",
250
+ };
251
+ }
252
+ if (status !== "ACCESSIBILITY_PAGE_SCAN_STATUS_COMPLETED") {
253
+ return {
254
+ clear: "unknown",
255
+ why: `this page reports a status this version does not recognise (${status})`,
256
+ };
257
+ }
258
+ if (count === undefined) {
259
+ return { clear: "unknown", why: "this page reported no finding count" };
260
+ }
261
+ if (count > 0)
262
+ return { clear: false, why: `${count} finding(s) on this page` };
263
+ const categories = Array.isArray(coverage.checkedCategories)
264
+ ? coverage.checkedCategories
265
+ : [];
266
+ if (rules === 0 || categories.length === 0) {
267
+ return {
268
+ clear: "unknown",
269
+ why: "no findings, but coverage lists no executed rule or category — nothing confirms any relevant check ran on this page",
270
+ };
271
+ }
272
+ // CLEAR IS NEVER UNQUALIFIED. Coverage says which checks ran, not that the
273
+ // right ones did: a page with no `image-alt` finding whose contrast was
274
+ // never evaluated is clear FOR WHAT RAN and unknown for the rest. The plugin
275
+ // cannot invent the set of checks a given page ought to have had — a page
276
+ // with no images legitimately runs no alt-text rule — so instead of guessing
277
+ // an expectation it states the scope of its own claim, and `clearFor` is
278
+ // returned alongside so the claim cannot be read wider than it is.
279
+ return {
280
+ clear: true,
281
+ clearFor: { categories, ruleCount: rules },
282
+ why: `no findings among the ${rules} rule(s) that ran, covering ` +
283
+ `${categories.join(", ")}. Checks that did not run are not evidence: ` +
284
+ "this page is clear FOR THOSE CHECKS, not in general.",
285
+ };
286
+ }
287
+ /** The documented application errors, and what each one means for the CALLER.
288
+ *
289
+ * Taken from the published per-method error unions, not from what a request
290
+ * happened to return once. Two reasons they are handled here rather than left
291
+ * to throw: several of them carry a scan id the caller is supposed to POLL —
292
+ * buried in a thrown message, the only move left is to start a duplicate — and
293
+ * every one of them describes a state in which the site is NOT known to be
294
+ * clean. A raw error reaches an agent as "something went wrong"; these reach
295
+ * it as "here is what you do not know, and what to do next".
296
+ */
297
+ const ERROR_GUIDANCE = {
298
+ INVALID_SCAN_TARGET: "Wix rejected the target itself. Re-read the collection id from `wix_a11y_list_page_collections`, or check the page URL belongs to this site.",
299
+ SCAN_ALREADY_IN_PROGRESS: "A scan is already running for this target. Poll `wix_a11y_get_scan` with the `scanId` below. Do NOT start another scan.",
300
+ IDEMPOTENCY_KEY_REUSED: "This `attempt` label was already used for a DIFFERENT request. Choose a new `attempt` for a genuinely new scan; reuse one only to retry the exact same request.",
301
+ SCAN_TARGET_NOT_AVAILABLE: "The page or collection is not available on this site right now. Refresh `wix_a11y_list_page_collections` or confirm the page still exists.",
302
+ SCAN_SIZE_LIMIT_EXCEEDED: "This target is too large to scan in one run. NEVER report the site as fully scanned after this. Narrow the target — prefer page collections — and say which parts were left out.",
303
+ SCAN_STATE_UNKNOWN: "The scan's state could not be established. Read `wix_a11y_get_scan` with the `scanId` below until it is known. Do NOT start a replacement scan.",
304
+ ACCESSIBILITY_SCAN_NOT_FOUND: "No scan with that id. It may have expired — stored results are kept about six months. Start a new scan with a new `attempt`.",
305
+ SCAN_NOT_COMPLETE: "This scan has not reached a terminal state, so it has no results to read yet. Poll `wix_a11y_get_scan` first. Its progress numbers are NOT an audit.",
306
+ SCAN_RESULTS_NOT_AVAILABLE: "This scan is terminal but stored no readable results. Nothing here says the site is clean — start a new scan with a new `attempt`.",
307
+ SCAN_RESULTS_EXPIRED: "This scan's stored results have passed their retention. They cannot be read, and their absence is not a clean result. Start a new scan with a new `attempt`.",
308
+ RATE_LIMITED: "Run requests are limited to 5 per minute per site. Wait, then retry with the SAME `attempt` so the retry cannot create a second scan.",
309
+ };
310
+ /** Map a failed request onto the documented surface, or give up honestly.
311
+ *
312
+ * `undefined` means this version does not recognise the error — the caller
313
+ * rethrows, because inventing a reassuring explanation for an unknown failure
314
+ * is worse than surfacing the raw one. */
315
+ function explainError(err) {
316
+ if (!(err instanceof WixApiError))
317
+ return undefined;
318
+ const body = err.bodyPreview;
319
+ // THE FIELD THE CONTRACT DEFINES, not the first GUID in the body. An error
320
+ // body can carry a request id or an echoed idempotency key ahead of the scan
321
+ // id, and pointing the caller at one of those sends it to poll a scan that
322
+ // does not exist — on the very path whose whole purpose is "poll the
323
+ // existing one instead of starting another".
324
+ // THE ENVELOPE WIX ACTUALLY SENDS:
325
+ // { message, details: { applicationError: { code, description, data } } }
326
+ // Reading `details` itself found the wrapper, so the code was still matched
327
+ // by text search while `accessibilityScanId` stayed undefined — the caller
328
+ // got "poll the existing scan" and nothing to poll it with.
329
+ let details = {};
330
+ let structuredCode;
331
+ try {
332
+ const parsed = JSON.parse(body);
333
+ const appError = parsed.details?.applicationError;
334
+ if (typeof appError?.code === "string")
335
+ structuredCode = appError.code;
336
+ const holder = appError?.data ??
337
+ parsed.data;
338
+ if (holder !== null && typeof holder === "object" && !Array.isArray(holder)) {
339
+ details = holder;
340
+ }
341
+ }
342
+ catch {
343
+ // A body this version cannot parse yields no id — which the guidance for
344
+ // those codes already covers, rather than a guessed one.
345
+ }
346
+ const rawId = details.accessibilityScanId;
347
+ const scanId = typeof rawId === "string" && rawId !== "" ? rawId : undefined;
348
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
349
+ const sizes = {
350
+ detectedPageCount: num(details.detectedPageCount),
351
+ maxPageCount: num(details.maxPageCount),
352
+ };
353
+ // THE DECLARED CODE FIRST. A text search over the whole body also matches a
354
+ // code name quoted inside a human-readable description, which is how a
355
+ // "not found" message mentioning another state gets classified as it.
356
+ if (structuredCode !== undefined && ERROR_GUIDANCE[structuredCode] !== undefined) {
357
+ return {
358
+ code: structuredCode,
359
+ scanId,
360
+ ...sizes,
361
+ guidance: ERROR_GUIDANCE[structuredCode],
362
+ };
363
+ }
364
+ // A STRUCTURED CODE THIS VERSION DOES NOT KNOW ENDS THE CLASSIFICATION.
365
+ // Falling through to the text search let a NEW error whose description
366
+ // happens to name a known one be answered as that one — a genuine failure
367
+ // returned as `ok`, with recovery steps for a different problem. The status
368
+ // branches below still apply: they read the transport, not the body.
369
+ if (structuredCode === undefined) {
370
+ for (const code of Object.keys(ERROR_GUIDANCE)) {
371
+ if (code !== "RATE_LIMITED" && body.includes(code)) {
372
+ return { code, scanId, ...sizes, guidance: ERROR_GUIDANCE[code] };
373
+ }
374
+ }
375
+ }
376
+ if (err.status === 429) {
377
+ return { code: "RATE_LIMITED", guidance: ERROR_GUIDANCE.RATE_LIMITED };
378
+ }
379
+ if (err.status === 403 || body.includes("PERMISSION_DENIED")) {
380
+ return {
381
+ code: "PERMISSION_DENIED",
382
+ guidance: "This Wix identity cannot perform this call — starting a scan needs the `Manage Accessibility Scans` permission, which reading collections or stored results does not imply. Stop here: do NOT retry with another URL, target or site, and never present this as a site with no issues.",
383
+ };
384
+ }
385
+ return undefined;
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ const TargetParams = {
389
+ siteId: SiteIdParam,
390
+ fullSite: Type.Optional(Type.Boolean({
391
+ description: "Scan every supported page and the site-level rules.",
392
+ })),
393
+ pageId: Type.Optional(Type.String({ description: "Wix page ID of a single page to scan." })),
394
+ pageUrl: Type.Optional(Type.String({
395
+ description: "Absolute URL of a single page to scan. Prefer this for a generated page such as one product or one blog post.",
396
+ })),
397
+ collectionId: Type.Optional(Type.String({
398
+ description: "Collection ID from `wix_a11y_list_page_collections`, passed back UNCHANGED. Never invent one.",
399
+ })),
400
+ };
401
+ export function buildAccessibilityTools(client) {
402
+ return [
403
+ defineWixTool({
404
+ name: "wix_a11y_list_page_collections",
405
+ description: "The page collections this site currently supports — store products, " +
406
+ "blog posts, booking services and so on, depending on which Wix apps " +
407
+ "are installed. Call this BEFORE scanning a collection and pass the " +
408
+ "returned `collectionId` back unchanged: the list is a property of " +
409
+ "the site, not a fixed vocabulary, so a hardcoded or invented id is " +
410
+ "wrong by construction. Read-only. " +
411
+ "NOTE: being able to read this list does NOT prove the key may start " +
412
+ "a scan — starting one needs its own Wix permission.",
413
+ parameters: Type.Object({
414
+ siteId: SiteIdParam,
415
+ cursor: Type.Optional(Type.String()),
416
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
417
+ }),
418
+ run: async (params, c, signal) => c.request("GET", `${SCANS}/page-collections`, {
419
+ siteId: params.siteId,
420
+ signal,
421
+ query: compactQuery({
422
+ "paging.limit": params.limit,
423
+ "paging.cursor": params.cursor,
424
+ }),
425
+ }),
426
+ }, client),
427
+ defineWixTool({
428
+ name: "wix_a11y_run_scan",
429
+ description: "START an accessibility scan of exactly ONE target: the whole site " +
430
+ "(`fullSite: true`), one page (`pageId` or `pageUrl`), or one page " +
431
+ "collection (`collectionId`). Returns a scan id IMMEDIATELY — the " +
432
+ "scan runs asynchronously and this tool does NOT wait for it. Poll " +
433
+ "`wix_a11y_get_scan` after `suggestedPollIntervalSeconds` (or every " +
434
+ "5 seconds when it is absent) until the status is terminal. " +
435
+ "`attempt` NAMES THIS SCAN: the same `attempt` for the same target " +
436
+ "is a RETRY and can never create a second scan, so reuse it when a " +
437
+ "call fails or times out. Use a NEW `attempt` for a genuinely new " +
438
+ "scan — verifying a fix is a new scan, not a retry. " +
439
+ "Runs are limited to 5 per minute per site, and a multi-page scan " +
440
+ "covers at most 300 pages. Requires the Wix `Manage Accessibility " +
441
+ "Scans` permission; reading results does not imply it. " +
442
+ "This starts work on the live site but changes nothing on it.",
443
+ parameters: Type.Object({
444
+ ...TargetParams,
445
+ attempt: Type.String({
446
+ minLength: 1,
447
+ description: "Short label naming this intended scan, e.g. `baseline-2026-08` or `after-alt-text-fix`. Same label = retry, new label = new scan.",
448
+ }),
449
+ }),
450
+ run: async (params, c, signal) => {
451
+ const { target, key, label } = buildTarget(params);
452
+ // THE EFFECTIVE SITE, NOT THE PARAMETER. A first call that omits
453
+ // `siteId` and a retry that spells out the same default site are the
454
+ // SAME intended scan; deriving from the literal argument gave them
455
+ // two keys and let the retry start a duplicate.
456
+ const site = params.siteId ?? c.defaultSiteId;
457
+ try {
458
+ const resp = (await c.request("POST", `${SCANS}/run`, {
459
+ siteId: params.siteId,
460
+ signal,
461
+ body: {
462
+ target,
463
+ idempotencyKey: idempotencyKey(site, key, params.attempt),
464
+ },
465
+ }));
466
+ const scan = (resp?.accessibilityScan ?? undefined);
467
+ return {
468
+ scanId: scanIdOf(scan),
469
+ target: label,
470
+ ...scanCompleteness(scan),
471
+ // WHAT THE CAVEAT ASKS FOR, IN THE SAME RESPONSE. An idempotent
472
+ // retry can land on a scan that already failed; telling the
473
+ // caller to read `failure` while dropping it left it with an
474
+ // instruction it could not follow.
475
+ ...(scan?.failure !== undefined ? { failure: scan.failure } : {}),
476
+ suggestedPollIntervalSeconds: resp?.suggestedPollIntervalSeconds,
477
+ // AN IDEMPOTENT RETRY CAN RETURN A SCAN THAT IS ALREADY DONE.
478
+ // Saying so stops the caller from waiting on a finished scan.
479
+ howToProceed: scan?.status === "ACCESSIBILITY_SCAN_STATUS_FAILED"
480
+ ? // A TERMINAL SCAN IS NOT A READABLE ONE. An idempotent
481
+ // retry can hand back a scan that already failed, and
482
+ // sending the caller to read its results contradicts the
483
+ // caveat in the same response.
484
+ "This scan already FAILED and has no usable result. Read `failure`, fix the cause, then run again with a NEW `attempt`."
485
+ : TERMINAL.has(typeof scan?.status === "string" ? scan.status : "")
486
+ ? "This scan is already terminal — read its results now."
487
+ : "Poll `wix_a11y_get_scan` with the `scanId` above.",
488
+ };
489
+ }
490
+ catch (err) {
491
+ const known = explainError(err);
492
+ if (known === undefined)
493
+ throw err;
494
+ return { refused: known.code, ...known, target: label };
495
+ }
496
+ },
497
+ }, client),
498
+ defineWixTool({
499
+ name: "wix_a11y_get_scan",
500
+ description: "Lifecycle, progress and AGGREGATE TOTALS for one scan. This is the " +
501
+ "poll target: keep calling it while the status is QUEUED or RUNNING. " +
502
+ "It does NOT return pages or findings — read `wix_a11y_list_page_summaries` " +
503
+ "and `wix_a11y_list_findings` for those. " +
504
+ "`resultIsWhole` is the field that matters: `false` means part of " +
505
+ "the target was never checked, and no report may call the site " +
506
+ "checked in that case. Read-only.",
507
+ parameters: Type.Object({
508
+ siteId: SiteIdParam,
509
+ scanId: Type.String({ minLength: 1 }),
510
+ }),
511
+ run: async (params, c, signal) => {
512
+ let resp = null;
513
+ try {
514
+ resp = (await c.request("GET", `${SCANS}/${encodeURIComponent(params.scanId)}`, { siteId: params.siteId, signal }));
515
+ }
516
+ catch (err) {
517
+ // THE POLL TARGET, of all places. `SCAN_STATE_UNKNOWN` here means
518
+ // "keep reading this scan, do not start another" — as a raw error
519
+ // it read as "this scan is gone", and the next move is a duplicate.
520
+ const known = explainError(err);
521
+ if (known === undefined)
522
+ throw err;
523
+ return { refused: known.code, ...known };
524
+ }
525
+ const scan = (resp?.accessibilityScan ?? undefined);
526
+ return {
527
+ ...scanCompleteness(scan),
528
+ scan,
529
+ suggestedPollIntervalSeconds: resp?.suggestedPollIntervalSeconds,
530
+ };
531
+ },
532
+ }, client),
533
+ defineWixTool({
534
+ name: "wix_a11y_get_latest_scan",
535
+ description: "The most recently STORED scan for one target, WITHOUT starting a " +
536
+ "new one. Use it before running a scan when a recent result may " +
537
+ "already answer the question — and check `resultsExpirationDate` " +
538
+ "before reusing it: stored results are kept about six months, and a " +
539
+ "result from before the last site change describes the old site. " +
540
+ "Takes the same one-of-three target as `wix_a11y_run_scan`. Read-only.",
541
+ parameters: Type.Object(TargetParams),
542
+ run: async (params, c, signal) => {
543
+ const { target, label } = buildTarget(params);
544
+ let resp = null;
545
+ try {
546
+ resp = (await c.request("GET", `${SCANS}/latest`, {
547
+ siteId: params.siteId,
548
+ signal,
549
+ query: compactQuery(targetQuery(target)),
550
+ }));
551
+ }
552
+ catch (err) {
553
+ const known = explainError(err);
554
+ // "NO STORED SCAN" IS AN ANSWER, NOT A FAILURE. Wix says so with
555
+ // an application error, so the documented first-use path — ask for
556
+ // the latest, find none, start one — arrived as a raw API error
557
+ // and never reached the branch written for it.
558
+ if (known?.code === "ACCESSIBILITY_SCAN_NOT_FOUND") {
559
+ return {
560
+ target: label,
561
+ storedScan: false,
562
+ howToProceed: "No stored scan for this target. Start one with `wix_a11y_run_scan`.",
563
+ };
564
+ }
565
+ if (known === undefined)
566
+ throw err;
567
+ return { refused: known.code, ...known, target: label };
568
+ }
569
+ const scan = (resp?.accessibilityScan ?? undefined);
570
+ if (scan === undefined) {
571
+ return {
572
+ target: label,
573
+ storedScan: false,
574
+ howToProceed: "No stored scan for this target. Start one with `wix_a11y_run_scan`.",
575
+ };
576
+ }
577
+ return {
578
+ target: label,
579
+ storedScan: true,
580
+ ...scanCompleteness(scan),
581
+ completedDate: scan.completedDate,
582
+ resultsExpirationDate: scan.resultsExpirationDate,
583
+ scan,
584
+ };
585
+ },
586
+ }, client),
587
+ defineWixTool({
588
+ name: "wix_a11y_list_page_summaries",
589
+ description: "EVERY page the scan discovered — the clear ones and the ones that " +
590
+ "failed to scan, not only the ones with findings. Each page carries " +
591
+ "`clear`, which is `true`, `false` or `\"unknown\"`, never a bare " +
592
+ "count: a page is clear ONLY when it completed, has no findings, AND " +
593
+ "its coverage confirms the checks actually ran. A page that failed " +
594
+ "reports zero findings because nothing was read — that is `\"unknown\"`, " +
595
+ "and reporting it as clean is the mistake this tool exists to " +
596
+ "prevent. Page through the cursor to the end. Read-only.",
597
+ parameters: Type.Object({
598
+ siteId: SiteIdParam,
599
+ scanId: Type.String({ minLength: 1 }),
600
+ cursor: Type.Optional(Type.String()),
601
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
602
+ }),
603
+ run: async (params, c, signal) => {
604
+ let resp = null;
605
+ try {
606
+ resp = (await c.request("GET", `${SCANS}/${encodeURIComponent(params.scanId)}/page-summaries`, {
607
+ siteId: params.siteId,
608
+ signal,
609
+ query: compactQuery({
610
+ "paging.limit": params.limit,
611
+ "paging.cursor": params.cursor,
612
+ }),
613
+ }));
614
+ }
615
+ catch (err) {
616
+ const known = explainError(err);
617
+ if (known === undefined)
618
+ throw err;
619
+ return { refused: known.code, ...known };
620
+ }
621
+ const raw = Array.isArray(resp?.pageSummaries)
622
+ ? resp.pageSummaries
623
+ : [];
624
+ // FLAT, because that is what the description and the README promise.
625
+ // Nesting it under `verdict` meant an agent following the stated
626
+ // contract looked for `clear` and found nothing — and a page whose
627
+ // state is unknown is exactly the one it would then miss.
628
+ const pages = raw.map((s) => ({ ...s, ...pageVerdict(s) }));
629
+ const unknown = pages.filter((p) => p.clear === "unknown");
630
+ return {
631
+ pages,
632
+ pagingMetadata: resp?.pagingMetadata,
633
+ // COUNTED, NOT LEFT TO THE READER. A caller that skims the list
634
+ // and reports "no findings" is the failure mode; a number it has
635
+ // to mention is harder to skim past.
636
+ pagesWhoseStateIsUnknown: unknown.length,
637
+ ...(unknown.length > 0
638
+ ? {
639
+ caveat: `${unknown.length} of the ${pages.length} page(s) on this ` +
640
+ "cursor page could not be established as clear. Name them " +
641
+ "in the report; do not fold them into a clean total.",
642
+ }
643
+ : {}),
644
+ };
645
+ },
646
+ }, client),
647
+ defineWixTool({
648
+ name: "wix_a11y_list_findings",
649
+ description: "The actionable findings of a completed or partially completed scan, " +
650
+ "with severity, WCAG criteria, the affected page and element, and " +
651
+ "ordered remediation and verification steps. Filter by page, rule, " +
652
+ "severity or category instead of issuing one request per page. " +
653
+ "A FINDING THAT IS ABSENT IS NOT A RULE THAT PASSED — only the " +
654
+ "scan's `coverage` says which checks ran. When a finding carries " +
655
+ "`humanInputRequired`, ask the user for the content or design " +
656
+ "decision instead of guessing it. This tool reports; it never edits " +
657
+ "the site. Page through the cursor to the end. Read-only.",
658
+ parameters: Type.Object({
659
+ siteId: SiteIdParam,
660
+ scanId: Type.String({ minLength: 1 }),
661
+ pageId: Type.Optional(Type.String()),
662
+ pageUrl: Type.Optional(Type.String()),
663
+ ruleId: Type.Optional(Type.String()),
664
+ severity: Type.Optional(Type.String({
665
+ description: "One of ACCESSIBILITY_SEVERITY_CRITICAL, _SERIOUS, _MODERATE, _MINOR, _INFO.",
666
+ })),
667
+ categories: Type.Optional(Type.Array(Type.String(), {
668
+ description: "ACCESSIBILITY_FINDING_CATEGORY_ALTERNATIVE_TEXT, _HEADING_STRUCTURE, _COLOR_CONTRAST, _SCREEN_READER, _KEYBOARD, _OTHER. Combined with OR.",
669
+ })),
670
+ cursor: Type.Optional(Type.String()),
671
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
672
+ }),
673
+ run: async (params, c, signal) => {
674
+ // THE SAME ONE-OF AS A SCAN TARGET. A caller copying both identifiers
675
+ // off a page summary sent both, and Wix failed the WHOLE findings
676
+ // request — which reads like a page with nothing on it.
677
+ if (params.pageId !== undefined &&
678
+ params.pageId !== "" &&
679
+ params.pageUrl !== undefined &&
680
+ params.pageUrl !== "") {
681
+ throw new Error("Refusing to filter: `pageId` and `pageUrl` both given. The page " +
682
+ "filter takes exactly one identifier — pass whichever the page " +
683
+ "summary gave you, not both.");
684
+ }
685
+ const cats = params.categories ?? [];
686
+ const query = {
687
+ "paging.limit": params.limit,
688
+ "paging.cursor": params.cursor,
689
+ "filter.ruleId": params.ruleId,
690
+ "filter.severity": params.severity,
691
+ "filter.page.pageId": params.pageId,
692
+ "filter.page.pageUrl": params.pageUrl,
693
+ // REPEATED, NOT INDEXED. `filter.categories.0` is a different
694
+ // parameter from `filter.categories`, and a filter Wix ignores
695
+ // returns UNFILTERED findings that this tool then labels filtered.
696
+ ...(cats.length > 0 ? { "filter.categories": cats } : {}),
697
+ };
698
+ let resp = null;
699
+ try {
700
+ resp = (await c.request("GET", `${SCANS}/${encodeURIComponent(params.scanId)}/findings`, { siteId: params.siteId, signal, query: compactQuery(query) }));
701
+ }
702
+ catch (err) {
703
+ const known = explainError(err);
704
+ if (known === undefined)
705
+ throw err;
706
+ return { refused: known.code, ...known };
707
+ }
708
+ const findings = Array.isArray(resp?.findings)
709
+ ? resp.findings
710
+ : [];
711
+ // UNDER `fixGuidance`, NOT AT THE ROOT. Read from the root, this
712
+ // counter was structurally always zero — the one signal that tells
713
+ // an agent to STOP and ask a person, silently absent.
714
+ const needsHuman = findings.filter((f) => f.fixGuidance
715
+ ?.humanInputRequired === true).length;
716
+ // WHAT WAS ACTUALLY SENT. `compactQuery` drops empty strings, so a
717
+ // `severity: ""` produced an UNFILTERED request that this answer then
718
+ // described as filtered — and an empty result read as "none in that
719
+ // subset" instead of "none at all".
720
+ const isSet = (v) => v !== undefined && v !== "";
721
+ const filtered = isSet(params.ruleId) ||
722
+ isSet(params.severity) ||
723
+ isSet(params.pageId) ||
724
+ isSet(params.pageUrl) ||
725
+ cats.length > 0;
726
+ return {
727
+ findings,
728
+ pagingMetadata: resp?.pagingMetadata,
729
+ findingsNeedingAHumanDecision: needsHuman,
730
+ // A FILTERED EMPTY LIST IS NOT A CLEAN SITE, and neither is an
731
+ // unfiltered one on its own.
732
+ caveat: filtered
733
+ ? "This list is FILTERED. An empty or short result says nothing about the rest of the scan."
734
+ : "An absent finding is not a rule that passed — check the scan's `coverage` before calling anything clear.",
735
+ };
736
+ },
737
+ }, client),
738
+ ];
739
+ }
740
+ //# sourceMappingURL=accessibility.js.map