frida-test 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.js ADDED
@@ -0,0 +1,95 @@
1
+ import { logger } from "./logger.js";
2
+ import { isAgentMessage } from "./protocol.js";
3
+ import { printTestSuiteResult } from "./reporter/console.js";
4
+ export class TestRunner {
5
+ device;
6
+ target;
7
+ agentBundle;
8
+ verbose;
9
+ session;
10
+ script;
11
+ initialized = false;
12
+ results = [];
13
+ fatalError;
14
+ constructor(device, target, agentBundle, verbose) {
15
+ this.device = device;
16
+ this.target = target;
17
+ this.agentBundle = agentBundle;
18
+ this.verbose = verbose;
19
+ }
20
+ get suiteResults() {
21
+ return this.results;
22
+ }
23
+ async initialize() {
24
+ if (this.initialized) {
25
+ throw new Error("TestRunner already initialized");
26
+ }
27
+ const session = await this.device.attach(this.target.pid);
28
+ try {
29
+ const script = await session.createScript(this.agentBundle);
30
+ script.message.connect((message, data) => this.onMessage(message, data));
31
+ script.destroyed.connect(() => {
32
+ this.fatalError ??= new Error("Script was destroyed unexpectedly");
33
+ });
34
+ await script.load();
35
+ this.session = session;
36
+ this.script = script;
37
+ this.initialized = true;
38
+ if (this.target.wasSpawned) {
39
+ await this.device.resume(this.target.pid);
40
+ }
41
+ }
42
+ catch (error) {
43
+ await session.detach().catch(() => { });
44
+ throw error;
45
+ }
46
+ }
47
+ async runTests() {
48
+ if (!this.initialized || this.script === undefined) {
49
+ throw new Error("TestRunner not initialized; call initialize() first");
50
+ }
51
+ const summary = await this.script.exports.runTests(this.verbose);
52
+ if (this.fatalError !== undefined) {
53
+ throw this.fatalError;
54
+ }
55
+ return summary;
56
+ }
57
+ async dispose() {
58
+ try {
59
+ await this.script?.unload();
60
+ }
61
+ finally {
62
+ await this.session?.detach();
63
+ this.script = undefined;
64
+ this.session = undefined;
65
+ this.initialized = false;
66
+ }
67
+ }
68
+ onMessage(message, _data) {
69
+ if (message.type === "error") {
70
+ this.fatalError = new Error(message.stack ?? message.description);
71
+ return;
72
+ }
73
+ const payload = message.payload;
74
+ if (!isAgentMessage(payload)) {
75
+ if (this.verbose) {
76
+ logger.info(`Ignoring non-agent message: ${JSON.stringify(payload)}`);
77
+ }
78
+ return;
79
+ }
80
+ switch (payload.type) {
81
+ case "agent-ready":
82
+ break;
83
+ case "test-suite-started":
84
+ logger.info(`Test suite "${payload.name}" started...`);
85
+ break;
86
+ case "test-suite-finished":
87
+ logger.info(`Test suite "${payload.name}" finished.`);
88
+ printTestSuiteResult(payload.result);
89
+ this.results.push(payload.result);
90
+ break;
91
+ case "run-finished":
92
+ break;
93
+ }
94
+ }
95
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "frida-test",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "license": "GPL-3.0-only",
6
+ "homepage": "https://github.com/bernhste/frida-test/blob/main/README.md",
7
+ "description": "frida-test is a small unit framework based on Frida. It is used to unit test Frida code running on actual devices.",
8
+ "keywords": [
9
+ "frida",
10
+ "testing",
11
+ "instrumentation",
12
+ "hooking"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/bernhste/frida-test.git"
17
+ },
18
+ "engines": {
19
+ "node": ">=24"
20
+ },
21
+ "bin": {
22
+ "frida-test": "bin/frida-test.js",
23
+ "frida-test-compiler": "bin/frida-test-compiler.js"
24
+ },
25
+ "types": "./src/agent-runtime/globals.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./src/agent-runtime/globals.d.ts",
29
+ "default": "./src/agent-runtime/globals.ts"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist/",
34
+ "bin/",
35
+ "src/agent-runtime"
36
+ ],
37
+ "scripts": {
38
+ "build": "npx tsc -b && npm link",
39
+ "test:android": "npm run build && frida-test -U -i 'com.google.android.dialer' ./tests/",
40
+ "watch": "npx tsc -b --watch",
41
+ "clean": "npx tsc -b --clean && npx rimraf dist"
42
+ },
43
+ "allowScripts": {
44
+ "frida": true
45
+ },
46
+ "dependencies": {
47
+ "chalk": "^6.0.0",
48
+ "frida-compile": "^19.0.5"
49
+ },
50
+ "devDependencies": {
51
+ "@types/frida-gum": "^19.10.0",
52
+ "@types/node": "^26.4.0",
53
+ "rimraf": "^6.1.3",
54
+ "typescript": "^7.0.2"
55
+ }
56
+ }
@@ -0,0 +1,25 @@
1
+ // AUTO-GENERATED by frida-test - do not edit
2
+ import type { AgentMessage, RunSummary } from "../../src/protocol.js";
3
+ import "./globals.js";
4
+ import "./matchers.js";
5
+ import { registry, runTests } from "./registry.js";
6
+
7
+ /// IMPORT TESTS SUITES ///
8
+
9
+ function emit(message: AgentMessage): void {
10
+ send(message);
11
+ }
12
+
13
+ let running: Promise<RunSummary> | null = null;
14
+
15
+ rpc.exports = {
16
+ runTests(verbose: boolean = false): Promise<RunSummary> {
17
+ running ??= runTests(registry, emit, verbose).then((summary) => {
18
+ emit({ type: "run-finished" });
19
+ return summary;
20
+ });
21
+ return running;
22
+ },
23
+ };
24
+
25
+ emit({ type: "agent-ready" });
@@ -0,0 +1,9 @@
1
+ type Matcher<T> = import("./matchers.js").Matcher<T>;
2
+ type Spy = import("./matchers.js").Spy;
3
+ type TestFn = import("./registry.js").TestFn;
4
+
5
+ declare function describe(name: string, fn: TestFn): void;
6
+ declare function it(name: string, fn: TestFn): void;
7
+ declare function test(name: string, fn: TestFn): void;
8
+ declare function expect<T>(actual: T): Matcher<T>;
9
+ declare function spyOn<T extends object, K extends keyof T>(target: T, key: K): Spy;
@@ -0,0 +1,13 @@
1
+ import { expect, spyOn, type Matcher, type Spy } from "./matchers.js";
2
+ import type { TestFn } from "./registry.js";
3
+ import { describe, it, test } from "./registry.js";
4
+
5
+ declare global {
6
+ function describe(name: string, fn: TestFn): void;
7
+ function it(name: string, fn: TestFn): void;
8
+ function test(name: string, fn: TestFn): void;
9
+ function expect<T>(actual: T): Matcher<T>;
10
+ function spyOn<T extends object, K extends keyof T>(target: T, key: K): Spy;
11
+ }
12
+
13
+ Object.assign(globalThis, { describe, it, test, expect, spyOn });
@@ -0,0 +1,243 @@
1
+ export type Containable<T> = T extends readonly (infer U)[] ? U : T extends string ? string : never;
2
+ export type Numeric<T> = T extends number ? number : never;
3
+
4
+ export interface Matcher<T> {
5
+ toBe(expected: T): void;
6
+ toEqual(expected: T): void;
7
+ toBeTruthy(): void;
8
+ toBeFalsy(): void;
9
+ toBeNull(): void;
10
+ toBeDefined(): void;
11
+ toBeUndefined(): void;
12
+ toBeGreaterThan(expected: Numeric<T>): void;
13
+ toBeLessThan(expected: Numeric<T>): void;
14
+ toContain(expected: Containable<T>): void;
15
+ toThrow(errorMatch?: string | Error): void;
16
+ toResolve(valueMatch?: unknown): Promise<void>;
17
+ toReject(errorMatch?: string | Error): Promise<void>;
18
+ toHaveBeenCalled(): void;
19
+ toHaveBeenCalledWith(...expected: unknown[]): void;
20
+ readonly not: Matcher<T>;
21
+ }
22
+
23
+ export interface Spy {
24
+ readonly calls: readonly unknown[][];
25
+ restore(): void;
26
+ mockReturnValue(value: unknown): Spy;
27
+ mockImplementation(fn: (...args: unknown[]) => unknown): Spy;
28
+ }
29
+
30
+ const assert = (condition: boolean, message: string): void => {
31
+ if (!condition) throw new Error(message);
32
+ };
33
+
34
+ const deepEqual = (a: unknown, b: unknown, seen: Array<[unknown, unknown]> = []): boolean => {
35
+ if (Object.is(a, b)) return true;
36
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
37
+
38
+ const tagA = Object.prototype.toString.call(a);
39
+ const tagB = Object.prototype.toString.call(b);
40
+ if (tagA !== tagB) return false;
41
+
42
+ if (a instanceof Date) return a.getTime() === (b as Date).getTime();
43
+ if (a instanceof RegExp) return a.source === (b as RegExp).source && a.flags === (b as RegExp).flags;
44
+
45
+ if (seen.some(([sa, sb]) => sa === a && sb === b)) return true;
46
+ const nextSeen: Array<[unknown, unknown]> = [...seen, [a, b]];
47
+
48
+ if (a instanceof Map) {
49
+ const bm = b as Map<unknown, unknown>;
50
+ if (a.size !== bm.size) return false;
51
+ for (const [key, val] of a) {
52
+ if (!bm.has(key) || !deepEqual(val, bm.get(key), nextSeen)) return false;
53
+ }
54
+ return true;
55
+ }
56
+
57
+ if (a instanceof Set) {
58
+ const bs = [...(b as Set<unknown>)];
59
+ if (a.size !== bs.length) return false;
60
+ for (const val of a) {
61
+ if (!bs.some((other) => deepEqual(val, other, nextSeen))) return false;
62
+ }
63
+ return true;
64
+ }
65
+
66
+ const ra = a as Record<string, unknown>;
67
+ const rb = b as Record<string, unknown>;
68
+ const keysA = Object.keys(ra).filter((k) => ra[k] !== undefined);
69
+ const keysB = Object.keys(rb).filter((k) => rb[k] !== undefined);
70
+ if (keysA.length !== keysB.length) return false;
71
+ return keysA.every((k) => deepEqual(ra[k], rb[k], nextSeen));
72
+ };
73
+
74
+ const isSpy = (value: unknown): value is Spy => typeof value === "object" && value !== null && Array.isArray((value as Spy).calls);
75
+
76
+ function assertIsNumber(value: unknown, label: string): asserts value is number {
77
+ assert(typeof value === "number", `Expected ${label} to be a number`);
78
+ }
79
+
80
+ function createMatcher<T>(actual: T, negated = false): Matcher<T> {
81
+ const check = (condition: boolean, message: string): void => assert(negated ? !condition : condition, message);
82
+ const phrase = negated ? "not to" : "to";
83
+
84
+ const describeCaught = (caught: unknown): string =>
85
+ caught instanceof Error ? `${caught.constructor.name}: "${caught.message}"` : JSON.stringify(caught);
86
+
87
+ const checkThrown = (caught: unknown, errorMatch: string | Error): void => {
88
+ if (typeof errorMatch === "string") {
89
+ const message = caught instanceof Error ? caught.message : String(caught);
90
+ assert(message.includes(errorMatch), `Expected message to include "${errorMatch}" but got "${message}"`);
91
+ } else {
92
+ assert(
93
+ caught instanceof Error && caught instanceof errorMatch.constructor && caught.message === errorMatch.message,
94
+ `Expected ${errorMatch.constructor.name}: "${errorMatch.message}" but got ${describeCaught(caught)}`,
95
+ );
96
+ }
97
+ };
98
+
99
+ return {
100
+ toBe: (expected) => check(Object.is(actual, expected), `Expected ${String(actual)} ${phrase} be ${String(expected)}`),
101
+
102
+ toEqual: (expected) =>
103
+ check(deepEqual(actual, expected), `Expected ${JSON.stringify(actual, null, 2)} ${phrase} equal ${JSON.stringify(expected, null, 2)}`),
104
+
105
+ toBeTruthy: () => check(Boolean(actual), `Expected ${String(actual)} ${phrase} be truthy`),
106
+ toBeFalsy: () => check(!actual, `Expected ${String(actual)} ${phrase} be falsy`),
107
+ toBeNull: () => check(actual === null, `Expected ${String(actual)} ${phrase} be null`),
108
+ toBeDefined: () => check(actual !== undefined, `Expected ${String(actual)} ${phrase} be defined`),
109
+ toBeUndefined: () => check(actual === undefined, `Expected ${String(actual)} ${phrase} be undefined`),
110
+
111
+ toBeGreaterThan: (expected) => {
112
+ assertIsNumber(actual, "actual");
113
+ assertIsNumber(expected, "expected");
114
+ check(actual > expected, `Expected ${actual} ${phrase} be greater than ${expected}`);
115
+ },
116
+
117
+ toBeLessThan: (expected) => {
118
+ assertIsNumber(actual, "actual");
119
+ assertIsNumber(expected, "expected");
120
+ check(actual < expected, `Expected ${actual} ${phrase} be less than ${expected}`);
121
+ },
122
+
123
+ toContain: (expected) => {
124
+ assert(typeof actual === "string" || Array.isArray(actual), "Expected an array or string");
125
+ const contains =
126
+ typeof actual === "string" ? actual.includes(expected as string) : (actual as readonly unknown[]).some((item) => deepEqual(item, expected));
127
+ check(contains, `Expected ${JSON.stringify(actual)} ${phrase} contain ${JSON.stringify(expected)}`);
128
+ },
129
+
130
+ toThrow: (errorMatch) => {
131
+ assert(typeof actual === "function", "Expected a function");
132
+ let caught: unknown;
133
+ let threw = false;
134
+ let result: unknown;
135
+ try {
136
+ result = (actual as () => unknown)();
137
+ } catch (e) {
138
+ threw = true;
139
+ caught = e;
140
+ }
141
+
142
+ if (!threw && result != null && typeof (result as PromiseLike<unknown>).then === "function") {
143
+ throw new Error("toThrow() received a function returning a Promise; use await expect(fn).toReject(...) instead");
144
+ }
145
+
146
+ check(threw, `Expected function ${phrase} throw`);
147
+ if (negated || errorMatch === undefined) return;
148
+ checkThrown(caught, errorMatch);
149
+ },
150
+
151
+ toResolve: async (expected) => {
152
+ assert(typeof actual === "function", "Expected a function returning a promise");
153
+ let caught: unknown;
154
+ let resolved = false;
155
+ try {
156
+ caught = await (actual as () => Promise<unknown>)();
157
+ resolved = true;
158
+ } catch (e) {
159
+ // promise rejected
160
+ }
161
+ check(resolved, `Expected promise ${phrase} resolve`);
162
+ if (negated || expected === undefined) return;
163
+ assert(deepEqual(caught, expected), `Expected ${JSON.stringify(caught)} to equal ${JSON.stringify(expected)}`);
164
+ },
165
+
166
+ toReject: async (errorMatch) => {
167
+ assert(typeof actual === "function", "Expected a function returning a promise");
168
+ let caught: unknown;
169
+ let rejected = false;
170
+ try {
171
+ await (actual as () => Promise<unknown>)();
172
+ } catch (e) {
173
+ rejected = true;
174
+ caught = e;
175
+ }
176
+ check(rejected, `Expected promise ${phrase} reject`);
177
+ if (negated || errorMatch === undefined) return;
178
+ checkThrown(caught, errorMatch);
179
+ },
180
+
181
+ toHaveBeenCalled: () => {
182
+ assert(isSpy(actual), "Expected a spy created with spyOn()");
183
+ check((actual as Spy).calls.length > 0, `Expected spy ${phrase} have been called`);
184
+ },
185
+
186
+ toHaveBeenCalledWith: (...expected: unknown[]) => {
187
+ assert(isSpy(actual), "Expected a spy created with spyOn()");
188
+ const calls = (actual as Spy).calls;
189
+ const match = calls.some((args) => deepEqual(args, expected));
190
+ check(match, `Expected spy ${phrase} have been called with ${JSON.stringify(expected)} but got ${JSON.stringify(calls)}`);
191
+ },
192
+
193
+ get not(): Matcher<T> {
194
+ return createMatcher(actual, !negated);
195
+ },
196
+ };
197
+ }
198
+
199
+ export function expect<T>(actual: T): Matcher<T> {
200
+ return createMatcher(actual);
201
+ }
202
+
203
+ export function spyOn<T extends object, K extends keyof T>(target: T, key: K): Spy {
204
+ const original = target[key];
205
+ assert(typeof original === "function", `${String(key)} is not a function`);
206
+
207
+ const descriptor = Object.getOwnPropertyDescriptor(target, key); // fix: capture descriptor for faithful restore
208
+ const calls: unknown[][] = [];
209
+ let impl: (...args: unknown[]) => unknown = (original as (...a: unknown[]) => unknown).bind(target);
210
+ let returnValue: unknown;
211
+ let hasReturnValue = false;
212
+ let restored = false;
213
+
214
+ const spy: Spy = {
215
+ calls,
216
+ restore: () => {
217
+ if (restored) return;
218
+ restored = true;
219
+ if (descriptor) {
220
+ Object.defineProperty(target, key, descriptor);
221
+ } else {
222
+ Reflect.deleteProperty(target, key);
223
+ }
224
+ },
225
+ mockReturnValue(value: unknown) {
226
+ hasReturnValue = true;
227
+ returnValue = value;
228
+ return spy;
229
+ },
230
+ mockImplementation(fn: (...args: unknown[]) => unknown) {
231
+ impl = fn;
232
+ hasReturnValue = false;
233
+ return spy;
234
+ },
235
+ };
236
+
237
+ target[key] = ((...args: unknown[]) => {
238
+ calls.push(args);
239
+ return hasReturnValue ? returnValue : impl(...args);
240
+ }) as T[K];
241
+
242
+ return spy;
243
+ }
@@ -0,0 +1,160 @@
1
+ // agent/registry.ts
2
+ import { type AgentMessage, type RunSummary, type TestError, type TestResult, type TestStatus, type TestSuiteResult } from "../../src/protocol.js";
3
+
4
+ export type TestFn = () => void | Promise<void>;
5
+
6
+ type NodeKind = "describe" | "it";
7
+
8
+ interface TestSuiteNode {
9
+ kind: NodeKind;
10
+ name: string;
11
+ fn: TestFn;
12
+ children?: TestSuiteNode[];
13
+ }
14
+
15
+ export const registry: TestSuiteNode[] = [];
16
+ const stack: TestSuiteNode[][] = [];
17
+
18
+ function registerNode(kind: NodeKind, name: string, fn: TestFn): void {
19
+ (stack.at(-1) ?? registry).push({ kind, name, fn });
20
+ }
21
+
22
+ export const describe = (name: string, fn: TestFn): void => registerNode("describe", name, fn);
23
+
24
+ export const it = (name: string, fn: TestFn): void => registerNode("it", name, fn);
25
+ export const test = it; // alias
26
+
27
+ function serializeError(err: unknown, verbose: boolean): TestError {
28
+ if (err instanceof Error) {
29
+ return { message: err.message, stack: verbose ? err.stack : undefined };
30
+ }
31
+ return { message: String(err) };
32
+ }
33
+
34
+ interface Counts {
35
+ total: number;
36
+ passed: number;
37
+ failed: number;
38
+ }
39
+
40
+ const ZERO_COUNTS: Counts = { total: 0, passed: 0, failed: 0 };
41
+
42
+ function addCounts(a: Counts, b: Counts): Counts {
43
+ return { total: a.total + b.total, passed: a.passed + b.passed, failed: a.failed + b.failed };
44
+ }
45
+
46
+ let expandLock: Promise<void> = Promise.resolve();
47
+
48
+ function withExpandLock<T>(fn: () => Promise<T>): Promise<T> {
49
+ const run = expandLock.then(fn, fn);
50
+ expandLock = run.then(
51
+ () => undefined,
52
+ () => undefined,
53
+ );
54
+ return run;
55
+ }
56
+
57
+ async function expand(node: TestSuiteNode, verbose: boolean): Promise<{ children: TestSuiteNode[]; error?: TestError }> {
58
+ return withExpandLock(async () => {
59
+ const children: TestSuiteNode[] = [];
60
+ stack.push(children);
61
+ try {
62
+ await node.fn();
63
+ return { children };
64
+ } catch (err) {
65
+ return { children, error: serializeError(err, verbose) };
66
+ } finally {
67
+ stack.pop();
68
+ }
69
+ });
70
+ }
71
+
72
+ async function runTestSuiteNode(node: TestSuiteNode, verbose: boolean): Promise<{ result: TestResult; counts: Counts }> {
73
+ const start = Date.now();
74
+
75
+ if (node.kind === "describe") {
76
+ const { children: nodes, error } = await expand(node, verbose);
77
+ node.children = nodes;
78
+
79
+ if (error) {
80
+ const children: TestResult[] = nodes.map((child) => ({
81
+ name: child.name,
82
+ status: "failed",
83
+ durationMs: 0,
84
+ error: { message: `parent suite "${node.name}" failed before this test could run` },
85
+ }));
86
+ const counts: Counts = { total: 1 + children.length, passed: 0, failed: 1 + children.length };
87
+ const result: TestResult = {
88
+ name: node.name,
89
+ status: "failed",
90
+ durationMs: Date.now() - start,
91
+ error,
92
+ children,
93
+ };
94
+ return { result, counts };
95
+ }
96
+
97
+ const children: TestResult[] = [];
98
+ let counts = ZERO_COUNTS;
99
+ for (const child of node.children) {
100
+ const childRun = await runTestSuiteNode(child, verbose);
101
+ children.push(childRun.result);
102
+ counts = addCounts(counts, childRun.counts);
103
+ }
104
+
105
+ const status: TestStatus = counts.failed > 0 ? "failed" : "passed";
106
+ const result: TestResult = { name: node.name, status, durationMs: Date.now() - start, children };
107
+ return { result, counts };
108
+ }
109
+
110
+ // Leaf test.
111
+ try {
112
+ await node.fn();
113
+ const result: TestResult = { name: node.name, status: "passed", durationMs: Date.now() - start };
114
+ return { result, counts: { total: 1, passed: 1, failed: 0 } };
115
+ } catch (err) {
116
+ const result: TestResult = {
117
+ name: node.name,
118
+ status: "failed",
119
+ durationMs: Date.now() - start,
120
+ error: serializeError(err, verbose),
121
+ };
122
+ return { result, counts: { total: 1, passed: 0, failed: 1 } };
123
+ }
124
+ }
125
+
126
+ export async function runTests(nodes: TestSuiteNode[], emit: (message: AgentMessage) => void, verbose: boolean = false): Promise<RunSummary> {
127
+ const start = Date.now();
128
+
129
+ const settled = await Promise.allSettled(
130
+ nodes.map(async (node) => {
131
+ emit({ type: "test-suite-started", name: node.name });
132
+ const { result: testResult, counts } = await runTestSuiteNode(node, verbose);
133
+ const suiteResult: TestSuiteResult = { name: node.name, testResult, status: testResult.status };
134
+ emit({ type: "test-suite-finished", name: node.name, result: suiteResult });
135
+ return { suiteResult, counts };
136
+ }),
137
+ );
138
+
139
+ const testSuitesResults: TestSuiteResult[] = [];
140
+ let counts = ZERO_COUNTS;
141
+
142
+ settled.forEach((outcome, i) => {
143
+ if (outcome.status === "fulfilled") {
144
+ testSuitesResults.push(outcome.value.suiteResult);
145
+ counts = addCounts(counts, outcome.value.counts);
146
+ return;
147
+ }
148
+
149
+ const error = serializeError(outcome.reason, verbose);
150
+ const testSuiteResult: TestSuiteResult = {
151
+ name: nodes[i].name,
152
+ testResult: { name: nodes[i].name, status: "failed", durationMs: 0, error },
153
+ status: "failed",
154
+ };
155
+ testSuitesResults.push(testSuiteResult);
156
+ counts = addCounts(counts, { total: 1, passed: 0, failed: 1 });
157
+ });
158
+
159
+ return { ...counts, durationMs: Date.now() - start, testSuitesResults };
160
+ }