openlattice-modal 0.0.3
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/config.d.ts +8 -0
- package/dist/config.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/modal-provider.d.ts +26 -0
- package/dist/modal-provider.js +363 -0
- package/package.json +37 -0
- package/src/config.ts +8 -0
- package/src/index.ts +2 -0
- package/src/modal-provider.ts +435 -0
- package/tests/conformance.test.ts +24 -0
- package/tests/integration.test.ts +131 -0
- package/tests/modal-provider.test.ts +461 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ModalProvider } from "../src/modal-provider";
|
|
3
|
+
|
|
4
|
+
// ── Mock modal SDK ──────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => {
|
|
7
|
+
const mockExec = vi.fn();
|
|
8
|
+
const mockTerminate = vi.fn();
|
|
9
|
+
const mockSnapshotFilesystem = vi.fn();
|
|
10
|
+
|
|
11
|
+
const mockSandbox = {
|
|
12
|
+
sandboxId: "sb-test-123",
|
|
13
|
+
exec: mockExec,
|
|
14
|
+
terminate: mockTerminate,
|
|
15
|
+
snapshotFilesystem: mockSnapshotFilesystem,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mockCreate = vi.fn().mockResolvedValue(mockSandbox);
|
|
19
|
+
const mockFromId = vi.fn().mockResolvedValue(mockSandbox);
|
|
20
|
+
const mockFromName = vi.fn().mockResolvedValue({ appId: "app-123" });
|
|
21
|
+
const mockFromRegistry = vi.fn().mockReturnValue({ imageId: "img-123" });
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
mockExec,
|
|
25
|
+
mockTerminate,
|
|
26
|
+
mockSnapshotFilesystem,
|
|
27
|
+
mockSandbox,
|
|
28
|
+
mockCreate,
|
|
29
|
+
mockFromId,
|
|
30
|
+
mockFromName,
|
|
31
|
+
mockFromRegistry,
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
vi.mock("modal", () => ({
|
|
36
|
+
ModalClient: vi.fn(function (this: any) {
|
|
37
|
+
this.apps = { fromName: mocks.mockFromName };
|
|
38
|
+
this.images = { fromRegistry: mocks.mockFromRegistry };
|
|
39
|
+
this.sandboxes = {
|
|
40
|
+
create: mocks.mockCreate,
|
|
41
|
+
fromId: mocks.mockFromId,
|
|
42
|
+
};
|
|
43
|
+
return this;
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function setupExec(stdout: string, stderr: string, exitCode: number) {
|
|
50
|
+
mocks.mockExec.mockResolvedValue({
|
|
51
|
+
stdout: { readText: () => Promise.resolve(stdout) },
|
|
52
|
+
stderr: { readText: () => Promise.resolve(stderr) },
|
|
53
|
+
wait: () => Promise.resolve(exitCode),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Tests ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe("ModalProvider", () => {
|
|
60
|
+
let provider: ModalProvider;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
mocks.mockFromName.mockResolvedValue({ appId: "app-123" });
|
|
65
|
+
mocks.mockFromRegistry.mockReturnValue({ imageId: "img-123" });
|
|
66
|
+
mocks.mockCreate.mockResolvedValue(mocks.mockSandbox);
|
|
67
|
+
mocks.mockFromId.mockResolvedValue(mocks.mockSandbox);
|
|
68
|
+
mocks.mockTerminate.mockResolvedValue(undefined);
|
|
69
|
+
mocks.mockSnapshotFilesystem.mockResolvedValue({
|
|
70
|
+
imageId: "snap-img-456",
|
|
71
|
+
});
|
|
72
|
+
setupExec("", "", 0);
|
|
73
|
+
provider = new ModalProvider();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("constructor", () => {
|
|
77
|
+
it("sets name to 'modal'", () => {
|
|
78
|
+
expect(provider.name).toBe("modal");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("declares expected capabilities", () => {
|
|
82
|
+
expect(provider.capabilities.restart).toBe(false);
|
|
83
|
+
expect(provider.capabilities.pause).toBe(false);
|
|
84
|
+
expect(provider.capabilities.snapshot).toBe(true);
|
|
85
|
+
expect(provider.capabilities.gpu).toBe(true);
|
|
86
|
+
expect(provider.capabilities.logs).toBe(false);
|
|
87
|
+
expect(provider.capabilities.tailscale).toBe(true);
|
|
88
|
+
expect(provider.capabilities.architectures).toContain("x86_64");
|
|
89
|
+
expect(provider.capabilities.persistentStorage).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("has GPU types in constraints", () => {
|
|
93
|
+
expect(provider.capabilities.constraints?.gpuTypes).toContain("A100");
|
|
94
|
+
expect(provider.capabilities.constraints?.gpuTypes).toContain("H100");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("provision", () => {
|
|
99
|
+
it("creates a sandbox and returns node info", async () => {
|
|
100
|
+
const node = await provider.provision({
|
|
101
|
+
runtime: { image: "python:3.12" },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(node.externalId).toBe("sb-test-123");
|
|
105
|
+
expect(mocks.mockCreate).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("resolves OCI images via fromRegistry", async () => {
|
|
109
|
+
await provider.provision({
|
|
110
|
+
runtime: { image: "python:3.12-slim" },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(mocks.mockFromRegistry).toHaveBeenCalledWith(
|
|
114
|
+
"python:3.12-slim"
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("passes GPU type to sandbox create", async () => {
|
|
119
|
+
await provider.provision({
|
|
120
|
+
runtime: { image: "python:3.12" },
|
|
121
|
+
gpu: { type: "A100", count: 1 },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(mocks.mockCreate).toHaveBeenCalledWith(
|
|
125
|
+
expect.anything(),
|
|
126
|
+
expect.anything(),
|
|
127
|
+
expect.objectContaining({ gpu: "A100" })
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("defaults GPU to T4 when type not specified", async () => {
|
|
132
|
+
await provider.provision({
|
|
133
|
+
runtime: { image: "python:3.12" },
|
|
134
|
+
gpu: { count: 1 },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(mocks.mockCreate).toHaveBeenCalledWith(
|
|
138
|
+
expect.anything(),
|
|
139
|
+
expect.anything(),
|
|
140
|
+
expect.objectContaining({ gpu: "T4" })
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("passes timeout from duration spec", async () => {
|
|
145
|
+
await provider.provision({
|
|
146
|
+
runtime: { image: "python:3.12" },
|
|
147
|
+
duration: { maxSeconds: 3600 },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(mocks.mockCreate).toHaveBeenCalledWith(
|
|
151
|
+
expect.anything(),
|
|
152
|
+
expect.anything(),
|
|
153
|
+
expect.objectContaining({ timeout: 3600 })
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("passes workdir from runtime spec", async () => {
|
|
158
|
+
await provider.provision({
|
|
159
|
+
runtime: { image: "python:3.12", workdir: "/app" },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(mocks.mockCreate).toHaveBeenCalledWith(
|
|
163
|
+
expect.anything(),
|
|
164
|
+
expect.anything(),
|
|
165
|
+
expect.objectContaining({ workdir: "/app" })
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("runs initial command if specified", async () => {
|
|
170
|
+
setupExec("setup done\n", "", 0);
|
|
171
|
+
|
|
172
|
+
await provider.provision({
|
|
173
|
+
runtime: {
|
|
174
|
+
image: "python:3.12",
|
|
175
|
+
command: ["pip", "install", "flask"],
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(mocks.mockExec).toHaveBeenCalledWith(
|
|
180
|
+
["pip", "install", "flask"],
|
|
181
|
+
expect.objectContaining({ stdout: "pipe", stderr: "pipe" })
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("calls tailscale up via sandbox exec when tailscaleAuthKey is set", async () => {
|
|
186
|
+
setupExec("", "", 0);
|
|
187
|
+
|
|
188
|
+
await provider.provision({
|
|
189
|
+
runtime: { image: "python:3.12" },
|
|
190
|
+
network: { tailscaleAuthKey: "tskey-auth-abc123" },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Should have called exec with tailscale up command
|
|
194
|
+
const calls = mocks.mockExec.mock.calls;
|
|
195
|
+
const hasTailscaleUp = calls.some(
|
|
196
|
+
(c: any[]) => {
|
|
197
|
+
const cmd = Array.isArray(c[0]) ? c[0].join(" ") : "";
|
|
198
|
+
return cmd.includes("tailscale up") && cmd.includes("tskey-auth-abc123");
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
expect(hasTailscaleUp).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("exec", () => {
|
|
206
|
+
it("executes command in sandbox", async () => {
|
|
207
|
+
const node = await provider.provision({
|
|
208
|
+
runtime: { image: "python:3.12" },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
setupExec("hello\n", "", 0);
|
|
212
|
+
|
|
213
|
+
const result = await provider.exec(node.externalId, [
|
|
214
|
+
"echo",
|
|
215
|
+
"hello",
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
expect(result.exitCode).toBe(0);
|
|
219
|
+
expect(result.stdout).toBe("hello\n");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("captures stderr and exit codes", async () => {
|
|
223
|
+
const node = await provider.provision({
|
|
224
|
+
runtime: { image: "python:3.12" },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
setupExec("", "error occurred\n", 1);
|
|
228
|
+
|
|
229
|
+
const result = await provider.exec(node.externalId, [
|
|
230
|
+
"sh",
|
|
231
|
+
"-c",
|
|
232
|
+
"exit 1",
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
expect(result.exitCode).toBe(1);
|
|
236
|
+
expect(result.stderr).toBe("error occurred\n");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("wraps command with cwd and env", async () => {
|
|
240
|
+
const node = await provider.provision({
|
|
241
|
+
runtime: { image: "python:3.12" },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
setupExec("", "", 0);
|
|
245
|
+
|
|
246
|
+
await provider.exec(node.externalId, ["ls"], {
|
|
247
|
+
cwd: "/app",
|
|
248
|
+
env: { FOO: "bar" },
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(mocks.mockExec).toHaveBeenCalledWith(
|
|
252
|
+
["sh", "-c", "cd /app && export FOO='bar' && ls"],
|
|
253
|
+
expect.anything()
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("passes timeout to exec options", async () => {
|
|
258
|
+
const node = await provider.provision({
|
|
259
|
+
runtime: { image: "python:3.12" },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
setupExec("", "", 0);
|
|
263
|
+
|
|
264
|
+
await provider.exec(node.externalId, ["sleep", "1"], {
|
|
265
|
+
timeoutMs: 30000,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(mocks.mockExec).toHaveBeenCalledWith(
|
|
269
|
+
expect.anything(),
|
|
270
|
+
expect.objectContaining({ timeout: 30 })
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("calls onStdout and onStderr callbacks", async () => {
|
|
275
|
+
const node = await provider.provision({
|
|
276
|
+
runtime: { image: "python:3.12" },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
setupExec("out\n", "err\n", 0);
|
|
280
|
+
|
|
281
|
+
const onStdout = vi.fn();
|
|
282
|
+
const onStderr = vi.fn();
|
|
283
|
+
|
|
284
|
+
await provider.exec(node.externalId, ["cmd"], {
|
|
285
|
+
onStdout,
|
|
286
|
+
onStderr,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(onStdout).toHaveBeenCalledWith("out\n");
|
|
290
|
+
expect(onStderr).toHaveBeenCalledWith("err\n");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("destroy", () => {
|
|
295
|
+
it("terminates the sandbox", async () => {
|
|
296
|
+
const node = await provider.provision({
|
|
297
|
+
runtime: { image: "python:3.12" },
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await provider.destroy(node.externalId);
|
|
301
|
+
|
|
302
|
+
expect(mocks.mockTerminate).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("is idempotent when sandbox not found", async () => {
|
|
306
|
+
mocks.mockFromId.mockRejectedValue(new Error("not found"));
|
|
307
|
+
|
|
308
|
+
await expect(
|
|
309
|
+
provider.destroy("nonexistent")
|
|
310
|
+
).resolves.toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("is idempotent when terminate fails", async () => {
|
|
314
|
+
const node = await provider.provision({
|
|
315
|
+
runtime: { image: "python:3.12" },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
mocks.mockTerminate.mockRejectedValue(new Error("already terminated"));
|
|
319
|
+
|
|
320
|
+
await expect(
|
|
321
|
+
provider.destroy(node.externalId)
|
|
322
|
+
).resolves.toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("inspect", () => {
|
|
327
|
+
it("returns running when sandbox is active", async () => {
|
|
328
|
+
const node = await provider.provision({
|
|
329
|
+
runtime: { image: "python:3.12" },
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const status = await provider.inspect(node.externalId);
|
|
333
|
+
expect(status.status).toBe("running");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("returns terminated when sandbox not found", async () => {
|
|
337
|
+
mocks.mockFromId.mockRejectedValue(new Error("not found"));
|
|
338
|
+
|
|
339
|
+
const status = await provider.inspect("nonexistent");
|
|
340
|
+
expect(status.status).toBe("terminated");
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe("snapshot / restore", () => {
|
|
345
|
+
it("creates filesystem snapshot", async () => {
|
|
346
|
+
const node = await provider.provision({
|
|
347
|
+
runtime: { image: "python:3.12" },
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const snap = await provider.snapshot(node.externalId);
|
|
351
|
+
|
|
352
|
+
expect(snap.provider).toBe("modal");
|
|
353
|
+
expect(snap.externalId).toBe("snap-img-456");
|
|
354
|
+
expect(snap.type).toBe("filesystem");
|
|
355
|
+
expect(snap.createdAt).toBeInstanceOf(Date);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("restores from snapshot", async () => {
|
|
359
|
+
const restoredSandbox = {
|
|
360
|
+
...mocks.mockSandbox,
|
|
361
|
+
sandboxId: "sb-restored-789",
|
|
362
|
+
};
|
|
363
|
+
mocks.mockCreate.mockResolvedValueOnce(restoredSandbox);
|
|
364
|
+
|
|
365
|
+
const node = await provider.restore({
|
|
366
|
+
provider: "modal",
|
|
367
|
+
externalId: "snap-img-456",
|
|
368
|
+
createdAt: new Date(),
|
|
369
|
+
type: "filesystem",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(node.externalId).toBe("sb-restored-789");
|
|
373
|
+
expect(mocks.mockCreate).toHaveBeenCalledWith(
|
|
374
|
+
expect.anything(),
|
|
375
|
+
expect.objectContaining({ imageId: "snap-img-456" }),
|
|
376
|
+
expect.anything()
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("passes GPU spec to restored sandbox", async () => {
|
|
381
|
+
mocks.mockCreate.mockResolvedValueOnce({
|
|
382
|
+
...mocks.mockSandbox,
|
|
383
|
+
sandboxId: "sb-gpu-restore",
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
await provider.restore(
|
|
387
|
+
{
|
|
388
|
+
provider: "modal",
|
|
389
|
+
externalId: "snap-img-456",
|
|
390
|
+
createdAt: new Date(),
|
|
391
|
+
type: "filesystem",
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
gpu: { type: "H100", count: 1 },
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
expect(mocks.mockCreate).toHaveBeenCalledWith(
|
|
399
|
+
expect.anything(),
|
|
400
|
+
expect.anything(),
|
|
401
|
+
expect.objectContaining({ gpu: "H100" })
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("healthCheck", () => {
|
|
407
|
+
it("returns healthy when Modal is reachable", async () => {
|
|
408
|
+
const health = await provider.healthCheck();
|
|
409
|
+
expect(health.healthy).toBe(true);
|
|
410
|
+
expect(health.latencyMs).toBeTypeOf("number");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("returns unhealthy when Modal is unreachable", async () => {
|
|
414
|
+
mocks.mockFromName.mockRejectedValue(
|
|
415
|
+
new Error("connection refused")
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// Need a fresh provider to trigger new client
|
|
419
|
+
const freshProvider = new ModalProvider();
|
|
420
|
+
const health = await freshProvider.healthCheck();
|
|
421
|
+
expect(health.healthy).toBe(false);
|
|
422
|
+
expect(health.message).toContain("unreachable");
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe("getExtension", () => {
|
|
427
|
+
it("returns files extension", async () => {
|
|
428
|
+
const node = await provider.provision({
|
|
429
|
+
runtime: { image: "python:3.12" },
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const files = provider.getExtension(node.externalId, "files");
|
|
433
|
+
expect(files).toBeDefined();
|
|
434
|
+
expect(files!.read).toBeTypeOf("function");
|
|
435
|
+
expect(files!.write).toBeTypeOf("function");
|
|
436
|
+
expect(files!.list).toBeTypeOf("function");
|
|
437
|
+
expect(files!.remove).toBeTypeOf("function");
|
|
438
|
+
expect(files!.mkdir).toBeTypeOf("function");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("returns network extension", async () => {
|
|
442
|
+
const node = await provider.provision({
|
|
443
|
+
runtime: { image: "python:3.12" },
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const network = provider.getExtension(node.externalId, "network");
|
|
447
|
+
expect(network).toBeDefined();
|
|
448
|
+
expect(network!.getUrl).toBeTypeOf("function");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("returns undefined for unsupported extensions", async () => {
|
|
452
|
+
const node = await provider.provision({
|
|
453
|
+
runtime: { image: "python:3.12" },
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
expect(
|
|
457
|
+
provider.getExtension(node.externalId, "metrics")
|
|
458
|
+
).toBeUndefined();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"moduleResolution": "node"
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"]
|
|
16
|
+
}
|