@appthrust/kest 0.3.0 → 0.3.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.
@@ -0,0 +1,361 @@
1
+ import type { Event } from "../../../recording";
2
+ import type {
3
+ Action,
4
+ BDDSection,
5
+ CleanupItem,
6
+ Command,
7
+ Report,
8
+ Scenario,
9
+ } from "../model";
10
+
11
+ const bddKeywordByKind = {
12
+ BDDGiven: "given",
13
+ BDDWhen: "when",
14
+ BDDThen: "then",
15
+ BDDAnd: "and",
16
+ BDBut: "but",
17
+ } as const;
18
+
19
+ type BDDEvent = Extract<
20
+ Event,
21
+ { kind: "BDDGiven" | "BDDWhen" | "BDDThen" | "BDDAnd" | "BDBut" }
22
+ >;
23
+
24
+ interface ParseState {
25
+ report: Report;
26
+ currentScenario: Scenario | undefined;
27
+ currentBDDSection: BDDSection | undefined;
28
+ inCleanup: boolean;
29
+ currentAction: Action | undefined;
30
+ currentOverviewIndex: number | undefined;
31
+ currentCleanup: CleanupItem | undefined;
32
+ }
33
+
34
+ export function parseEvents(events: ReadonlyArray<Event>): Report {
35
+ const state: ParseState = {
36
+ report: { scenarios: [] },
37
+ currentScenario: undefined,
38
+ currentBDDSection: undefined,
39
+ inCleanup: false,
40
+ currentAction: undefined,
41
+ currentOverviewIndex: undefined,
42
+ currentCleanup: undefined,
43
+ };
44
+
45
+ for (const event of events) {
46
+ const bdd = bddFromEvent(event);
47
+ if (bdd) {
48
+ handleBDDEvent(state, bdd);
49
+ continue;
50
+ }
51
+ handleNonBDDEvent(state, event);
52
+ }
53
+
54
+ return state.report;
55
+ }
56
+
57
+ function handleNonBDDEvent(state: ParseState, event: Event): void {
58
+ if (event.kind.startsWith("BDD")) {
59
+ return;
60
+ }
61
+
62
+ switch (event.kind) {
63
+ case "ScenarioStart":
64
+ handleScenarioStart(state, event);
65
+ return;
66
+ case "ScenarioEnd":
67
+ clearScenarioProgressState(state);
68
+ return;
69
+ case "RevertingsStart":
70
+ state.inCleanup = true;
71
+ clearCurrentActionState(state);
72
+ return;
73
+ case "RevertingsEnd":
74
+ state.inCleanup = false;
75
+ clearCurrentActionState(state);
76
+ return;
77
+ case "ActionStart":
78
+ handleActionStart(state, event);
79
+ return;
80
+ case "ActionEnd":
81
+ handleActionEnd(state, event);
82
+ return;
83
+ case "CommandRun":
84
+ handleCommandRun(state, event);
85
+ return;
86
+ case "CommandResult":
87
+ handleCommandResult(state, event);
88
+ return;
89
+ case "RetryEnd":
90
+ handleRetryEnd(state, event);
91
+ return;
92
+ case "RetryStart":
93
+ return;
94
+ default:
95
+ return;
96
+ }
97
+ }
98
+
99
+ function handleActionStart(
100
+ state: ParseState,
101
+ event: Extract<Event, { kind: "ActionStart" }>
102
+ ): void {
103
+ const scenario = ensureScenario(state.currentScenario, state.report);
104
+ if (state.inCleanup) {
105
+ const cleanup: CleanupItem = {
106
+ action: event.data.description,
107
+ status: "success",
108
+ command: { cmd: "", args: [], output: "" },
109
+ };
110
+ scenario.cleanup.push(cleanup);
111
+ state.currentCleanup = cleanup;
112
+ state.currentAction = undefined;
113
+ state.currentOverviewIndex = undefined;
114
+ return;
115
+ }
116
+
117
+ const action: Action = { name: event.data.description };
118
+ scenario.overview.push({
119
+ name: event.data.description,
120
+ status: "pending",
121
+ });
122
+ state.currentOverviewIndex = scenario.overview.length - 1;
123
+
124
+ if (state.currentBDDSection) {
125
+ state.currentAction = action;
126
+ state.currentBDDSection.actions.push(action);
127
+ return;
128
+ }
129
+
130
+ // NOTE: keep a shared reference between `state.currentAction` and `details`
131
+ // so that subsequent Command*/Retry*/ActionEnd events update what is shown in
132
+ // the scenario details even when the action is outside a BDD section.
133
+ const taggedAction = { type: "Action" as const, ...action };
134
+ scenario.details.push(taggedAction);
135
+ state.currentAction = taggedAction;
136
+ }
137
+
138
+ function applyRegularActionEnd(
139
+ state: ParseState,
140
+ event: Extract<Event, { kind: "ActionEnd" }>
141
+ ): void {
142
+ const { currentScenario, currentAction } = state;
143
+ if (!currentScenario) {
144
+ return;
145
+ }
146
+ if (!currentAction) {
147
+ return;
148
+ }
149
+
150
+ if (state.currentOverviewIndex !== undefined) {
151
+ const overviewItem = currentScenario.overview[state.currentOverviewIndex];
152
+ if (overviewItem) {
153
+ overviewItem.status = event.data.ok ? "success" : "failure";
154
+ }
155
+ }
156
+
157
+ if (!event.data.ok && event.data.error) {
158
+ currentAction.error = {
159
+ message: {
160
+ text: event.data.error.message,
161
+ language: isDiffLike(event.data.error.message) ? "diff" : "text",
162
+ },
163
+ };
164
+ }
165
+
166
+ state.currentAction = undefined;
167
+ state.currentOverviewIndex = undefined;
168
+ }
169
+
170
+ function handleCommandResult(
171
+ state: ParseState,
172
+ event: Extract<Event, { kind: "CommandResult" }>
173
+ ): void {
174
+ if (state.inCleanup) {
175
+ if (state.currentCleanup) {
176
+ state.currentCleanup.command.output =
177
+ event.data.stdout.length > 0 ? event.data.stdout : event.data.stderr;
178
+ }
179
+ return;
180
+ }
181
+
182
+ const { currentAction } = state;
183
+ if (!currentAction?.command) {
184
+ return;
185
+ }
186
+
187
+ currentAction.command.stdout = {
188
+ text: event.data.stdout,
189
+ ...(event.data.stdoutLanguage
190
+ ? { language: event.data.stdoutLanguage }
191
+ : {}),
192
+ };
193
+ currentAction.command.stderr = {
194
+ text: event.data.stderr,
195
+ ...(event.data.stderrLanguage
196
+ ? { language: event.data.stderrLanguage }
197
+ : {}),
198
+ };
199
+ }
200
+
201
+ function handleCommandRun(
202
+ state: ParseState,
203
+ event: Extract<Event, { kind: "CommandRun" }>
204
+ ): void {
205
+ if (state.inCleanup) {
206
+ if (state.currentCleanup) {
207
+ state.currentCleanup.command = {
208
+ cmd: event.data.cmd,
209
+ args: [...event.data.args],
210
+ output: "",
211
+ };
212
+ }
213
+ return;
214
+ }
215
+
216
+ if (!state.currentAction) {
217
+ return;
218
+ }
219
+ state.currentAction.command = createCommandFromRun(event);
220
+ }
221
+
222
+ function handleScenarioStart(
223
+ state: ParseState,
224
+ event: Extract<Event, { kind: "ScenarioStart" }>
225
+ ): void {
226
+ state.currentScenario = {
227
+ name: event.data.name,
228
+ overview: [],
229
+ details: [],
230
+ cleanup: [],
231
+ };
232
+ state.report.scenarios.push(state.currentScenario);
233
+ clearScenarioProgressState(state);
234
+ }
235
+
236
+ function handleActionEnd(
237
+ state: ParseState,
238
+ event: Extract<Event, { kind: "ActionEnd" }>
239
+ ): void {
240
+ if (handleCleanupActionEnd(state, event)) {
241
+ return;
242
+ }
243
+
244
+ applyRegularActionEnd(state, event);
245
+ }
246
+
247
+ function handleCleanupActionEnd(
248
+ state: ParseState,
249
+ event: Extract<Event, { kind: "ActionEnd" }>
250
+ ): boolean {
251
+ if (!state.inCleanup) {
252
+ return false;
253
+ }
254
+
255
+ if (state.currentCleanup) {
256
+ state.currentCleanup.status = event.data.ok ? "success" : "failure";
257
+ }
258
+ state.currentCleanup = undefined;
259
+ return true;
260
+ }
261
+
262
+ function handleRetryEnd(
263
+ state: ParseState,
264
+ event: Extract<Event, { kind: "RetryEnd" }>
265
+ ): void {
266
+ if (state.inCleanup) {
267
+ return;
268
+ }
269
+ if (!state.currentAction) {
270
+ return;
271
+ }
272
+ state.currentAction.attempts = event.data.attempts;
273
+ }
274
+
275
+ function handleBDDEvent(state: ParseState, bdd: BDDSection): void {
276
+ const scenario = ensureScenario(state.currentScenario, state.report);
277
+ scenario.details.push({
278
+ type: "BDDSection",
279
+ ...bdd,
280
+ });
281
+ state.currentScenario = scenario;
282
+ state.currentBDDSection = bdd;
283
+ }
284
+
285
+ function createCommandFromRun(
286
+ run: Extract<Event, { kind: "CommandRun" }>
287
+ ): Command {
288
+ return {
289
+ cmd: run.data.cmd,
290
+ args: [...run.data.args],
291
+ ...(typeof run.data.stdin === "string"
292
+ ? {
293
+ stdin: {
294
+ text: run.data.stdin,
295
+ ...(run.data.stdinLanguage
296
+ ? { language: run.data.stdinLanguage }
297
+ : {}),
298
+ },
299
+ }
300
+ : {}),
301
+ };
302
+ }
303
+
304
+ function ensureScenario(
305
+ scenario: Scenario | undefined,
306
+ report: Report
307
+ ): Scenario {
308
+ if (scenario) {
309
+ return scenario;
310
+ }
311
+ const created: Scenario = {
312
+ name: "Scenario",
313
+ overview: [],
314
+ details: [],
315
+ cleanup: [],
316
+ };
317
+ report.scenarios.push(created);
318
+ return created;
319
+ }
320
+
321
+ function clearScenarioProgressState(state: ParseState): void {
322
+ state.currentBDDSection = undefined;
323
+ state.inCleanup = false;
324
+ clearCurrentActionState(state);
325
+ }
326
+
327
+ function clearCurrentActionState(state: ParseState): void {
328
+ state.currentAction = undefined;
329
+ state.currentOverviewIndex = undefined;
330
+ state.currentCleanup = undefined;
331
+ }
332
+
333
+ function bddFromEvent(event: Event): BDDSection | undefined {
334
+ if (!isBDDEvent(event)) {
335
+ return undefined;
336
+ }
337
+ const keyword = bddKeywordByKind[event.kind];
338
+ return { keyword, description: event.data.description, actions: [] };
339
+ }
340
+
341
+ function isDiffLike(message: string): boolean {
342
+ const lines = message.split(/\r?\n/);
343
+ let sawPlus = false;
344
+ let sawMinus = false;
345
+ for (const line of lines) {
346
+ if (line.startsWith("+") && !line.startsWith("+++")) {
347
+ sawPlus = true;
348
+ }
349
+ if (line.startsWith("-") && !line.startsWith("---")) {
350
+ sawMinus = true;
351
+ }
352
+ if (sawPlus && sawMinus) {
353
+ return true;
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+
359
+ function isBDDEvent(event: Event): event is BDDEvent {
360
+ return event.kind in bddKeywordByKind;
361
+ }
@@ -0,0 +1,296 @@
1
+ import { codeToANSIForcedColors } from "../../shiki";
2
+ import type { MarkdownReporterOptions } from "../index";
3
+ import type { Action, Report } from "../model";
4
+
5
+ const markdownLang = "markdown";
6
+ const markdownTheme = "catppuccin-mocha";
7
+
8
+ type StdinReplacement = Readonly<{
9
+ placeholder: string;
10
+ stdin: string;
11
+ stdinLanguage: string;
12
+ }>;
13
+
14
+ function normalizeStdin(stdin: string): string {
15
+ // Match `ts/reporter/markdown.ts` behavior: keep content stable.
16
+ return stdin.replace(/^\n/, "").replace(/\s+$/, "");
17
+ }
18
+
19
+ function applyStdinReplacements(
20
+ highlightedMarkdown: string,
21
+ replacements: ReadonlyArray<StdinReplacement>
22
+ ): string {
23
+ if (replacements.length === 0) {
24
+ return highlightedMarkdown;
25
+ }
26
+
27
+ let current = highlightedMarkdown;
28
+ for (const r of replacements) {
29
+ const lines = current.split("\n");
30
+ const stdinLines = r.stdin.split("\n");
31
+ const out: Array<string> = [];
32
+ for (const line of lines) {
33
+ if (stripAnsi(line).includes(r.placeholder)) {
34
+ out.push(...stdinLines);
35
+ } else {
36
+ out.push(line);
37
+ }
38
+ }
39
+ current = out.join("\n");
40
+ }
41
+ return current;
42
+ }
43
+
44
+ async function highlightMarkdown(
45
+ markdown: string,
46
+ stdinReplacements: ReadonlyArray<StdinReplacement>
47
+ ): Promise<string> {
48
+ const stripped = stripAnsi(markdown);
49
+ try {
50
+ const highlightedMarkdown = await codeToANSIForcedColors(
51
+ stripped,
52
+ markdownLang,
53
+ markdownTheme
54
+ );
55
+
56
+ if (stdinReplacements.length === 0) {
57
+ // Keep output shape stable: always end with a single trailing newline.
58
+ return highlightedMarkdown.replace(/\n+$/, "\n");
59
+ }
60
+
61
+ const highlightedStdinList = await Promise.all(
62
+ stdinReplacements.map(async (r) => {
63
+ const highlightedStdin = await codeToANSIForcedColors(
64
+ r.stdin,
65
+ r.stdinLanguage,
66
+ markdownTheme
67
+ );
68
+ // Avoid inserting an extra blank line before `EOF`.
69
+ const trimmed = trimFinalNewline(
70
+ highlightedStdin.replace(/\n+$/, "\n")
71
+ );
72
+ return { ...r, stdin: trimmed } satisfies StdinReplacement;
73
+ })
74
+ );
75
+
76
+ const replaced = applyStdinReplacements(
77
+ highlightedMarkdown,
78
+ highlightedStdinList
79
+ );
80
+ return replaced.replace(/\n+$/, "\n");
81
+ } catch {
82
+ return stripped;
83
+ }
84
+ }
85
+
86
+ function stripAnsi(input: string): string {
87
+ // Prefer Bun's built-in ANSI stripper when available.
88
+ if (typeof Bun !== "undefined" && typeof Bun.stripANSI === "function") {
89
+ return Bun.stripANSI(input);
90
+ }
91
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intended
92
+ return input.replace(/\u001b\[[0-9;]*m/g, "");
93
+ }
94
+
95
+ function trimFinalNewline(input: string): string {
96
+ return input.replace(/\n$/, "");
97
+ }
98
+
99
+ function toBddHeading(keyword: string): string {
100
+ if (keyword.length === 0) {
101
+ return keyword;
102
+ }
103
+ return keyword.charAt(0).toUpperCase() + keyword.slice(1);
104
+ }
105
+
106
+ const statusEmojiByStatus = {
107
+ pending: "⏳",
108
+ success: "✅",
109
+ failure: "❌",
110
+ } as const;
111
+
112
+ function statusEmoji(status: keyof typeof statusEmojiByStatus): string {
113
+ return statusEmojiByStatus[status];
114
+ }
115
+
116
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
117
+ export function renderReport(
118
+ report: Report,
119
+ options: MarkdownReporterOptions
120
+ ): Promise<string> {
121
+ const enableANSI = options.enableANSI ?? false;
122
+
123
+ const renderedScenarios: Array<string> = [];
124
+ const stdinReplacements: Array<StdinReplacement> = [];
125
+ let stdinSeq = 0;
126
+
127
+ for (const scenario of report.scenarios) {
128
+ const isEmpty =
129
+ scenario.overview.length === 0 &&
130
+ scenario.details.length === 0 &&
131
+ scenario.cleanup.length === 0;
132
+ if (isEmpty) {
133
+ continue;
134
+ }
135
+
136
+ const overviewStatusByName = new Map<
137
+ string,
138
+ "pending" | "success" | "failure"
139
+ >(scenario.overview.map((o) => [o.name, o.status]));
140
+
141
+ const lines: Array<string> = [];
142
+
143
+ lines.push(`# ${stripAnsi(scenario.name)}`);
144
+ lines.push("");
145
+
146
+ // Overview
147
+ lines.push("## Scenario Overview");
148
+ lines.push("");
149
+ lines.push("| # | Action | Status |");
150
+ lines.push("|---|--------|--------|");
151
+ for (const [i, item] of scenario.overview.entries()) {
152
+ lines.push(
153
+ `| ${i + 1} | ${stripAnsi(item.name)} | ${statusEmoji(item.status)} |`
154
+ );
155
+ }
156
+ lines.push("");
157
+
158
+ // Details
159
+ lines.push("## Scenario Details");
160
+ lines.push("");
161
+
162
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: rendering is intentionally linear and explicit
163
+ const renderAction = (action: Action) => {
164
+ let status = overviewStatusByName.get(action.name);
165
+ if (!status) {
166
+ if (action.error) {
167
+ status = "failure";
168
+ } else if (action.command) {
169
+ status = "success";
170
+ } else {
171
+ status = "pending";
172
+ }
173
+ }
174
+ const emoji = statusEmoji(status);
175
+ const attemptsSuffix =
176
+ status === "failure" && typeof action.attempts === "number"
177
+ ? ` (Failed after ${action.attempts} attempts)`
178
+ : "";
179
+
180
+ lines.push(`**${emoji} ${stripAnsi(action.name)}**${attemptsSuffix}`);
181
+ lines.push("");
182
+
183
+ const cmd = action.command;
184
+ if (cmd) {
185
+ const base = [cmd.cmd, ...cmd.args].join(" ").trim();
186
+ const stdin = cmd.stdin?.text;
187
+ const stdinLanguage = cmd.stdin?.language ?? "text";
188
+
189
+ lines.push("```shell");
190
+ if (typeof stdin === "string") {
191
+ lines.push(`${base} <<EOF`);
192
+ if (enableANSI) {
193
+ const placeholder = `__KEST_STDIN_${stdinSeq++}__`;
194
+ stdinReplacements.push({
195
+ placeholder,
196
+ stdin: normalizeStdin(stripAnsi(stdin)),
197
+ stdinLanguage,
198
+ });
199
+ lines.push(placeholder);
200
+ } else {
201
+ lines.push(stripAnsi(stdin));
202
+ }
203
+ lines.push("EOF");
204
+ } else {
205
+ lines.push(base);
206
+ }
207
+ lines.push("```");
208
+ lines.push("");
209
+
210
+ const stdout = stripAnsi(cmd.stdout?.text ?? "");
211
+ if (stdout.trim().length > 0) {
212
+ const lang = cmd.stdout?.language ?? "text";
213
+ lines.push(`\`\`\`${lang} title="stdout"`);
214
+ lines.push(trimFinalNewline(stdout));
215
+ lines.push("```");
216
+ lines.push("");
217
+ }
218
+
219
+ const stderr = stripAnsi(cmd.stderr?.text ?? "");
220
+ if (stderr.trim().length > 0) {
221
+ const lang = cmd.stderr?.language ?? "text";
222
+ lines.push(`\`\`\`${lang} title="stderr"`);
223
+ lines.push(trimFinalNewline(stderr));
224
+ lines.push("```");
225
+ lines.push("");
226
+ }
227
+ }
228
+
229
+ if (status === "failure" && action.error?.message?.text) {
230
+ const messageText = stripAnsi(action.error.message.text);
231
+ const lang = action.error.message.language ?? "text";
232
+ lines.push("Error:");
233
+ lines.push("");
234
+ lines.push(`\`\`\`${lang}`);
235
+ lines.push(trimFinalNewline(messageText));
236
+ lines.push("```");
237
+ lines.push("");
238
+ }
239
+ };
240
+
241
+ for (const item of scenario.details) {
242
+ if (item.type === "BDDSection") {
243
+ lines.push(
244
+ `### ${toBddHeading(item.keyword)}: ${stripAnsi(item.description)}`
245
+ );
246
+ lines.push("");
247
+ for (const action of item.actions) {
248
+ renderAction(action);
249
+ }
250
+ }
251
+ if (item.type === "Action") {
252
+ renderAction(item);
253
+ }
254
+ }
255
+
256
+ // Cleanup
257
+ if (scenario.cleanup.length > 0) {
258
+ lines.push("### Cleanup");
259
+ lines.push("");
260
+ lines.push("| # | Action | Status |");
261
+ lines.push("|---|--------|--------|");
262
+ for (const [i, item] of scenario.cleanup.entries()) {
263
+ lines.push(
264
+ `| ${i + 1} | ${stripAnsi(item.action)} | ${item.status === "success" ? "✅" : "❌"} |`
265
+ );
266
+ }
267
+ lines.push("");
268
+
269
+ lines.push("```shellsession");
270
+ for (const [i, item] of scenario.cleanup.entries()) {
271
+ if (i > 0) {
272
+ lines.push("");
273
+ }
274
+ const base = [item.command.cmd, ...item.command.args].join(" ").trim();
275
+ lines.push(`$ ${stripAnsi(base)}`);
276
+ const output = stripAnsi(item.command.output);
277
+ if (output.trim().length > 0) {
278
+ lines.push(trimFinalNewline(output));
279
+ }
280
+ }
281
+ lines.push("```");
282
+ }
283
+
284
+ renderedScenarios.push(lines.join("\n"));
285
+ }
286
+
287
+ if (renderedScenarios.length === 0) {
288
+ return Promise.resolve("");
289
+ }
290
+ const rendered = renderedScenarios.join("\n\n");
291
+ const markdown = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
292
+ if (!enableANSI) {
293
+ return Promise.resolve(markdown);
294
+ }
295
+ return highlightMarkdown(markdown, stdinReplacements);
296
+ }
@@ -0,0 +1,58 @@
1
+ export type CodeToANSI = (
2
+ code: string,
3
+ language: string,
4
+ theme: string
5
+ ) => Promise<string>;
6
+
7
+ let codeToANSIPromise: Promise<CodeToANSI> | undefined;
8
+
9
+ function loadCodeToANSI(): Promise<CodeToANSI> {
10
+ if (codeToANSIPromise) {
11
+ return codeToANSIPromise;
12
+ }
13
+
14
+ codeToANSIPromise = (async () => {
15
+ const env = typeof process !== "undefined" ? process.env : undefined;
16
+ const prevNoColor = env?.["NO_COLOR"];
17
+ const prevForceColor = env?.["FORCE_COLOR"];
18
+
19
+ // `@shikijs/cli` uses `ansis`, which disables colors when NO_COLOR is set.
20
+ // Force ANSI output when our callers explicitly request ANSI.
21
+ if (env) {
22
+ // biome-ignore lint/performance/noDelete: required to actually unset env vars
23
+ delete env["NO_COLOR"];
24
+ env["FORCE_COLOR"] = "3";
25
+ }
26
+
27
+ try {
28
+ const mod = await import("@shikijs/cli");
29
+ return mod.codeToANSI as unknown as CodeToANSI;
30
+ } finally {
31
+ if (env) {
32
+ if (prevNoColor === undefined) {
33
+ // biome-ignore lint/performance/noDelete: required to restore absence
34
+ delete env["NO_COLOR"];
35
+ } else {
36
+ env["NO_COLOR"] = prevNoColor;
37
+ }
38
+ if (prevForceColor === undefined) {
39
+ // biome-ignore lint/performance/noDelete: required to restore absence
40
+ delete env["FORCE_COLOR"];
41
+ } else {
42
+ env["FORCE_COLOR"] = prevForceColor;
43
+ }
44
+ }
45
+ }
46
+ })();
47
+
48
+ return codeToANSIPromise;
49
+ }
50
+
51
+ export async function codeToANSIForcedColors(
52
+ code: string,
53
+ language: string,
54
+ theme: string
55
+ ): Promise<string> {
56
+ const fn = await loadCodeToANSI();
57
+ return await fn(code, language, theme);
58
+ }