ptywright 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +318 -1
- package/dist/agent.mjs +2 -0
- package/dist/bin/ptywright.mjs +6 -0
- package/dist/cli-CfvlbRoZ.mjs +3585 -0
- package/dist/cli.mjs +2 -0
- package/{src/index.ts → dist/index.mjs} +7 -9
- package/dist/mcp.mjs +2 -0
- package/dist/pty-cassette.mjs +24 -0
- package/dist/pty_like-Cpkh_O9B.mjs +404 -0
- package/dist/runner-zApMYWZx.mjs +3257 -0
- package/dist/runner-zi0nItvB.mjs +1874 -0
- package/dist/script.mjs +2 -0
- package/dist/server-BC3yo-dq.mjs +3068 -0
- package/dist/session.mjs +2 -0
- package/dist/terminal_session-DopC7Xg6.mjs +893 -0
- package/package.json +28 -21
- package/schemas/ptywright-agent-cassette.schema.json +57 -0
- package/schemas/ptywright-agent-check.schema.json +122 -0
- package/schemas/ptywright-agent-manifest.schema.json +107 -0
- package/schemas/ptywright-agent-promote.schema.json +146 -0
- package/schemas/ptywright-agent-replay-summary.schema.json +140 -0
- package/schemas/ptywright-agent-run.schema.json +126 -0
- package/schemas/ptywright-agent.schema.json +166 -0
- package/schemas/ptywright-pty-cassette.schema.json +86 -0
- package/schemas/ptywright-script-manifest.schema.json +75 -0
- package/schemas/ptywright-script-run-summary.schema.json +114 -0
- package/schemas/ptywright-script.schema.json +55 -3
- package/bin/ptywright +0 -4
- package/src/cli.ts +0 -414
- package/src/generator/doc_parser.ts +0 -341
- package/src/generator/generate.ts +0 -161
- package/src/generator/index.ts +0 -10
- package/src/generator/script_generator.ts +0 -209
- package/src/generator/step_extractor.ts +0 -397
- package/src/mcp/http_server.ts +0 -174
- package/src/mcp/script_recording.ts +0 -238
- package/src/mcp/server.ts +0 -1348
- package/src/pty/bun_pty_adapter.ts +0 -34
- package/src/pty/bun_terminal_adapter.ts +0 -149
- package/src/pty/pty_adapter.ts +0 -31
- package/src/script/dsl.ts +0 -188
- package/src/script/module.ts +0 -43
- package/src/script/path.ts +0 -151
- package/src/script/run.ts +0 -108
- package/src/script/run_all.ts +0 -229
- package/src/script/runner.ts +0 -983
- package/src/script/schema.ts +0 -237
- package/src/script/steps/assert_snapshot_equals.ts +0 -21
- package/src/script/steps/index.ts +0 -2
- package/src/script/suite_report.ts +0 -626
- package/src/session/session_manager.ts +0 -145
- package/src/session/terminal_session.ts +0 -473
- package/src/terminal/ansi.ts +0 -142
- package/src/terminal/keys.ts +0 -180
- package/src/terminal/mask.ts +0 -70
- package/src/terminal/mouse.ts +0 -75
- package/src/terminal/snapshot.ts +0 -196
- package/src/terminal/style.ts +0 -121
- package/src/terminal/view.ts +0 -49
- package/src/trace/asciicast.ts +0 -20
- package/src/trace/asciinema_player_assets.ts +0 -44
- package/src/trace/cast_to_txt.ts +0 -116
- package/src/trace/recorder.ts +0 -110
- package/src/trace/report.ts +0 -2092
- package/src/types.ts +0 -86
- package/src/util/hash.ts +0 -8
- package/src/util/sleep.ts +0 -5
package/src/mcp/http_server.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2
|
-
|
|
3
|
-
import type { PtywrightCapability } from "./server";
|
|
4
|
-
import { createPtywrightServer } from "./server";
|
|
5
|
-
|
|
6
|
-
export type PtywrightHttpServerOptions = {
|
|
7
|
-
hostname?: string;
|
|
8
|
-
port?: number;
|
|
9
|
-
capabilities?: PtywrightCapability[];
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* If the request includes an Origin header, it must match one of these values.
|
|
13
|
-
* Use ["*"] to allow any Origin (NOT recommended).
|
|
14
|
-
*/
|
|
15
|
-
allowedOrigins?: string[];
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Add CORS headers for browser-based clients.
|
|
19
|
-
* If false, this server is intended for non-browser clients only.
|
|
20
|
-
*/
|
|
21
|
-
cors?: boolean;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export type PtywrightHttpServerHandle = {
|
|
25
|
-
url: string;
|
|
26
|
-
hostname: string;
|
|
27
|
-
port: number;
|
|
28
|
-
close: () => Promise<void>;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
function parseAllowedOrigins(value: string | undefined): string[] | undefined {
|
|
32
|
-
if (!value?.trim()) return undefined;
|
|
33
|
-
return value
|
|
34
|
-
.split(/[\s,]+/g)
|
|
35
|
-
.map((v) => v.trim())
|
|
36
|
-
.filter(Boolean);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function isOriginAllowed(origin: string, allowed: string[]): boolean {
|
|
40
|
-
if (allowed.includes("*")) return true;
|
|
41
|
-
return allowed.includes(origin);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function withCorsHeaders(
|
|
45
|
-
init: ResponseInit,
|
|
46
|
-
origin: string | null,
|
|
47
|
-
allowed: string[],
|
|
48
|
-
): ResponseInit {
|
|
49
|
-
if (!origin) return init;
|
|
50
|
-
if (!isOriginAllowed(origin, allowed)) return init;
|
|
51
|
-
|
|
52
|
-
const headers = new Headers(init.headers);
|
|
53
|
-
headers.set("access-control-allow-origin", origin);
|
|
54
|
-
headers.set("vary", "origin");
|
|
55
|
-
headers.set("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
|
|
56
|
-
headers.set(
|
|
57
|
-
"access-control-allow-headers",
|
|
58
|
-
"content-type,mcp-session-id,last-event-id,mcp-protocol-version",
|
|
59
|
-
);
|
|
60
|
-
headers.set("access-control-expose-headers", "mcp-session-id,mcp-protocol-version");
|
|
61
|
-
|
|
62
|
-
return { ...init, headers };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function startPtywrightHttpServer(
|
|
66
|
-
options?: PtywrightHttpServerOptions,
|
|
67
|
-
): Promise<PtywrightHttpServerHandle> {
|
|
68
|
-
const hostname = options?.hostname?.trim() ? options.hostname.trim() : "127.0.0.1";
|
|
69
|
-
const desiredPort = options?.port ?? 3000;
|
|
70
|
-
const cors = options?.cors ?? true;
|
|
71
|
-
|
|
72
|
-
const { server, sessions } = createPtywrightServer({
|
|
73
|
-
capabilities: options?.capabilities,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const transport = new WebStandardStreamableHTTPServerTransport();
|
|
77
|
-
await server.connect(transport);
|
|
78
|
-
|
|
79
|
-
let allowedOrigins: string[] =
|
|
80
|
-
options?.allowedOrigins ??
|
|
81
|
-
parseAllowedOrigins(process.env.PTYWRIGHT_HTTP_ALLOWED_ORIGINS) ??
|
|
82
|
-
[];
|
|
83
|
-
|
|
84
|
-
const srv = Bun.serve({
|
|
85
|
-
hostname,
|
|
86
|
-
port: desiredPort,
|
|
87
|
-
fetch: async (req: Request) => {
|
|
88
|
-
const url = new URL(req.url);
|
|
89
|
-
const origin = req.headers.get("origin");
|
|
90
|
-
|
|
91
|
-
if (url.pathname === "/health") {
|
|
92
|
-
const init = withCorsHeaders(
|
|
93
|
-
{
|
|
94
|
-
status: 200,
|
|
95
|
-
headers: { "content-type": "application/json" },
|
|
96
|
-
},
|
|
97
|
-
cors ? origin : null,
|
|
98
|
-
allowedOrigins,
|
|
99
|
-
);
|
|
100
|
-
return new Response(JSON.stringify({ status: "ok" }), init);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (url.pathname !== "/mcp") {
|
|
104
|
-
const init = withCorsHeaders(
|
|
105
|
-
{
|
|
106
|
-
status: 404,
|
|
107
|
-
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
108
|
-
},
|
|
109
|
-
cors ? origin : null,
|
|
110
|
-
allowedOrigins,
|
|
111
|
-
);
|
|
112
|
-
return new Response("not found", init);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Per MCP Streamable HTTP security guidance: validate Origin (when present).
|
|
116
|
-
if (origin && !isOriginAllowed(origin, allowedOrigins)) {
|
|
117
|
-
return new Response("forbidden", {
|
|
118
|
-
status: 403,
|
|
119
|
-
headers: cors
|
|
120
|
-
? {
|
|
121
|
-
"content-type": "text/plain; charset=utf-8",
|
|
122
|
-
"access-control-allow-origin": origin,
|
|
123
|
-
vary: "origin",
|
|
124
|
-
}
|
|
125
|
-
: { "content-type": "text/plain; charset=utf-8" },
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (req.method === "OPTIONS") {
|
|
130
|
-
return new Response(null, withCorsHeaders({ status: 204 }, origin, allowedOrigins));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const res = await transport.handleRequest(req);
|
|
134
|
-
|
|
135
|
-
if (!cors) return res;
|
|
136
|
-
|
|
137
|
-
const init = withCorsHeaders(
|
|
138
|
-
{
|
|
139
|
-
status: res.status,
|
|
140
|
-
statusText: res.statusText,
|
|
141
|
-
headers: res.headers,
|
|
142
|
-
},
|
|
143
|
-
origin,
|
|
144
|
-
allowedOrigins,
|
|
145
|
-
);
|
|
146
|
-
return new Response(res.body, init);
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
const port = srv.port;
|
|
151
|
-
if (port === undefined) {
|
|
152
|
-
await srv.stop();
|
|
153
|
-
sessions.closeAll();
|
|
154
|
-
await server.close();
|
|
155
|
-
throw new Error("failed to bind HTTP server port");
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (allowedOrigins.length === 0) {
|
|
159
|
-
allowedOrigins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const url = `http://${hostname}:${port}/mcp`;
|
|
163
|
-
|
|
164
|
-
return {
|
|
165
|
-
url,
|
|
166
|
-
hostname,
|
|
167
|
-
port,
|
|
168
|
-
close: async () => {
|
|
169
|
-
await srv.stop();
|
|
170
|
-
sessions.closeAll();
|
|
171
|
-
await server.close();
|
|
172
|
-
},
|
|
173
|
-
};
|
|
174
|
-
}
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, relative, resolve } from "node:path";
|
|
3
|
-
|
|
4
|
-
import { scriptSchema } from "../script/schema";
|
|
5
|
-
import type { Script, ScriptStep } from "../script/schema";
|
|
6
|
-
import type { TerminalSession } from "../session/terminal_session";
|
|
7
|
-
import type { TextMaskRule } from "../terminal/mask";
|
|
8
|
-
|
|
9
|
-
export type StartScriptRecordingArgs = {
|
|
10
|
-
name: string;
|
|
11
|
-
outPath?: string;
|
|
12
|
-
goldenDir?: string;
|
|
13
|
-
overwrite?: boolean;
|
|
14
|
-
checkpoint?: {
|
|
15
|
-
scope?: "visible" | "buffer";
|
|
16
|
-
trimRight?: boolean;
|
|
17
|
-
trimBottom?: boolean;
|
|
18
|
-
mask?: TextMaskRule[];
|
|
19
|
-
};
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export type StopScriptRecordingArgs = {
|
|
23
|
-
recordingId: string;
|
|
24
|
-
writeFiles?: boolean;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type ScriptRecordingStatus = {
|
|
28
|
-
recordingId: string;
|
|
29
|
-
name: string;
|
|
30
|
-
outPath: string;
|
|
31
|
-
goldenDir: string;
|
|
32
|
-
hasLaunch: boolean;
|
|
33
|
-
stepCount: number;
|
|
34
|
-
checkpointCount: number;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export type StopScriptRecordingResult = {
|
|
38
|
-
scriptPath?: string;
|
|
39
|
-
goldenPaths: string[];
|
|
40
|
-
script: Script & { $schema?: string };
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
type GoldenWrite = { path: string; text: string };
|
|
44
|
-
|
|
45
|
-
type CheckpointConfig = {
|
|
46
|
-
scope: "visible" | "buffer";
|
|
47
|
-
trimRight: boolean;
|
|
48
|
-
trimBottom: boolean;
|
|
49
|
-
mask?: TextMaskRule[];
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
type ScriptRecording = {
|
|
53
|
-
id: string;
|
|
54
|
-
name: string;
|
|
55
|
-
outPath: string;
|
|
56
|
-
goldenDir: string;
|
|
57
|
-
overwrite: boolean;
|
|
58
|
-
checkpoint: CheckpointConfig;
|
|
59
|
-
launch: Script["launch"] | null;
|
|
60
|
-
sessionId: string | null;
|
|
61
|
-
steps: ScriptStep[];
|
|
62
|
-
checkpointIndex: number;
|
|
63
|
-
goldenWrites: GoldenWrite[];
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
export class ScriptRecordingManager {
|
|
67
|
-
private active: ScriptRecording | null = null;
|
|
68
|
-
|
|
69
|
-
start(args: StartScriptRecordingArgs): ScriptRecordingStatus {
|
|
70
|
-
if (this.active) {
|
|
71
|
-
throw new Error(`recording already active: ${this.active.id}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const name = args.name.trim();
|
|
75
|
-
if (!name) throw new Error("name is required");
|
|
76
|
-
|
|
77
|
-
const outPath = (args.outPath?.trim() ? args.outPath.trim() : `scripts/${name}.json`).trim();
|
|
78
|
-
const goldenDir = (
|
|
79
|
-
args.goldenDir?.trim() ? args.goldenDir.trim() : `tests/golden/scripts/${name}`
|
|
80
|
-
).trim();
|
|
81
|
-
|
|
82
|
-
this.active = {
|
|
83
|
-
id: crypto.randomUUID(),
|
|
84
|
-
name,
|
|
85
|
-
outPath,
|
|
86
|
-
goldenDir,
|
|
87
|
-
overwrite: args.overwrite ?? false,
|
|
88
|
-
checkpoint: {
|
|
89
|
-
scope: args.checkpoint?.scope ?? "visible",
|
|
90
|
-
trimRight: args.checkpoint?.trimRight ?? true,
|
|
91
|
-
trimBottom: args.checkpoint?.trimBottom ?? true,
|
|
92
|
-
mask: args.checkpoint?.mask,
|
|
93
|
-
},
|
|
94
|
-
launch: null,
|
|
95
|
-
sessionId: null,
|
|
96
|
-
steps: [],
|
|
97
|
-
checkpointIndex: 0,
|
|
98
|
-
goldenWrites: [],
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
return this.status();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
stop(args: StopScriptRecordingArgs): StopScriptRecordingResult {
|
|
105
|
-
if (!this.active) {
|
|
106
|
-
throw new Error("no active recording");
|
|
107
|
-
}
|
|
108
|
-
if (args.recordingId !== this.active.id) {
|
|
109
|
-
throw new Error(`recording not found: ${args.recordingId}`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const writeFiles = args.writeFiles ?? true;
|
|
113
|
-
const recording = this.active;
|
|
114
|
-
this.active = null;
|
|
115
|
-
|
|
116
|
-
if (!recording.launch) {
|
|
117
|
-
throw new Error("recording has no launch_session");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const schemaAbs = resolve("schemas/ptywright-script.schema.json");
|
|
121
|
-
const schemaRel = toPosixPath(relative(dirname(resolve(recording.outPath)), schemaAbs));
|
|
122
|
-
|
|
123
|
-
const built: Script & { $schema?: string } = {
|
|
124
|
-
$schema: schemaRel,
|
|
125
|
-
name: recording.name,
|
|
126
|
-
launch: recording.launch,
|
|
127
|
-
steps: recording.steps,
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const parsed = scriptSchema.parse(built) as Script;
|
|
131
|
-
const script = { ...parsed, $schema: built.$schema };
|
|
132
|
-
|
|
133
|
-
const goldenPaths = recording.goldenWrites.map((w) => w.path);
|
|
134
|
-
|
|
135
|
-
if (writeFiles) {
|
|
136
|
-
writeOrThrow(recording.outPath, `${JSON.stringify(script, null, 2)}\n`, recording.overwrite);
|
|
137
|
-
|
|
138
|
-
for (const w of recording.goldenWrites) {
|
|
139
|
-
writeOrThrow(w.path, `${w.text}\n`, recording.overwrite);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return { scriptPath: writeFiles ? recording.outPath : undefined, goldenPaths, script };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
status(): ScriptRecordingStatus {
|
|
147
|
-
if (!this.active) {
|
|
148
|
-
throw new Error("no active recording");
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
recordingId: this.active.id,
|
|
152
|
-
name: this.active.name,
|
|
153
|
-
outPath: this.active.outPath,
|
|
154
|
-
goldenDir: this.active.goldenDir,
|
|
155
|
-
hasLaunch: this.active.launch !== null,
|
|
156
|
-
stepCount: this.active.steps.length,
|
|
157
|
-
checkpointCount: this.active.checkpointIndex,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
recordLaunch(args: Script["launch"], sessionId: string): void {
|
|
162
|
-
const rec = this.active;
|
|
163
|
-
if (!rec) return;
|
|
164
|
-
|
|
165
|
-
if (rec.launch) return;
|
|
166
|
-
rec.launch = args;
|
|
167
|
-
rec.sessionId = sessionId;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
recordStep(step: ScriptStep): void {
|
|
171
|
-
const rec = this.active;
|
|
172
|
-
if (!rec) return;
|
|
173
|
-
rec.steps.push(step);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async recordCheckpoint(args: { session: TerminalSession; label?: string }): Promise<void> {
|
|
177
|
-
const rec = this.active;
|
|
178
|
-
if (!rec) return;
|
|
179
|
-
if (!rec.sessionId || rec.sessionId !== args.session.id) return;
|
|
180
|
-
|
|
181
|
-
const label = (args.label ?? "").trim();
|
|
182
|
-
const safe = sanitizeLabel(label || `checkpoint_${rec.checkpointIndex + 1}`);
|
|
183
|
-
rec.checkpointIndex += 1;
|
|
184
|
-
|
|
185
|
-
const snapshot = await args.session.snapshotText({
|
|
186
|
-
scope: rec.checkpoint.scope,
|
|
187
|
-
trimRight: rec.checkpoint.trimRight,
|
|
188
|
-
trimBottom: rec.checkpoint.trimBottom,
|
|
189
|
-
captureFrame: true,
|
|
190
|
-
mask: rec.checkpoint.mask,
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const goldenPath = toPosixPath(resolvePathLike(joinPosix(rec.goldenDir, `${safe}.txt`), false));
|
|
194
|
-
rec.goldenWrites.push({ path: goldenPath, text: snapshot.text });
|
|
195
|
-
|
|
196
|
-
rec.steps.push({
|
|
197
|
-
type: "snapshot",
|
|
198
|
-
kind: "text",
|
|
199
|
-
scope: rec.checkpoint.scope,
|
|
200
|
-
trimRight: rec.checkpoint.trimRight,
|
|
201
|
-
trimBottom: rec.checkpoint.trimBottom,
|
|
202
|
-
mask: rec.checkpoint.mask,
|
|
203
|
-
} as Extract<ScriptStep, { type: "snapshot" }>);
|
|
204
|
-
|
|
205
|
-
rec.steps.push({
|
|
206
|
-
type: "expectGolden",
|
|
207
|
-
path: goldenPath,
|
|
208
|
-
} as Extract<ScriptStep, { type: "expectGolden" }>);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function writeOrThrow(path: string, text: string, overwrite: boolean): void {
|
|
213
|
-
const abs = resolvePathLike(path, true);
|
|
214
|
-
if (!overwrite && existsSync(abs)) {
|
|
215
|
-
throw new Error(`refusing to overwrite: ${path}`);
|
|
216
|
-
}
|
|
217
|
-
mkdirSync(dirname(abs), { recursive: true });
|
|
218
|
-
writeFileSync(abs, text, "utf8");
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function resolvePathLike(path: string, absolute: boolean): string {
|
|
222
|
-
if (!absolute) return toPosixPath(path);
|
|
223
|
-
return resolve(process.cwd(), path);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function sanitizeLabel(label: string): string {
|
|
227
|
-
return label.replace(/[^a-z0-9._-]+/gi, "_").replace(/^_+|_+$/g, "") || "checkpoint";
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function toPosixPath(path: string): string {
|
|
231
|
-
return path.replace(/\\/g, "/");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function joinPosix(a: string, b: string): string {
|
|
235
|
-
const left = a.replace(/\\/g, "/").replace(/\/+$/g, "");
|
|
236
|
-
const right = b.replace(/\\/g, "/").replace(/^\/+/g, "");
|
|
237
|
-
return `${left}/${right}`;
|
|
238
|
-
}
|