@autifyhq/aximo-cli 0.12.0 → 0.14.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.
@@ -3,7 +3,7 @@ import { request } from "../../client.js";
3
3
  import { handleApiError } from "../../errors.js";
4
4
  import { dryRunEnvelope, resolveJsonOverride } from "../../mutate.js";
5
5
  import { formatSuccess, printResult, validationError } from "../../output.js";
6
- import { optionalString, parseCaseFormat, parseCaseType, requireId } from "../../validate.js";
6
+ import { optionalString, parseCaseFormat, parseCaseType, parseJsonArray, parseJsonObject, requireId, } from "../../validate.js";
7
7
  export async function runCaseUpdate(options, deps) {
8
8
  let caseId;
9
9
  let body;
@@ -21,6 +21,7 @@ export async function runCaseUpdate(options, deps) {
21
21
  }
22
22
  else {
23
23
  try {
24
+ const desktopDevice = optionalString("desktopDevice", options.desktopDevice);
24
25
  body = {
25
26
  title: optionalString("title", options.title),
26
27
  description: optionalString("description", options.description),
@@ -28,6 +29,10 @@ export async function runCaseUpdate(options, deps) {
28
29
  type: parseCaseType(options.type),
29
30
  format: parseCaseFormat(options.format),
30
31
  startUrl: optionalString("startUrl", options.startUrl),
32
+ apiMobileConfig: parseJsonObject("mobileConfig", options.mobileConfig),
33
+ apiDesktopConfig: desktopDevice === undefined ? undefined : { userDeviceId: desktopDevice },
34
+ apiMultiDeviceConfig: parseJsonArray("multiDeviceConfig", options.multiDeviceConfig),
35
+ apiWebConfig: parseJsonObject("webConfig", options.webConfig),
31
36
  };
32
37
  }
33
38
  catch (err) {
@@ -54,11 +59,33 @@ export default defineCommand({
54
59
  args: {
55
60
  caseId: { type: "positional", required: true },
56
61
  title: { type: "string" },
57
- description: { type: "string" },
62
+ description: {
63
+ type: "string",
64
+ description: "Cannot clear; use --json '{\"description\": null}' for that",
65
+ },
58
66
  scenario: { type: "string" },
59
67
  type: { type: "string", description: "web | mobile | desktop | multi_device" },
60
68
  format: { type: "string", description: "gherkin | markdown | plain_text" },
61
- "start-url": { type: "string" },
69
+ "start-url": {
70
+ type: "string",
71
+ description: "Cannot clear; use --json '{\"startUrl\": null}' for that",
72
+ },
73
+ "mobile-config": {
74
+ type: "string",
75
+ description: "JSON object for apiMobileConfig ({deviceArn, deviceName, platform?, uploadArn?, uploadType?, appName?, bundleId?, apps?}); replaces the whole config. Clear with --json '{\"apiMobileConfig\":null}'",
76
+ },
77
+ "desktop-device": {
78
+ type: "string",
79
+ description: "Registered BYOD desktop device id (aximo device status) for apiDesktopConfig. Clear with --json '{\"apiDesktopConfig\":null}'",
80
+ },
81
+ "multi-device-config": {
82
+ type: "string",
83
+ description: "JSON array of 2-3 device entries ({kind: web|mobile, name, ...}) for apiMultiDeviceConfig; replaces the whole config. Clear with --json '{\"apiMultiDeviceConfig\":null}'",
84
+ },
85
+ "web-config": {
86
+ type: "string",
87
+ description: "JSON object for apiWebConfig ({hostnameOverrideIds: [...]}); replaces the whole config. Clear with --json '{\"apiWebConfig\":null}'",
88
+ },
62
89
  json: { type: "string", description: "Raw JSON request body (replaces individual flags)" },
63
90
  "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
64
91
  },
@@ -71,6 +98,10 @@ export default defineCommand({
71
98
  type: args.type,
72
99
  format: args.format,
73
100
  startUrl: args["start-url"],
101
+ mobileConfig: args["mobile-config"],
102
+ desktopDevice: args["desktop-device"],
103
+ multiDeviceConfig: args["multi-device-config"],
104
+ webConfig: args["web-config"],
74
105
  json: args.json,
75
106
  dryRun: args["dry-run"],
76
107
  }));
@@ -1,14 +1,5 @@
1
1
  import { type ClientDeps } from "../../client.js";
2
- import type { Envelope } from "../../types.js";
3
- interface UserDevice {
4
- id: string;
5
- name: string;
6
- platform: string;
7
- os?: string;
8
- status: string;
9
- lastSeenAt?: string;
10
- createdAt: string;
11
- }
2
+ import type { Envelope, UserDevice } from "../../types.js";
12
3
  export declare function runDeviceStatus(deps?: ClientDeps): Promise<Envelope<{
13
4
  devices: UserDevice[];
14
5
  }>>;
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 22 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 29 tools through tools/list with valid JSON Schema", async () => {
28
28
  const { client, cleanup } = await connect();
29
29
  try {
30
30
  const { tools } = await client.listTools();
@@ -37,17 +37,27 @@ describe("createMcpServer wiring", () => {
37
37
  "aximo_get_session_screenshots",
38
38
  "aximo_get_session_timeline",
39
39
  "aximo_list_cases",
40
+ "aximo_list_devices",
40
41
  "aximo_list_projects",
41
42
  "aximo_list_sessions",
43
+ "aximo_list_uploads",
44
+ "aximo_list_user_devices",
42
45
  "aximo_create_case",
43
46
  "aximo_update_case",
47
+ "aximo_archive_case",
48
+ "aximo_restore_case",
44
49
  "aximo_delete_case",
50
+ "aximo_run_case",
45
51
  "aximo_create_plan",
46
52
  "aximo_add_case_to_plan",
47
53
  "aximo_remove_case_from_plan",
48
54
  "aximo_list_plans",
49
55
  "aximo_get_plan",
50
56
  "aximo_update_plan",
57
+ "aximo_archive_plan",
58
+ "aximo_restore_plan",
59
+ "aximo_delete_plan",
60
+ "aximo_run_plan",
51
61
  "aximo_list_plan_runs",
52
62
  "aximo_get_plan_run",
53
63
  "aximo_save_session_as_case",
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { request } from "../../client.js";
3
+ import { ARCHIVE_FIRST_HINT, featureHint, isArchiveFirstConflict, withDetails, } from "../../errors.js";
3
4
  function ok(payload) {
4
5
  return {
5
6
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
@@ -23,18 +24,31 @@ async function call(fn) {
23
24
  catch (e) {
24
25
  if (!(e instanceof Error))
25
26
  return err(String(e));
26
- const status = e.status;
27
+ const { status, details } = e;
28
+ const message = withDetails(e.message, details);
27
29
  if (status === 401)
28
30
  return err("Unauthorized: check AXIMO_API_KEY", "Run `aximo configure`.");
29
31
  if (status === 403) {
30
- return err(e.message || "Forbidden", "API key may not have access to this resource.");
32
+ return err(message || "Forbidden", featureHint(e.message, details) ?? "API key may not have access to this resource.");
31
33
  }
32
34
  if (status === 404)
33
- return err(e.message || "Not found");
34
- if (status === 429)
35
- return err("Rate limited", "Aximo enforces a daily session limit.");
35
+ return err(message || "Not found");
36
+ if (status === 429) {
37
+ const retryAfter = e.retryAfter;
38
+ return err("Rate limited", retryAfter
39
+ ? `Back off and retry after ${retryAfter} seconds.`
40
+ : "Back off before retrying; Aximo rate-limits requests per organization.");
41
+ }
42
+ // Mirrors the 409 branch in handleApiError. Without this, an archive-first
43
+ // 409 falls through to the bare `HTTP 409: ...` case below and the agent is
44
+ // told to call a REST endpoint it has no way to reach, while the tool that
45
+ // would work goes unmentioned. Keyed on the details-enriched message, the
46
+ // same input handleApiError feeds the predicate, so the two cannot disagree.
47
+ if (status === 409) {
48
+ return err(message || "Conflict", isArchiveFirstConflict(message) ? ARCHIVE_FIRST_HINT : undefined);
49
+ }
36
50
  if (typeof status === "number")
37
- return err(`HTTP ${status}: ${e.message}`);
51
+ return err(`HTTP ${status}: ${message}`);
38
52
  if (e.message.startsWith("No API key configured")) {
39
53
  return err("API key not configured", "Run `aximo configure` and restart the MCP server.");
40
54
  }
@@ -54,6 +68,18 @@ function buildQuery(params) {
54
68
  }
55
69
  return out;
56
70
  }
71
+ // Mobile app selection accepted by the run triggers (multi-app, max 3).
72
+ // ARN fields are the encrypted tokens Aximo returns, never raw AWS ARNs.
73
+ const runAppsSchema = z
74
+ .array(z.object({
75
+ uploadArn: z
76
+ .string()
77
+ .describe("Encrypted upload token (from aximo_get_case config or `aximo upload`)."),
78
+ uploadType: z.enum(["own_app", "existing", "sample"]),
79
+ appName: z.string().nullable(),
80
+ bundleId: z.string().optional(),
81
+ }))
82
+ .max(3);
57
83
  export function defineTools(deps = {}) {
58
84
  return [
59
85
  {
@@ -66,6 +92,53 @@ export function defineTools(deps = {}) {
66
92
  return res;
67
93
  }),
68
94
  },
95
+ {
96
+ name: "aximo_list_devices",
97
+ title: "List Device Farm devices",
98
+ description: "List the cloud (AWS Device Farm) mobile devices available to your organization — not BYOD devices (see aximo_list_user_devices). Each device's arn is the encrypted token to pass as deviceArn, and its name as deviceName, in a case's apiMobileConfig or multi-device mobile entries (aximo_update_case) and in per-run overrides (aximo_run_case, aximo_run_plan). Filter by platform (ANDROID | IOS) or availableOnly.",
99
+ inputSchema: {
100
+ platform: z.enum(["ANDROID", "IOS"]).optional(),
101
+ availableOnly: z
102
+ .boolean()
103
+ .optional()
104
+ .describe("Only devices that are currently free to run on."),
105
+ },
106
+ handler: async ({ platform, availableOnly }) => call(async () => {
107
+ return await request("/api/devices", { query: { platform, availableOnly: availableOnly || undefined } }, deps);
108
+ }),
109
+ },
110
+ {
111
+ name: "aximo_list_user_devices",
112
+ title: "List registered BYOD devices",
113
+ description: "List the BYOD (bring-your-own) desktop and mobile devices registered with `aximo device register` by the owner of this API key — other members' devices are not included. Each device's id is the userDeviceId to pass in a case's apiDesktopConfig or multi-device local entries (aximo_update_case) and in per-run overrides. status shows whether the device is online. Returns 404 when BYOD is not enabled for the organization.",
114
+ inputSchema: {},
115
+ handler: async () => call(async () => {
116
+ try {
117
+ return await request("/api/devices/user", {}, deps);
118
+ }
119
+ catch (e) {
120
+ // The route answers a bare 404 when the org has no BYOD feature.
121
+ if (e.status === 404) {
122
+ throw Object.assign(new Error("BYOD is not enabled for this organization"), {
123
+ status: 403,
124
+ details: { feature: "byodEnabled" },
125
+ });
126
+ }
127
+ throw e;
128
+ }
129
+ }),
130
+ },
131
+ {
132
+ name: "aximo_list_uploads",
133
+ title: "List app uploads",
134
+ description: "List the mobile app binaries (APK / IPA) uploaded to your organization. Each upload's uploadArn is the encrypted token to pass as uploadArn in a case's apiMobileConfig or multi-device mobile entries (aximo_update_case) and in per-run overrides; only uploads with status succeeded can be installed. identifier is the app's bundleId. Filter by fileType (ANDROID_APP | IOS_APP).",
135
+ inputSchema: {
136
+ fileType: z.enum(["ANDROID_APP", "IOS_APP"]).optional(),
137
+ },
138
+ handler: async ({ fileType }) => call(async () => {
139
+ return await request("/api/uploads", { query: { fileType } }, deps);
140
+ }),
141
+ },
69
142
  {
70
143
  name: "aximo_list_sessions",
71
144
  title: "List sessions",
@@ -215,15 +288,36 @@ export function defineTools(deps = {}) {
215
288
  {
216
289
  name: "aximo_update_case",
217
290
  title: "Update test case",
218
- description: "Update a test case's title, description, scenario, type, format, or startUrl (pass null to clear startUrl). Omitted fields are left unchanged; at least one field is required. Per-target device configuration cannot be changed over the API, and changing type does not create or clear it (e.g. switching to mobile leaves apiMobileConfig null until set in the dashboard).",
291
+ description: "Update a test case's title, description, scenario, type, format, or startUrl (pass null to clear description or startUrl; an empty description string is stored as-is, not a clear). Omitted fields are left unchanged; at least one field is required. Pass apiMobileConfig / apiDesktopConfig / apiMultiDeviceConfig / apiWebConfig to set the case's default targets — the same shapes aximo_get_case returns; null clears one, omit leaves it unchanged. Each config value is replaced whole — send the complete config object you want stored (never a partial), and omit a config key entirely to leave that target unchanged. deviceArn = aximo_list_devices devices[].arn and deviceName = devices[].name; uploadArn = aximo_list_uploads uploads[].uploadArn (status succeeded), bundleId = uploads[].identifier, uploadType is usually \"own_app\"; userDeviceId = aximo_list_user_devices devices[].id. apiMobileConfig.platform is ANDROID|IOS; multi-device mobilePlatform is android|ios. There is no top-level userDeviceId/deviceArn here — they live inside the config objects. Desktop and multi-device require the organization's BYOD / multi-device feature. You can set type in the same call.",
219
292
  inputSchema: {
220
293
  caseId: z.string().uuid(),
221
294
  title: z.string().min(1).max(500).optional(),
222
- description: z.string().optional(),
295
+ description: z.string().nullable().optional(),
223
296
  scenario: z.string().min(1).optional(),
224
297
  type: z.enum(["web", "mobile", "desktop", "multi_device"]).optional(),
225
298
  format: z.enum(["gherkin", "markdown", "plain_text"]).optional(),
226
299
  startUrl: z.string().url().nullable().optional(),
300
+ apiMobileConfig: z
301
+ .record(z.unknown())
302
+ .nullable()
303
+ .optional()
304
+ .describe("{ deviceArn, deviceName, platform?: ANDROID|IOS, uploadArn?, uploadType?: own_app|existing|sample, appName?, bundleId?, apps?: [{ uploadArn, uploadType, appName, bundleId? }] (max 3) }. null clears."),
305
+ apiDesktopConfig: z
306
+ .object({ userDeviceId: z.string().uuid() })
307
+ .passthrough()
308
+ .nullable()
309
+ .optional()
310
+ .describe("{ userDeviceId } of a registered BYOD desktop device (aximo_list_user_devices); name/platform/os are resolved server-side. null clears."),
311
+ apiMultiDeviceConfig: z
312
+ .array(z.record(z.unknown()))
313
+ .nullable()
314
+ .optional()
315
+ .describe('2-3 entries, each { kind: "web", name, startUrl?, userDeviceId? } or { kind: "mobile", name, mobilePlatform: android|ios, deviceArn?, deviceName?, userDeviceId?, uploadArn?, uploadType?, appName?, bundleId?, apps? }. null clears.'),
316
+ apiWebConfig: z
317
+ .record(z.unknown())
318
+ .nullable()
319
+ .optional()
320
+ .describe("{ hostnameOverrideIds?: string[] }. null clears."),
227
321
  },
228
322
  handler: async ({ caseId, ...body }) => call(async () => {
229
323
  if (Object.keys(body).length === 0) {
@@ -232,10 +326,32 @@ export function defineTools(deps = {}) {
232
326
  return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "PATCH", body }, deps);
233
327
  }),
234
328
  },
329
+ {
330
+ name: "aximo_archive_case",
331
+ title: "Archive test case",
332
+ description: "Archive a test case: hidden from lists, plans, and new runs; run history kept. Idempotent. Required before aximo_delete_case can permanently delete it.",
333
+ inputSchema: {
334
+ caseId: z.string().uuid(),
335
+ },
336
+ handler: async ({ caseId }) => call(async () => {
337
+ return await request(`/api/cases/${encodeURIComponent(caseId)}/archive`, { method: "POST" }, deps);
338
+ }),
339
+ },
340
+ {
341
+ name: "aximo_restore_case",
342
+ title: "Restore test case",
343
+ description: "Restore an archived test case: it returns to lists, directory counts, and plan membership. Idempotent. A case whose directory was deleted while archived returns to the project root.",
344
+ inputSchema: {
345
+ caseId: z.string().uuid(),
346
+ },
347
+ handler: async ({ caseId }) => call(async () => {
348
+ return await request(`/api/cases/${encodeURIComponent(caseId)}/restore`, { method: "POST" }, deps);
349
+ }),
350
+ },
235
351
  {
236
352
  name: "aximo_delete_case",
237
353
  title: "Delete test case",
238
- description: "Permanently delete a test case. It is removed from any plans it belongs to; past run sessions are kept but detached. This cannot be undone.",
354
+ description: "Permanently delete an already-archived test case. Archive it first with aximo_archive_case. Requires an organization admin or owner API key. It is removed from any plans it belongs to, and its rows in past plan runs go with it, so those runs list fewer cases than their stored totals; the agent sessions themselves are kept but detached. This cannot be undone.",
239
355
  inputSchema: {
240
356
  caseId: z.string().uuid(),
241
357
  },
@@ -243,6 +359,45 @@ export function defineTools(deps = {}) {
243
359
  return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
244
360
  }),
245
361
  },
362
+ {
363
+ name: "aximo_run_case",
364
+ title: "Run test case",
365
+ description: "Start a run of a saved test case and return the new sessionId. The case must be apiEnabled (aximo_get_case shows this). All fields except caseId are per-run overrides of the case's stored per-target configuration: omit them to run with the saved defaults. Poll aximo_get_session with the returned sessionId to follow the run; wait at least 5 seconds between polls.",
366
+ inputSchema: {
367
+ caseId: z.string().uuid(),
368
+ model: z
369
+ .string()
370
+ .optional()
371
+ .describe("Agent model override (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
372
+ hostnameOverrideIds: z
373
+ .array(z.string().uuid())
374
+ .optional()
375
+ .describe("Web cases: hostname override ids to apply for this run."),
376
+ userDeviceId: z
377
+ .string()
378
+ .uuid()
379
+ .optional()
380
+ .describe("A user-owned (BYOD) device id; overrides the device bound on the case."),
381
+ deviceArn: z
382
+ .string()
383
+ .optional()
384
+ .describe("Mobile cases: encrypted device token; overrides the saved device."),
385
+ deviceName: z.string().optional(),
386
+ platform: z.enum(["ANDROID", "IOS"]).optional(),
387
+ uploadArn: z
388
+ .string()
389
+ .optional()
390
+ .describe("Mobile cases: encrypted app upload token; overrides the saved app."),
391
+ uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
392
+ appName: z.string().optional(),
393
+ apps: runAppsSchema
394
+ .optional()
395
+ .describe("Mobile multi-app runs: up to 3 apps to install; overrides the saved apps and takes precedence over the single-app uploadArn/uploadType/appName fields."),
396
+ },
397
+ handler: async ({ caseId, ...body }) => call(async () => {
398
+ return await request(`/api/triggers/cases/${encodeURIComponent(caseId)}/run`, { method: "POST", body }, deps);
399
+ }),
400
+ },
246
401
  {
247
402
  name: "aximo_create_plan",
248
403
  title: "Create test plan",
@@ -323,6 +478,87 @@ export function defineTools(deps = {}) {
323
478
  return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "PATCH", body }, deps);
324
479
  }),
325
480
  },
481
+ {
482
+ name: "aximo_archive_plan",
483
+ title: "Archive test plan",
484
+ description: "Archive a test plan: hidden from lists and blocked from new runs; its cases, run history, and schedule are all kept. The scheduler skips archived plans, so restoring one resumes its normal cadence. Idempotent. Required before aximo_delete_plan can permanently delete it.",
485
+ inputSchema: {
486
+ planId: z.string().uuid(),
487
+ },
488
+ handler: async ({ planId }) => call(async () => {
489
+ return await request(`/api/plans/${encodeURIComponent(planId)}/archive`, { method: "POST" }, deps);
490
+ }),
491
+ },
492
+ {
493
+ name: "aximo_restore_plan",
494
+ title: "Restore test plan",
495
+ description: "Restore an archived test plan: it returns to lists and can be run again, and its schedule resumes exactly as configured. Idempotent.",
496
+ inputSchema: {
497
+ planId: z.string().uuid(),
498
+ },
499
+ handler: async ({ planId }) => call(async () => {
500
+ return await request(`/api/plans/${encodeURIComponent(planId)}/restore`, { method: "POST" }, deps);
501
+ }),
502
+ },
503
+ {
504
+ name: "aximo_delete_plan",
505
+ title: "Delete test plan",
506
+ description: "Permanently delete an already-archived test plan. Archive it first with aximo_archive_plan. Requires an organization admin or owner API key. Its member cases are kept, and so is its run history: those runs are detached from the plan and remain readable as standalone results. This cannot be undone.",
507
+ inputSchema: {
508
+ planId: z.string().uuid(),
509
+ },
510
+ handler: async ({ planId }) => call(async () => {
511
+ return await request(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE" }, deps);
512
+ }),
513
+ },
514
+ {
515
+ name: "aximo_run_plan",
516
+ title: "Run test plan",
517
+ description: "Start a run of a test plan (all its cases) and return the new planRunId, totalCases, and the effective executionMode. The plan must be apiEnabled (aximo_get_plan shows this). All fields except planId are per-run overrides of the plan's stored settings: omit them to run with the saved defaults. Poll aximo_get_plan_run with the returned planRunId to follow the run; wait at least 5 seconds between polls.",
518
+ inputSchema: {
519
+ planId: z.string().uuid(),
520
+ model: z
521
+ .string()
522
+ .optional()
523
+ .describe("Agent model override (e.g. 'sonnet', 'haiku', 'auto'). Server-validated."),
524
+ executionMode: z
525
+ .enum(["parallel", "sequential"])
526
+ .optional()
527
+ .describe("Override the plan's stored Execution setting for this run only."),
528
+ mobileConfig: z
529
+ .object({
530
+ deviceArn: z.string().describe("Encrypted device token."),
531
+ deviceName: z.string(),
532
+ platform: z.enum(["ANDROID", "IOS"]).optional(),
533
+ uploadArn: z.string().optional().describe("Encrypted app upload token."),
534
+ uploadType: z.enum(["own_app", "existing", "sample"]).optional(),
535
+ appName: z.string().optional(),
536
+ apps: runAppsSchema
537
+ .optional()
538
+ .describe("Takes precedence over the single-app uploadArn/uploadType/appName fields."),
539
+ })
540
+ .optional()
541
+ .describe("Device + app for the plan's mobile cases; overrides the plan's stored config."),
542
+ desktopConfig: z
543
+ .object({
544
+ devices: z.array(z.object({ userDeviceId: z.string().uuid() })).min(1),
545
+ })
546
+ .optional()
547
+ .describe("User-owned (BYOD) desktop devices to run desktop cases on; overrides the plan's stored desktop config."),
548
+ variables: z
549
+ .array(z.object({
550
+ name: z
551
+ .string()
552
+ .describe("Uppercase letters, digits, underscores; must start with a letter."),
553
+ value: z.string(),
554
+ }))
555
+ .optional()
556
+ .describe("Per-run variable values merged over the plan's stored variables by name; never persisted on the plan."),
557
+ },
558
+ handler: async ({ planId, ...body }) => call(async () => {
559
+ return await request(`/api/triggers/plans/${encodeURIComponent(planId)}/run`, { method: "POST", body }, deps);
560
+ }),
561
+ },
326
562
  {
327
563
  name: "aximo_list_plan_runs",
328
564
  title: "List plan runs",