mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
ef955e604d
## Summary
Adds the missing **failure codes** across the tool-server and converts
raw
`throw new Error(...)` sites into classified `FailureError`s, so
operational
failures land in named telemetry buckets instead of falling through to
the
generic `REGISTRY_TOOL_FAILURE_UNCLASSIFIED` bucket.
- **39 new `FAILURE_CODES`** plus a new `"electron"` entry in
`FAILURE_COMMANDS`.
- Throw sites migrated to `FailureError`, each carrying
`error_code` + `failure_stage` + `failure_area` + `error_kind`, and
where
relevant `failure_command` / `network_failure` / subprocess metadata
(`failure_exit_code` / `failure_signal` / `failure_spawn_code`).
## Addressing review feedback
The review found that several proposed codes were thrown only on paths
that are
caught / reformatted before the telemetry boundary (a code only buckets
if its
`FailureError` propagates out of a registered tool's `execute`), so they
could
never bucket a real failure. Those are removed rather than left as dead
codes:
- **Dropped
`VEGA_PAGE_SOURCE_{FETCH_FAILED,HTTP_ERROR,RPC_ERROR,TIMEOUT,PARSE_FAILED}`**
- `describeVega` deliberately swallows every toolkit failure (empty tree
+
relaunch hint) except `MultipleVegaDevicesError`, so these never reach
`toolFailed`. The throw sites revert to plain `Error` (behaviour
unchanged).
- **Dropped `CHROMIUM_CLIPBOARD_FAILED`** (the chromium-server Express
route
reformats the error to a 500 body; the image-copy path has no production
caller) and **`CHROMIUM_BUTTON_UNSUPPORTED`** (the WS `button` handler
reformats it; the registered `button` tool is apple+android only).
- **Fixed the `CHROMIUM_CDP_INVALID_RESPONSE` gap** - a reachable
HTTP-200 with a
non-JSON body made `res.json()` reject with a raw `SyntaxError` that
escaped
`fetchJson` unclassified; it is now classified
`CHROMIUM_CDP_INVALID_RESPONSE`
(matching the non-2xx and missing-`webSocketDebuggerUrl` paths).
`AbortError`
still surfaces untouched.
- **Unified two codes that carried two `error_kind`s** - both
`REACT_PROFILER_NO_ACTIVE_SESSION` sites and both
`PROFILER_NATIVE_TRACE_MISSING`
sites now use `not_found` (a missing session / a trace file missing on
disk),
keeping telemetry keyed on `error_code` from splitting by platform.
- **Reverted an out-of-scope edit** - the in-flight Vega-boot coalescing
Map-key
separator is back to its original NUL byte.
- **Added `failure-classification.test.ts` coverage** - expanded from 7
to 17
pinned codes, reading each back exactly how telemetry does
(`getFailureSignal(err).error_code`) across the chromium CDP discovery
family
(incl. the malformed-200 regression guard), vega input, and
native-profiler
metadata / stack-query.
This branch also merges the latest `main`; the keyboard tool was
refactored on
`main` into per-platform modules + a `createKeyboardTool` factory, so
the
`KEYBOARD_KEY_UNSUPPORTED` / `KEYBOARD_CHARACTER_UNSUPPORTED`
classifications were
re-homed onto its new throw sites (`platforms/chromium.ts`,
`simulator-server-keys.ts`; the vega path keeps its classification in
`vega-input.ts`).
## What changed
By area (the 39 codes):
- **Chromium / CDP (16)** - discovery, input, viewport, storage, tabs,
describe,
and Electron boot: `CHROMIUM_CDP_UNREACHABLE`,
`CHROMIUM_CDP_NO_PAGE_TARGET`,
`CHROMIUM_CDP_INVALID_RESPONSE`, `CHROMIUM_PARAM_INVALID`,
`CHROMIUM_INPUT_INVALID`, `CHROMIUM_VIEWPORT_READ_FAILED`,
`CHROMIUM_SCREENSHOT_FAILED`, `CHROMIUM_STORAGE_EVAL_FAILED`,
`CHROMIUM_TAB_NOT_FOUND`, `CHROMIUM_TAB_OPEN_FAILED`,
`CHROMIUM_DESCRIBE_FAILED`,
`CHROMIUM_DEVICE_ID_INVALID`, `CHROMIUM_ELECTRON_APP_PATH_INVALID`,
`CHROMIUM_ELECTRON_SPAWN_FAILED`, `CHROMIUM_ELECTRON_CDP_TIMEOUT`,
`CHROMIUM_ELECTRON_EXITED_BEFORE_READY`.
- Unreachable debug ports are distinguished from reached-but-malformed
responses; the `undici`-wrapped `ECONN*` code is read off `err.cause`
and
mapped to a precise `network_failure` instead of always reporting
`connection_refused`.
- **Vega (8)** - device discovery/registration, boot timeouts, image
lookup,
input, and screenshot: `VEGA_DEVICE_NOT_FOUND`,
`VEGA_DEVICE_NOT_REGISTERED`,
`VEGA_MULTIPLE_DEVICES`, `VEGA_BOOT_TIMEOUT`, `VEGA_IMAGE_NOT_FOUND`,
`VEGA_ALREADY_RUNNING`, `VEGA_SCREENSHOT_FAILED`, `VEGA_TEXT_INVALID`.
- `MultipleVegaDevicesError` extends `FailureError` so callers that
re-throw it
(e.g. `describe`) surface a classified signal.
- **Native profiler (8)** - perfetto start failures (mirroring the iOS
xctrace
set), app-process detection, the metadata sidecar, and the shared
analyze
guard: `NATIVE_PROFILER_PERFETTO_PROCESS_ERROR`,
`NATIVE_PROFILER_PERFETTO_READY_TIMEOUT`,
`NATIVE_PROFILER_PERFETTO_READY_EXITED`,
`NATIVE_PROFILER_APP_PROCESS_NOT_FOUND`,
`NATIVE_PROFILER_NO_EXPORTED_TRACE`,
`NATIVE_PROFILER_TRACE_TEMPLATE_MISSING`,
`PROFILER_NATIVE_METADATA_MISSING`,
`PROFILER_NATIVE_METADATA_INVALID`.
- The iOS `analyze` guard moves off `PROFILER_NATIVE_TRACE_MISSING` onto
the
shared `NATIVE_PROFILER_NO_EXPORTED_TRACE` so the same logical failure
isn't
split by platform; `PROFILER_NATIVE_TRACE_MISSING` now means strictly a
trace
file missing on disk (and both of its sites use `error_kind:
not_found`).
- **Android / simulator / screenshot-diff (3)** -
`ANDROID_UIAUTOMATOR_CAPTURE_FAILED`, `SIMULATOR_SCREENSHOT_FAILED`,
`SCREENSHOT_DIFF_INPUT_INVALID`.
- **Flows / keyboard (4)** - `FLOW_PROJECT_ROOT_INVALID` (split from
`_REQUIRED`,
which now means strictly "no root set yet"), `FLOW_NAME_INVALID`,
`KEYBOARD_KEY_UNSUPPORTED`, `KEYBOARD_CHARACTER_UNSUPPORTED`.
`error_kind` is reserved consistently: `validation` for checks against
caller
input or a schema we own; `unknown` for malformed output from an
uncontrolled
source (renderer main world, toolkit RPC); `network` with a
`network_failure`
class for transport and reached-but-malformed responses; `not_found` for
a
missing resource / session / on-disk artifact.
## Testing
- `tsc --build` clean - `error_kind` / `failure_area` /
`failure_command` /
`network_failure` and every `FAILURE_CODES.X` are typed, so the compiler
enforces structural validity of every signal.
- Unit tests: registry **83/83**, tool-server green (the one red
`await-ui-element` case is a pre-existing local-only timing flake
unrelated to
this change - it fails identically on `main` and passes in CI).
- `failure-classification.test.ts` pins **17** codes, reading each back
via
`getFailureSignal(err).error_code`; the `CHROMIUM_CDP_INVALID_RESPONSE`
malformed-200 fix is reproduced against a local HTTP server.
- `prettier --check` and `eslint` clean on all changed files.
## Notes
- No new codes are orphaned - every added `FAILURE_CODES` entry has at
least one
throw site that can reach the telemetry boundary.
- A handful of internal-invariant throws (SQL-template/identifier
guards,
Tesseract-TSV parsing, unreachable `default:` branches) are
intentionally left
as plain `Error`s - they represent programmer/tooling errors, not
user-reachable failure modes, so they don't warrant telemetry codes.
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
import { createServer, type Server } from "node:http";
|
|
import { describe, it, expect, afterEach } from "vitest";
|
|
import { FAILURE_CODES, getFailureSignal, type FailureCode } from "@argent/registry";
|
|
|
|
import {
|
|
setActiveProjectRoot,
|
|
clearActiveProjectRoot,
|
|
assertSafeFlowName,
|
|
getFlowPath,
|
|
} from "../src/tools/flows/flow-utils";
|
|
import type { DeviceInfo, Registry } from "@argent/registry";
|
|
import { makeChromiumImpl } from "../src/tools/keyboard/platforms/chromium";
|
|
import { chromiumCdpBlueprint } from "../src/blueprints/chromium-cdp";
|
|
import { ensureCdpReachable, discoverPrimaryPage } from "../src/chromium-server/cdp-session";
|
|
import { readViewport } from "../src/chromium-server/viewport";
|
|
import { captureScreenshot } from "../src/chromium-server/screenshot";
|
|
import type { CDPClient } from "../src/utils/debugger/cdp-client";
|
|
import { injectVegaText, injectVegaNamedKey } from "../src/utils/vega-input";
|
|
import {
|
|
readAndroidNativeProfilerMetadata,
|
|
androidNativeProfilerMetadataPath,
|
|
} from "../src/utils/android-profiler/session-metadata";
|
|
import { profilerStackQueryTool } from "../src/tools/profiler/query/profiler-stack-query";
|
|
import { tmpdir } from "node:os";
|
|
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
/**
|
|
* Telemetry only reads a FailureError's signal when it propagates out of a tool's
|
|
* `execute` (the registry's `toolFailed` event walks the cause chain via
|
|
* `getFailureSignal`). These tests pin the `error_code` of representative
|
|
* newly-classified throw sites — across the flows, keyboard, chromium (blueprint
|
|
* factory + CDP discovery), vega input, and native-profiler (metadata + stack
|
|
* query) areas — so a regression that drops or mis-codes a classification is
|
|
* caught in CI rather than only in a one-off manual run.
|
|
*
|
|
* Each site here is reachable on a real telemetry path (a registered tool's
|
|
* execute, or a helper it calls un-swallowed) and driven with only a bare stub,
|
|
* a local HTTP server, or a temp file — no live device. Families that require a
|
|
* live CDP WebSocket or a booted device (electron boot, tab-open, vega adb/CLI
|
|
* paths) are exercised in the end-to-end suites, not here.
|
|
*/
|
|
|
|
/** Await a promise expected to reject and return the thrown error. */
|
|
async function captureError(p: Promise<unknown>): Promise<unknown> {
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
return e;
|
|
}
|
|
throw new Error("expected the call to throw, but it resolved");
|
|
}
|
|
|
|
/** Run a synchronous function expected to throw and return the thrown error. */
|
|
function captureSync(fn: () => unknown): unknown {
|
|
try {
|
|
fn();
|
|
} catch (e) {
|
|
return e;
|
|
}
|
|
throw new Error("expected the call to throw, but it returned");
|
|
}
|
|
|
|
function expectCode(err: unknown, code: FailureCode): void {
|
|
expect(getFailureSignal(err)?.error_code).toBe(code);
|
|
}
|
|
|
|
const openServers: Server[] = [];
|
|
|
|
/** Start a local HTTP server on an ephemeral port and return its port. */
|
|
function startServer(handler: (path: string, res: import("node:http").ServerResponse) => void) {
|
|
return new Promise<number>((resolve) => {
|
|
const srv = createServer((req, res) => handler(req.url ?? "/", res));
|
|
openServers.push(srv);
|
|
srv.listen(0, "127.0.0.1", () => resolve((srv.address() as { port: number }).port));
|
|
});
|
|
}
|
|
|
|
afterEach(async () => {
|
|
// setActiveProjectRoot mutates module state; reset so cases don't leak.
|
|
clearActiveProjectRoot();
|
|
// Tear down any local servers a case spun up.
|
|
await Promise.all(openServers.splice(0).map((s) => new Promise<void>((r) => s.close(() => r()))));
|
|
});
|
|
|
|
describe("flow-utils classifications", () => {
|
|
it("classifies a relative project_root as FLOW_PROJECT_ROOT_INVALID", () => {
|
|
expectCode(
|
|
captureSync(() => setActiveProjectRoot("relative/path")),
|
|
FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID
|
|
);
|
|
});
|
|
|
|
it("classifies a project_root containing '..' as FLOW_PROJECT_ROOT_INVALID", () => {
|
|
expectCode(
|
|
captureSync(() => setActiveProjectRoot("/a/../b")),
|
|
FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID
|
|
);
|
|
});
|
|
|
|
it("classifies path resolution with no active project_root as FLOW_PROJECT_ROOT_REQUIRED", () => {
|
|
// No active root → getFlowPath → getFlowsDir → requireActiveProjectRoot throws.
|
|
expectCode(
|
|
captureSync(() => getFlowPath("valid-name")),
|
|
FAILURE_CODES.FLOW_PROJECT_ROOT_REQUIRED
|
|
);
|
|
});
|
|
|
|
it("classifies an unsafe flow name as FLOW_NAME_INVALID", () => {
|
|
expectCode(
|
|
captureSync(() => assertSafeFlowName("bad name!")),
|
|
FAILURE_CODES.FLOW_NAME_INVALID
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("keyboard classifications", () => {
|
|
// The chromium typing path lives in makeChromiumImpl's handler, which resolves
|
|
// the CDP service then validates the key/char before touching it. A stub
|
|
// registry (service resolves to a bare object) reaches the classified throw
|
|
// without a live browser.
|
|
const registry = { resolveService: async () => ({}) } as unknown as Registry;
|
|
const { handler } = makeChromiumImpl(registry);
|
|
const device = {
|
|
id: "chromium-cdp-9222",
|
|
platform: "chromium",
|
|
kind: "app",
|
|
} as unknown as DeviceInfo;
|
|
|
|
it("classifies an unknown named key as KEYBOARD_KEY_UNSUPPORTED", async () => {
|
|
expectCode(
|
|
await captureError(handler({}, { udid: device.id, key: "not-a-key" }, device)),
|
|
FAILURE_CODES.KEYBOARD_KEY_UNSUPPORTED
|
|
);
|
|
});
|
|
|
|
it("classifies an unmappable character as KEYBOARD_CHARACTER_UNSUPPORTED", async () => {
|
|
expectCode(
|
|
await captureError(handler({}, { udid: device.id, text: "\u{1F600}" }, device)),
|
|
FAILURE_CODES.KEYBOARD_CHARACTER_UNSUPPORTED
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("chromium blueprint factory classifications", () => {
|
|
const device = (id: string) => ({ id, platform: "chromium" as const, kind: "app" as const });
|
|
|
|
it("classifies a malformed chromium device id as CHROMIUM_DEVICE_ID_INVALID", async () => {
|
|
// A well-formed id that agrees between options and URN passes the (uncoded)
|
|
// wiring guards, then the port parse fails — exercising the reachable
|
|
// CHROMIUM_DEVICE_ID_INVALID branch.
|
|
expectCode(
|
|
await captureError(
|
|
chromiumCdpBlueprint.factory({}, device("chromium-cdp-bogus"), {
|
|
device: device("chromium-cdp-bogus"),
|
|
})
|
|
),
|
|
FAILURE_CODES.CHROMIUM_DEVICE_ID_INVALID
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("chromium CDP discovery classifications", () => {
|
|
// These drive the discovery helpers against a local HTTP server standing in
|
|
// for the debug port, exercising the reachable throw sites the way the tools
|
|
// do (ensureCdpReachable / discoverPrimaryPage bubble out of chromium tool
|
|
// execute → toolFailed → getFailureSignal).
|
|
|
|
it("classifies a 200 with a non-JSON body as CHROMIUM_CDP_INVALID_RESPONSE", async () => {
|
|
// A non-CDP service squatting the debug port answers 200 but the body can't
|
|
// be parsed — the reached-but-malformed case (regression guard for the
|
|
// res.json() parse that used to escape unclassified to the generic bucket).
|
|
const port = await startServer((_path, res) => {
|
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
res.end("<html>not a CDP endpoint</html>");
|
|
});
|
|
const err = await captureError(ensureCdpReachable(port));
|
|
expectCode(err, FAILURE_CODES.CHROMIUM_CDP_INVALID_RESPONSE);
|
|
expect(getFailureSignal(err)?.network_failure).toBe("invalid_response");
|
|
});
|
|
|
|
it("classifies a non-2xx status as CHROMIUM_CDP_INVALID_RESPONSE", async () => {
|
|
const port = await startServer((_path, res) => {
|
|
res.writeHead(500);
|
|
res.end("boom");
|
|
});
|
|
expectCode(
|
|
await captureError(ensureCdpReachable(port)),
|
|
FAILURE_CODES.CHROMIUM_CDP_INVALID_RESPONSE
|
|
);
|
|
});
|
|
|
|
it("classifies a refused connection as CHROMIUM_CDP_UNREACHABLE", async () => {
|
|
// Bind then immediately release a port so the connect is refused.
|
|
const port = await startServer(() => {});
|
|
await new Promise<void>((r) => openServers.splice(0)[0]!.close(() => r()));
|
|
const err = await captureError(ensureCdpReachable(port));
|
|
expectCode(err, FAILURE_CODES.CHROMIUM_CDP_UNREACHABLE);
|
|
expect(getFailureSignal(err)?.network_failure).toBe("connection_refused");
|
|
});
|
|
|
|
it("classifies an endpoint with no page targets as CHROMIUM_CDP_NO_PAGE_TARGET", async () => {
|
|
const port = await startServer((path, res) => {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
// /json/version resolves; /json/list has no page targets.
|
|
if (path.startsWith("/json/version")) {
|
|
res.end(JSON.stringify({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools/browser/x" }));
|
|
} else {
|
|
res.end(JSON.stringify([]));
|
|
}
|
|
});
|
|
expectCode(
|
|
await captureError(discoverPrimaryPage(port)),
|
|
FAILURE_CODES.CHROMIUM_CDP_NO_PAGE_TARGET
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("chromium CDP-command malformed-response classifications", () => {
|
|
// A CDP command that round-trips but returns a payload missing the expected
|
|
// field is classified `error_kind: "unknown"` (a malformed response from a
|
|
// source we don't own), never `network`. These helpers are called
|
|
// un-swallowed from the chromium tools' execute, so the signal reaches
|
|
// telemetry. A stub CDPClient whose `send` resolves to `{}` reaches the
|
|
// classified throw without a live browser. This pins the convention that
|
|
// CHROMIUM_TAB_OPEN_FAILED (Target.createTarget with no targetId) also follows.
|
|
const stubCdp = (payload: unknown) => ({ send: async () => payload }) as unknown as CDPClient;
|
|
|
|
it("classifies a viewport read with no value as CHROMIUM_VIEWPORT_READ_FAILED (unknown)", async () => {
|
|
const err = await captureError(readViewport(stubCdp({})));
|
|
expectCode(err, FAILURE_CODES.CHROMIUM_VIEWPORT_READ_FAILED);
|
|
expect(getFailureSignal(err)?.error_kind).toBe("unknown");
|
|
});
|
|
|
|
it("classifies a screenshot with no data as CHROMIUM_SCREENSHOT_FAILED (unknown)", async () => {
|
|
const err = await captureError(
|
|
captureScreenshot({ cdp: stubCdp({}), deviceId: "chromium-cdp-9222" })
|
|
);
|
|
expectCode(err, FAILURE_CODES.CHROMIUM_SCREENSHOT_FAILED);
|
|
expect(getFailureSignal(err)?.error_kind).toBe("unknown");
|
|
});
|
|
});
|
|
|
|
describe("vega input classifications", () => {
|
|
// Both validate before any `adb`/inputd I/O, so they throw synchronously on
|
|
// bad input on the real typing path — no device needed to pin the code.
|
|
it("classifies newline-bearing text as VEGA_TEXT_INVALID", async () => {
|
|
expectCode(await captureError(injectVegaText("line1\nline2")), FAILURE_CODES.VEGA_TEXT_INVALID);
|
|
});
|
|
|
|
it("classifies an unknown named key as KEYBOARD_KEY_UNSUPPORTED", async () => {
|
|
expectCode(
|
|
await captureError(injectVegaNamedKey("not-a-real-key")),
|
|
FAILURE_CODES.KEYBOARD_KEY_UNSUPPORTED
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("native-profiler metadata classifications", () => {
|
|
// readAndroidNativeProfilerMetadata is called un-swallowed on the profiler-load
|
|
// path; a corrupt or structurally-invalid sidecar propagates to telemetry.
|
|
let dir = "";
|
|
afterEach(async () => {
|
|
if (dir) await rm(dir, { recursive: true, force: true });
|
|
dir = "";
|
|
});
|
|
|
|
it("classifies a corrupt JSON metadata sidecar as PROFILER_NATIVE_METADATA_INVALID", async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "argent-meta-"));
|
|
const pftrace = join(dir, "native-profiler-s1.pftrace");
|
|
await writeFile(androidNativeProfilerMetadataPath(pftrace), "{ not valid json", "utf8");
|
|
expectCode(
|
|
await captureError(readAndroidNativeProfilerMetadata(pftrace)),
|
|
FAILURE_CODES.PROFILER_NATIVE_METADATA_INVALID
|
|
);
|
|
});
|
|
|
|
it("classifies a structurally-invalid metadata sidecar as PROFILER_NATIVE_METADATA_INVALID", async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "argent-meta-"));
|
|
const pftrace = join(dir, "native-profiler-s2.pftrace");
|
|
// Valid JSON, wrong shape (empty appProcess) → the validate branch.
|
|
await writeFile(
|
|
androidNativeProfilerMetadataPath(pftrace),
|
|
JSON.stringify({ platform: "android", appProcess: "", wallClockStartMs: null }),
|
|
"utf8"
|
|
);
|
|
expectCode(
|
|
await captureError(readAndroidNativeProfilerMetadata(pftrace)),
|
|
FAILURE_CODES.PROFILER_NATIVE_METADATA_INVALID
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("native-profiler stack-query classifications", () => {
|
|
// The tool's execute has no try/catch, so these codes propagate to telemetry.
|
|
// A bare session stub is enough — the throws fire before any trace I/O.
|
|
const session = (api: object) => ({ session: api }) as never;
|
|
|
|
it("classifies a query with no parsed data as PROFILER_DATA_NOT_LOADED", async () => {
|
|
expectCode(
|
|
await captureError(
|
|
profilerStackQueryTool.execute(session({ platform: "ios", parsedData: null }), {
|
|
device_id: "x",
|
|
mode: "thread_breakdown",
|
|
top_n: 15,
|
|
})
|
|
),
|
|
FAILURE_CODES.PROFILER_DATA_NOT_LOADED
|
|
);
|
|
});
|
|
|
|
it("classifies hang_stacks without hang_index as PROFILER_QUERY_REQUIRED_PARAM_MISSING", async () => {
|
|
expectCode(
|
|
await captureError(
|
|
profilerStackQueryTool.execute(
|
|
session({
|
|
platform: "ios",
|
|
parsedData: { cpuSamples: [], uiHangs: [], cpuHotspots: [], memoryLeaks: [] },
|
|
}),
|
|
{ device_id: "x", mode: "hang_stacks", top_n: 15 }
|
|
)
|
|
),
|
|
FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING
|
|
);
|
|
});
|
|
});
|