openuispec 0.2.8 → 0.2.10
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 +50 -385
- package/cli/index.ts +77 -1
- package/cli/init.ts +9 -4
- package/docs/cli.md +160 -0
- package/docs/file-formats.md +84 -0
- package/examples/taskflow/generated/ios/TaskFlow/project.yml +5 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +45 -6
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +89 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +13 -6
- package/mcp-server/index.ts +94 -3
- package/mcp-server/screenshot-android.ts +199 -65
- package/mcp-server/screenshot-ios.ts +242 -30
- package/mcp-server/screenshot.ts +80 -0
- package/package.json +1 -1
|
@@ -233,6 +233,45 @@ async function setAppearance(udid: string, theme: "light" | "dark"): Promise<voi
|
|
|
233
233
|
const UITEST_TARGET = "ScreenshotUITests";
|
|
234
234
|
const UITEST_DIR = ".screenshot-uitest";
|
|
235
235
|
|
|
236
|
+
export function generateUITestTargetYml(
|
|
237
|
+
appInfo: IOSAppInfo,
|
|
238
|
+
sourcePath: string,
|
|
239
|
+
includeProductName = false,
|
|
240
|
+
): string {
|
|
241
|
+
const productLines = includeProductName
|
|
242
|
+
? `\n PRODUCT_NAME: ${UITEST_TARGET}\n PRODUCT_MODULE_NAME: ${UITEST_TARGET}`
|
|
243
|
+
: "";
|
|
244
|
+
return ` ${UITEST_TARGET}:
|
|
245
|
+
type: bundle.ui-testing
|
|
246
|
+
platform: iOS
|
|
247
|
+
deploymentTarget: "${appInfo.deploymentTarget}"
|
|
248
|
+
sources:
|
|
249
|
+
- path: ${sourcePath}
|
|
250
|
+
dependencies:
|
|
251
|
+
- target: ${appInfo.schemeName}
|
|
252
|
+
embed: false
|
|
253
|
+
settings:
|
|
254
|
+
base:${productLines}
|
|
255
|
+
TEST_TARGET_NAME: ${appInfo.schemeName}
|
|
256
|
+
PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
|
|
257
|
+
GENERATE_INFOPLIST_FILE: YES`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function insertUITestTarget(yml: string, targetYml: string): string {
|
|
261
|
+
if (yml.includes("\nschemes:")) {
|
|
262
|
+
return yml.replace("\nschemes:", `\n${targetYml}\nschemes:`);
|
|
263
|
+
}
|
|
264
|
+
return yml + "\n" + targetYml + "\n";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function ensureInfoPlistFlag(yml: string): string {
|
|
268
|
+
if (yml.includes("GENERATE_INFOPLIST_FILE")) return yml;
|
|
269
|
+
return yml.replace(
|
|
270
|
+
/(PRODUCT_BUNDLE_IDENTIFIER:[^\n]+\n)/,
|
|
271
|
+
"$1 GENERATE_INFOPLIST_FILE: YES\n",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
236
275
|
function generateUITestSwift(
|
|
237
276
|
bundleId: string,
|
|
238
277
|
navSteps: string[],
|
|
@@ -322,40 +361,13 @@ async function runXCUITest(
|
|
|
322
361
|
|
|
323
362
|
if (appInfo.hasXcodegen) {
|
|
324
363
|
originalProjectYml = readFileSync(projectYmlPath, "utf-8");
|
|
364
|
+
let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
|
|
365
|
+
modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, `${UITEST_DIR}/Sources`));
|
|
366
|
+
writeFileSync(projectYmlPath, modifiedYml);
|
|
325
367
|
|
|
326
|
-
// Ensure main target has GENERATE_INFOPLIST_FILE and append UI test target
|
|
327
|
-
let modifiedYml = originalProjectYml;
|
|
328
|
-
if (!modifiedYml.includes("GENERATE_INFOPLIST_FILE")) {
|
|
329
|
-
// Add after the first PRODUCT_BUNDLE_IDENTIFIER line
|
|
330
|
-
modifiedYml = modifiedYml.replace(
|
|
331
|
-
/(PRODUCT_BUNDLE_IDENTIFIER:[^\n]+\n)/,
|
|
332
|
-
"$1 GENERATE_INFOPLIST_FILE: YES\n",
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const uitestConfig = `
|
|
337
|
-
${UITEST_TARGET}:
|
|
338
|
-
type: bundle.ui-testing
|
|
339
|
-
platform: iOS
|
|
340
|
-
deploymentTarget: "${appInfo.deploymentTarget}"
|
|
341
|
-
sources:
|
|
342
|
-
- path: ${UITEST_DIR}/Sources
|
|
343
|
-
dependencies:
|
|
344
|
-
- target: ${appInfo.schemeName}
|
|
345
|
-
embed: false
|
|
346
|
-
settings:
|
|
347
|
-
base:
|
|
348
|
-
TEST_TARGET_NAME: ${appInfo.schemeName}
|
|
349
|
-
PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
|
|
350
|
-
GENERATE_INFOPLIST_FILE: YES
|
|
351
|
-
`;
|
|
352
|
-
writeFileSync(projectYmlPath, modifiedYml + uitestConfig);
|
|
353
|
-
|
|
354
|
-
// Regenerate Xcode project
|
|
355
368
|
try {
|
|
356
369
|
await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
|
|
357
370
|
} catch (err: any) {
|
|
358
|
-
// Restore original project.yml
|
|
359
371
|
writeFileSync(projectYmlPath, originalProjectYml);
|
|
360
372
|
throw new Error(`xcodegen failed: ${((err.stderr ?? "") + (err.stdout ?? "")).slice(-300)}`);
|
|
361
373
|
}
|
|
@@ -539,3 +551,203 @@ export async function takeIOSScreenshot(
|
|
|
539
551
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
540
552
|
}
|
|
541
553
|
}
|
|
554
|
+
|
|
555
|
+
// ── batch types ──────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
export interface IOSBatchCapture {
|
|
558
|
+
screen: string;
|
|
559
|
+
nav?: string[];
|
|
560
|
+
wait_for?: number;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export interface IOSScreenshotBatchOptions {
|
|
564
|
+
captures: IOSBatchCapture[];
|
|
565
|
+
device?: string;
|
|
566
|
+
theme?: "light" | "dark";
|
|
567
|
+
output_dir?: string;
|
|
568
|
+
project_dir?: string;
|
|
569
|
+
scheme?: string;
|
|
570
|
+
bundle_id?: string;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── batch screenshot ─────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
export async function takeIOSScreenshotBatch(
|
|
576
|
+
projectCwd: string,
|
|
577
|
+
options: IOSScreenshotBatchOptions,
|
|
578
|
+
): Promise<ScreenshotResult> {
|
|
579
|
+
const { captures, device, theme, output_dir, project_dir, scheme, bundle_id } = options;
|
|
580
|
+
|
|
581
|
+
if (captures.length === 0) {
|
|
582
|
+
return { content: [{ type: "text", text: "No iOS captures specified." }], isError: true };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const iosDir = findIOSAppDir(projectCwd, project_dir);
|
|
586
|
+
const appInfo = extractAppInfo(iosDir, { scheme, bundle_id });
|
|
587
|
+
const sim = findSimulator(device);
|
|
588
|
+
await ensureSimulatorBooted(sim.udid);
|
|
589
|
+
|
|
590
|
+
if (theme) {
|
|
591
|
+
await setAppearance(sim.udid, theme);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const themeLabel = theme ?? "default";
|
|
595
|
+
const snapshots: Array<{ screen: string; path: string; data: string }> = [];
|
|
596
|
+
|
|
597
|
+
// Separate captures: no-nav (simctl screenshot) vs nav (XCUITest batch)
|
|
598
|
+
const noNavCaptures = captures.filter((c) => !c.nav || c.nav.length === 0);
|
|
599
|
+
const navCaptures = captures.filter((c) => c.nav && c.nav.length > 0);
|
|
600
|
+
|
|
601
|
+
// Build + install once for all captures
|
|
602
|
+
const appBundlePath = await buildApp(iosDir, appInfo, sim.udid);
|
|
603
|
+
await installAndLaunch(sim.udid, appBundlePath, appInfo.bundleId);
|
|
604
|
+
|
|
605
|
+
// Pre-create output dir once
|
|
606
|
+
if (output_dir) mkdirSync(resolve(iosDir, output_dir), { recursive: true });
|
|
607
|
+
|
|
608
|
+
// No-nav captures: relaunch, wait, simctl screenshot
|
|
609
|
+
for (const capture of noNavCaptures) {
|
|
610
|
+
// Relaunch without reinstalling
|
|
611
|
+
try { await exec(`xcrun simctl terminate ${sim.udid} ${appInfo.bundleId}`); } catch { /* not running */ }
|
|
612
|
+
await exec(`xcrun simctl launch ${sim.udid} ${appInfo.bundleId}`, { timeout: 30_000 });
|
|
613
|
+
await waitForAppReady(sim.udid, appInfo.bundleId, capture.wait_for ?? 3000);
|
|
614
|
+
|
|
615
|
+
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
616
|
+
const tmpPath = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
|
|
617
|
+
await captureScreenshot(sim.udid, tmpPath);
|
|
618
|
+
|
|
619
|
+
if (!existsSync(tmpPath)) continue;
|
|
620
|
+
|
|
621
|
+
let savedPath = filename;
|
|
622
|
+
if (output_dir) {
|
|
623
|
+
savedPath = join(resolve(iosDir, output_dir), filename);
|
|
624
|
+
copyFileSync(tmpPath, savedPath);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
snapshots.push({ screen: capture.screen, path: savedPath, data: readFileSync(tmpPath).toString("base64") });
|
|
628
|
+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Nav captures: batch into a single XCUITest run
|
|
632
|
+
if (navCaptures.length > 0) {
|
|
633
|
+
const uitestDir = join(iosDir, ".screenshot-uitest");
|
|
634
|
+
const sourcesDir = join(uitestDir, "Sources");
|
|
635
|
+
mkdirSync(sourcesDir, { recursive: true });
|
|
636
|
+
|
|
637
|
+
// Build output paths map
|
|
638
|
+
const outputPaths: Record<string, string> = {};
|
|
639
|
+
for (const capture of navCaptures) {
|
|
640
|
+
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
641
|
+
if (output_dir) {
|
|
642
|
+
const outDir = resolve(iosDir, output_dir);
|
|
643
|
+
mkdirSync(outDir, { recursive: true });
|
|
644
|
+
outputPaths[capture.screen] = join(outDir, filename);
|
|
645
|
+
} else {
|
|
646
|
+
outputPaths[capture.screen] = join(iosDir, `.openuispec-screenshot-${capture.screen}.png`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Generate multi-test Swift file
|
|
651
|
+
const testCases = navCaptures.map((capture, i) => {
|
|
652
|
+
const taps = (capture.nav ?? []).map((step, j) => {
|
|
653
|
+
const escaped = step.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
654
|
+
return `
|
|
655
|
+
let target_${i}_${j} = app.descendants(matching: .any).matching(NSPredicate(format: "label ==[c] %@ OR title ==[c] %@", "${escaped}", "${escaped}")).firstMatch
|
|
656
|
+
if target_${i}_${j}.waitForExistence(timeout: 5) {
|
|
657
|
+
target_${i}_${j}.tap()
|
|
658
|
+
Thread.sleep(forTimeInterval: 0.8)
|
|
659
|
+
}`;
|
|
660
|
+
}).join("\n");
|
|
661
|
+
|
|
662
|
+
const outPath = outputPaths[capture.screen].replace(/"/g, '\\"');
|
|
663
|
+
return `
|
|
664
|
+
func test_${String(i + 1).padStart(2, "0")}_${capture.screen}() {
|
|
665
|
+
let app = XCUIApplication()
|
|
666
|
+
app.launchArguments = ["-AppleLanguages", "(en)"]
|
|
667
|
+
app.launch()
|
|
668
|
+
Thread.sleep(forTimeInterval: ${((capture.wait_for ?? 3000) / 1000).toFixed(1)})
|
|
669
|
+
${taps}
|
|
670
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
671
|
+
let screenshot = XCUIScreen.main.screenshot()
|
|
672
|
+
try! screenshot.pngRepresentation.write(to: URL(fileURLWithPath: "${outPath}"))
|
|
673
|
+
}`;
|
|
674
|
+
}).join("\n");
|
|
675
|
+
|
|
676
|
+
writeFileSync(join(sourcesDir, "ScreenshotUITest.swift"),
|
|
677
|
+
`import XCTest\n\nfinal class ScreenshotUITest: XCTestCase {\n${testCases}\n}\n`);
|
|
678
|
+
|
|
679
|
+
// Set up xcodegen
|
|
680
|
+
const UITEST_TARGET = "ScreenshotUITests";
|
|
681
|
+
const hasXcodegen = existsSync(join(iosDir, "project.yml"));
|
|
682
|
+
const projectYmlPath = join(iosDir, "project.yml");
|
|
683
|
+
let originalProjectYml: string | null = null;
|
|
684
|
+
const buildDir = join(iosDir, ".build", "screenshot");
|
|
685
|
+
|
|
686
|
+
if (hasXcodegen) {
|
|
687
|
+
originalProjectYml = readFileSync(projectYmlPath, "utf-8");
|
|
688
|
+
let modifiedYml = ensureInfoPlistFlag(originalProjectYml);
|
|
689
|
+
modifiedYml = insertUITestTarget(modifiedYml, generateUITestTargetYml(appInfo, ".screenshot-uitest/Sources", true));
|
|
690
|
+
writeFileSync(projectYmlPath, modifiedYml);
|
|
691
|
+
await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 });
|
|
692
|
+
} else {
|
|
693
|
+
writeFileSync(join(uitestDir, "project.yml"), `name: ${UITEST_TARGET}
|
|
694
|
+
targets:
|
|
695
|
+
${UITEST_TARGET}:
|
|
696
|
+
type: bundle.ui-testing
|
|
697
|
+
platform: iOS
|
|
698
|
+
deploymentTarget: "${appInfo.deploymentTarget}"
|
|
699
|
+
sources:
|
|
700
|
+
- path: Sources
|
|
701
|
+
settings:
|
|
702
|
+
base:
|
|
703
|
+
TEST_TARGET_NAME: ${appInfo.schemeName}
|
|
704
|
+
PRODUCT_BUNDLE_IDENTIFIER: ${appInfo.bundleId}.uitests
|
|
705
|
+
GENERATE_INFOPLIST_FILE: YES
|
|
706
|
+
`);
|
|
707
|
+
await exec(`xcodegen generate`, { cwd: uitestDir, timeout: 30_000 });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const testProjectFlag = hasXcodegen
|
|
711
|
+
? (appInfo.xcodeproj ? `-project "${join(iosDir, appInfo.xcodeproj)}"` : "")
|
|
712
|
+
: `-project "${join(uitestDir, `${UITEST_TARGET}.xcodeproj`)}"`;
|
|
713
|
+
const testCwd = hasXcodegen ? iosDir : uitestDir;
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
await exec(
|
|
717
|
+
`xcodebuild test ${testProjectFlag} -scheme "${UITEST_TARGET}" -destination "id=${sim.udid}" -derivedDataPath "${buildDir}" -only-testing:${UITEST_TARGET}/ScreenshotUITest 2>&1`,
|
|
718
|
+
{ cwd: testCwd, timeout: 300_000 },
|
|
719
|
+
);
|
|
720
|
+
} catch {
|
|
721
|
+
// Tests may "fail" but still produce screenshots
|
|
722
|
+
} finally {
|
|
723
|
+
if (originalProjectYml) {
|
|
724
|
+
writeFileSync(projectYmlPath, originalProjectYml);
|
|
725
|
+
try { await exec(`xcodegen generate`, { cwd: iosDir, timeout: 30_000 }); } catch { /* best effort */ }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Collect results
|
|
730
|
+
for (const capture of navCaptures) {
|
|
731
|
+
const outPath = outputPaths[capture.screen];
|
|
732
|
+
if (existsSync(outPath)) {
|
|
733
|
+
snapshots.push({
|
|
734
|
+
screen: capture.screen,
|
|
735
|
+
path: output_dir ? outPath : `${capture.screen}_${themeLabel}.png`,
|
|
736
|
+
data: readFileSync(outPath).toString("base64"),
|
|
737
|
+
});
|
|
738
|
+
if (!output_dir) { try { unlinkSync(outPath); } catch { /* ignore */ } }
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (snapshots.length === 0) {
|
|
744
|
+
return { content: [{ type: "text", text: "No screenshots were captured. Check Xcode and Simulator output." }], isError: true };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const content: ScreenshotResult["content"] = [];
|
|
748
|
+
for (const s of snapshots) {
|
|
749
|
+
content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
|
|
750
|
+
content.push({ type: "text" as const, text: JSON.stringify({ screen: s.screen, path: s.path, simulator: sim.name, theme: themeLabel, bundleId: appInfo.bundleId }, null, 2) });
|
|
751
|
+
}
|
|
752
|
+
return { content };
|
|
753
|
+
}
|
package/mcp-server/screenshot.ts
CHANGED
|
@@ -258,6 +258,86 @@ export async function takeScreenshot(
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
// ── batch types ──────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export interface WebBatchCapture {
|
|
264
|
+
screen: string;
|
|
265
|
+
route: string;
|
|
266
|
+
selector?: string;
|
|
267
|
+
full_page?: boolean;
|
|
268
|
+
wait_for?: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface WebScreenshotBatchOptions {
|
|
272
|
+
captures: WebBatchCapture[];
|
|
273
|
+
viewport?: { width: number; height: number };
|
|
274
|
+
theme?: "light" | "dark";
|
|
275
|
+
output_dir?: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── batch screenshot ─────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
export async function takeScreenshotBatch(
|
|
281
|
+
projectCwd: string,
|
|
282
|
+
options: WebScreenshotBatchOptions,
|
|
283
|
+
): Promise<ScreenshotResult> {
|
|
284
|
+
const { captures, viewport = { width: 1280, height: 800 }, theme, output_dir } = options;
|
|
285
|
+
|
|
286
|
+
if (captures.length === 0) {
|
|
287
|
+
return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const webDir = findWebAppDir(projectCwd);
|
|
291
|
+
const server = await startDevServer(webDir);
|
|
292
|
+
const browser = await getBrowser();
|
|
293
|
+
const page = await browser.newPage();
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
await page.setViewport({ width: viewport.width, height: viewport.height });
|
|
297
|
+
if (theme) {
|
|
298
|
+
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const base = server.url.replace(/\/+$/, "");
|
|
302
|
+
const themeLabel = theme ?? "default";
|
|
303
|
+
const snapshots: Array<{ screen: string; path: string; data: string }> = [];
|
|
304
|
+
|
|
305
|
+
for (const capture of captures) {
|
|
306
|
+
const targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
|
|
307
|
+
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
308
|
+
await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
|
|
309
|
+
|
|
310
|
+
let buffer: Buffer;
|
|
311
|
+
if (capture.selector) {
|
|
312
|
+
const el = await page.$(capture.selector);
|
|
313
|
+
buffer = el ? await el.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
|
|
314
|
+
} else {
|
|
315
|
+
buffer = await page.screenshot({ type: "png", fullPage: capture.full_page ?? false });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
319
|
+
let savedPath = filename;
|
|
320
|
+
if (output_dir) {
|
|
321
|
+
const outDir = resolve(webDir, output_dir);
|
|
322
|
+
mkdirSync(outDir, { recursive: true });
|
|
323
|
+
savedPath = join(outDir, filename);
|
|
324
|
+
writeFileSync(savedPath, buffer);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64") });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const content: ScreenshotResult["content"] = [];
|
|
331
|
+
for (const s of snapshots) {
|
|
332
|
+
content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
|
|
333
|
+
content.push({ type: "text" as const, text: JSON.stringify({ screen: s.screen, path: s.path, theme: themeLabel }, null, 2) });
|
|
334
|
+
}
|
|
335
|
+
return { content };
|
|
336
|
+
} finally {
|
|
337
|
+
await page.close();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
261
341
|
// ── cleanup ─────────────────────────────────────────────────────────
|
|
262
342
|
|
|
263
343
|
export async function shutdownAll() {
|