@qavajs/playwright-runner-adapter 2.1.0 → 2.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/CHANGELOG.md +7 -0
- package/adapter/PlaywrightWorld.d.ts +1 -1
- package/adapter/PlaywrightWorld.js +3 -1
- package/adapter/PlaywrightWorld.js.map +1 -1
- package/adapter/cucumberAdapter.js +130 -164
- package/adapter/cucumberAdapter.js.map +1 -1
- package/adapter/js/PlaywrightWorld.d.ts +31 -0
- package/adapter/js/PlaywrightWorld.js +56 -0
- package/adapter/js/Template.d.ts +10 -0
- package/adapter/js/Template.js +26 -0
- package/adapter/js/cucumberAdapter.d.ts +4 -0
- package/adapter/js/cucumberAdapter.js +244 -0
- package/adapter/js/filter.d.ts +19 -0
- package/adapter/js/filter.js +55 -0
- package/adapter/js/index.d.ts +5 -0
- package/adapter/js/index.js +21 -0
- package/adapter/js/loader.d.ts +14 -0
- package/adapter/js/loader.js +62 -0
- package/package.json +6 -6
- package/src/PlaywrightWorld.ts +4 -2
- package/src/cucumberAdapter.ts +216 -210
- package/tsconfig.js.json +23 -0
package/src/cucumberAdapter.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import {load} from './loader';
|
|
2
2
|
import type {TestInfo, TestType} from '@playwright/test';
|
|
3
|
-
import type {ITestCaseHookParameter} from '@cucumber/cucumber';
|
|
3
|
+
import type {ITestCaseHookParameter, ITestStepHookParameter} from '@cucumber/cucumber';
|
|
4
4
|
|
|
5
5
|
// Constants
|
|
6
6
|
const ANNOTATION_TYPES = {
|
|
7
|
-
TEST_ID: 'testId',
|
|
8
7
|
NAME: 'name',
|
|
9
8
|
URI: 'uri',
|
|
10
9
|
TAGS: 'tags'
|
|
@@ -52,41 +51,17 @@ interface TestResult {
|
|
|
52
51
|
error?: Error;
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
interface
|
|
56
|
-
|
|
57
|
-
message?: string;
|
|
58
|
-
status: string;
|
|
59
|
-
exception?: Error;
|
|
54
|
+
interface TestExecutionResult extends TestResult {
|
|
55
|
+
start: number;
|
|
60
56
|
}
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*/
|
|
65
|
-
function getTestId(testInfo: TestInfo): string {
|
|
66
|
-
const annotation = testInfo.annotations
|
|
67
|
-
.find((annotation) => annotation.type === ANNOTATION_TYPES.TEST_ID);
|
|
68
|
-
|
|
69
|
-
if (!annotation?.description) {
|
|
70
|
-
throw new Error('Test ID annotation not found');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return annotation.description;
|
|
58
|
+
interface FeatureContext {
|
|
59
|
+
gherkinDocument: unknown;
|
|
74
60
|
}
|
|
75
61
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
function getResult(testInfo: TestInfo): HookResult {
|
|
80
|
-
const message = testInfo.errors.length > 0
|
|
81
|
-
? testInfo.errors.map((err) => err.message).join('\n')
|
|
82
|
-
: undefined;
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
duration: testInfo.duration,
|
|
86
|
-
message,
|
|
87
|
-
status: testInfo.status?.toUpperCase() ?? 'UNKNOWN',
|
|
88
|
-
exception: testInfo.error as any
|
|
89
|
-
};
|
|
62
|
+
interface PickleStep {
|
|
63
|
+
text: string;
|
|
64
|
+
argument?: { docString?: unknown };
|
|
90
65
|
}
|
|
91
66
|
|
|
92
67
|
/**
|
|
@@ -116,10 +91,10 @@ function attach(
|
|
|
116
91
|
this: { test: { info: () => TestInfo } },
|
|
117
92
|
body: string | Buffer,
|
|
118
93
|
details?: { fileName?: string; mediaType?: string }
|
|
119
|
-
): void {
|
|
94
|
+
): Promise<void> {
|
|
120
95
|
const fileName = details?.fileName ?? DEFAULT_VALUES.ATTACHMENT_FILENAME;
|
|
121
96
|
const contentType = details?.mediaType ?? DEFAULT_VALUES.CONTENT_TYPE;
|
|
122
|
-
this.test.info().attach(fileName, { body, contentType });
|
|
97
|
+
return this.test.info().attach(fileName, { body, contentType });
|
|
123
98
|
}
|
|
124
99
|
|
|
125
100
|
/**
|
|
@@ -140,6 +115,134 @@ function stepName(pickleStep: {
|
|
|
140
115
|
return step;
|
|
141
116
|
}
|
|
142
117
|
|
|
118
|
+
function createTestExecutionResult(): TestExecutionResult {
|
|
119
|
+
return {
|
|
120
|
+
status: 'passed',
|
|
121
|
+
start: Date.now()
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createHookResult(result: TestExecutionResult): any {
|
|
126
|
+
return {
|
|
127
|
+
duration: (Date.now() - result.start) as any,
|
|
128
|
+
message: result.error?.message,
|
|
129
|
+
status: result.status.toUpperCase(),
|
|
130
|
+
exception: result.error
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createScenarioAnnotation(testCase: any): { type: string; description: string }[] {
|
|
135
|
+
const tag = [...new Set(testCase.tags.map((item: { name: string }) => item.name))];
|
|
136
|
+
return [
|
|
137
|
+
{type: ANNOTATION_TYPES.NAME, description: testCase.name},
|
|
138
|
+
{type: ANNOTATION_TYPES.URI, description: testCase.uri},
|
|
139
|
+
{type: ANNOTATION_TYPES.TAGS, description: JSON.stringify(tag)}
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createScenarioTags(testCase: any): string[] {
|
|
144
|
+
return [...new Set<string>(testCase.tags.map((item: { name: string }) => item.name))];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createCaseHookPayload(feature: FeatureContext, testCase: any): ITestCaseHookParameter {
|
|
148
|
+
return {
|
|
149
|
+
gherkinDocument: feature.gherkinDocument,
|
|
150
|
+
pickle: testCase
|
|
151
|
+
} as ITestCaseHookParameter;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function executeCaseHooks(
|
|
155
|
+
test: TestType<any, any>,
|
|
156
|
+
hooks: any[],
|
|
157
|
+
world: any,
|
|
158
|
+
testCase: any,
|
|
159
|
+
defaultHookName: string,
|
|
160
|
+
createPayload: (hook: any) => any
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
for (const hook of hooks) {
|
|
163
|
+
if (!hook.appliesToTestCase(testCase)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const hookName = hook.name ?? defaultHookName;
|
|
168
|
+
const location = getLine(hook);
|
|
169
|
+
await test.step(
|
|
170
|
+
hookName,
|
|
171
|
+
() => hook.code.apply(world, [createPayload(hook)]),
|
|
172
|
+
location
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function executeStepHooks(
|
|
178
|
+
test: TestType<any, any>,
|
|
179
|
+
hooks: any[],
|
|
180
|
+
world: any,
|
|
181
|
+
testCase: any,
|
|
182
|
+
hookName: string,
|
|
183
|
+
createPayload: (hook: any) => any
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
for (const hook of hooks) {
|
|
186
|
+
if (!hook.appliesToTestCase(testCase)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const location = getLine(hook);
|
|
191
|
+
await test.step(
|
|
192
|
+
hookName,
|
|
193
|
+
() => hook.code.apply(world, [createPayload(hook)]),
|
|
194
|
+
location
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function createStepHookParameter(
|
|
200
|
+
feature: any,
|
|
201
|
+
testCase: any,
|
|
202
|
+
pickleStep: any,
|
|
203
|
+
result: TestExecutionResult
|
|
204
|
+
): ITestStepHookParameter {
|
|
205
|
+
return {
|
|
206
|
+
gherkinDocument: feature.gherkinDocument,
|
|
207
|
+
testCaseStartedId: DEFAULT_VALUES.TEST_CASE_ID,
|
|
208
|
+
testStepId: DEFAULT_VALUES.TEST_STEP_ID,
|
|
209
|
+
pickle: testCase,
|
|
210
|
+
pickleStep,
|
|
211
|
+
result: createHookResult(result)
|
|
212
|
+
} as any;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function createCaseHookParameter(
|
|
216
|
+
feature: any,
|
|
217
|
+
testCase: any,
|
|
218
|
+
result: TestExecutionResult
|
|
219
|
+
): ITestCaseHookParameter {
|
|
220
|
+
return {
|
|
221
|
+
gherkinDocument: feature.gherkinDocument,
|
|
222
|
+
testCaseStartedId: DEFAULT_VALUES.TEST_CASE_ID,
|
|
223
|
+
pickle: testCase,
|
|
224
|
+
result: createHookResult(result)
|
|
225
|
+
} as any;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findSingleStepDefinition(
|
|
229
|
+
supportCodeLibrary: any,
|
|
230
|
+
pickleStep: PickleStep
|
|
231
|
+
): any {
|
|
232
|
+
const matchingSteps = supportCodeLibrary.stepDefinitions
|
|
233
|
+
.filter((stepDefinition: any) => stepDefinition.matchesStepName(pickleStep.text));
|
|
234
|
+
|
|
235
|
+
if (matchingSteps.length === 0) {
|
|
236
|
+
throw new Error(`Step '${pickleStep.text}' is not defined`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (matchingSteps.length > 1) {
|
|
240
|
+
throw new Error(`Step '${pickleStep.text}' matches multiple step definitions`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return matchingSteps[0];
|
|
244
|
+
}
|
|
245
|
+
|
|
143
246
|
/**
|
|
144
247
|
* Executes before all hooks
|
|
145
248
|
*/
|
|
@@ -180,20 +283,6 @@ function executeAfterAllHooks(
|
|
|
180
283
|
});
|
|
181
284
|
}
|
|
182
285
|
|
|
183
|
-
/**
|
|
184
|
-
* Creates test annotations from test case data
|
|
185
|
-
*/
|
|
186
|
-
function createTestAnnotations(testCase: any): any[] {
|
|
187
|
-
const tags = [...new Set(testCase.tags.map((tag: { name: string }) => tag.name))];
|
|
188
|
-
|
|
189
|
-
return [
|
|
190
|
-
{ type: ANNOTATION_TYPES.NAME, description: testCase.name },
|
|
191
|
-
{ type: ANNOTATION_TYPES.URI, description: testCase.uri },
|
|
192
|
-
{ type: ANNOTATION_TYPES.TEST_ID, description: testCase.id },
|
|
193
|
-
{ type: ANNOTATION_TYPES.TAGS, description: JSON.stringify(tags) }
|
|
194
|
-
];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
286
|
/**
|
|
198
287
|
* Main configuration function that sets up Cucumber adapter for Playwright
|
|
199
288
|
*/
|
|
@@ -202,178 +291,95 @@ export function defineConfig(config: any): any {
|
|
|
202
291
|
|
|
203
292
|
const fixture = new supportCodeLibrary.World({config});
|
|
204
293
|
const test: TestType<any, any> = fixture.test;
|
|
205
|
-
|
|
206
294
|
executeBeforeAllHooks(test, supportCodeLibrary.beforeTestRunHookDefinitions);
|
|
207
295
|
|
|
208
|
-
const worlds = new Map<string, any>();
|
|
209
|
-
|
|
210
296
|
for (const feature of features) {
|
|
211
297
|
const tests = feature.tests;
|
|
212
|
-
|
|
213
|
-
test.describe(feature.feature as string, async () => {
|
|
214
|
-
/**
|
|
215
|
-
* Factory function to create and register world instances
|
|
216
|
-
*/
|
|
217
|
-
const worldFactory = (fixtures: any): void => {
|
|
218
|
-
const world = new supportCodeLibrary.World({
|
|
219
|
-
log,
|
|
220
|
-
attach,
|
|
221
|
-
supportCodeLibrary,
|
|
222
|
-
config
|
|
223
|
-
});
|
|
224
|
-
world.init(fixtures);
|
|
225
|
-
worlds.set(getTestId(test.info()), world);
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
worldFactory.toString = (): string => fixture.init.toString();
|
|
229
|
-
|
|
230
|
-
test.beforeEach(HOOK_NAMES.WORLD, worldFactory);
|
|
231
|
-
|
|
232
|
-
test.beforeEach(HOOK_NAMES.BEFORE_HOOKS, async () => {
|
|
233
|
-
const testId = getTestId(test.info());
|
|
234
|
-
const world = worlds.get(testId);
|
|
235
|
-
const testCase = tests.find((t: any) => t.id === testId);
|
|
236
|
-
|
|
237
|
-
if (!testCase) {
|
|
238
|
-
throw new Error(`Test case with ID ${testId} not found`);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
for (const beforeHook of supportCodeLibrary.beforeTestCaseHookDefinitions) {
|
|
242
|
-
if (beforeHook.appliesToTestCase(testCase)) {
|
|
243
|
-
const hookName = beforeHook.name ?? HOOK_NAMES.BEFORE;
|
|
244
|
-
const location = getLine(beforeHook);
|
|
245
|
-
await test.step(
|
|
246
|
-
hookName,
|
|
247
|
-
() => beforeHook.code.apply(world, [{
|
|
248
|
-
gherkinDocument: feature.gherkinDocument,
|
|
249
|
-
pickle: testCase
|
|
250
|
-
} as ITestCaseHookParameter]),
|
|
251
|
-
location
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
});
|
|
298
|
+
test.describe(feature.feature as string, () => {
|
|
256
299
|
|
|
257
300
|
for (const testCase of tests) {
|
|
258
|
-
const tag =
|
|
259
|
-
const annotation =
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const [step] = matchingSteps;
|
|
286
|
-
const location = getLine(step);
|
|
287
|
-
await test.step(stepName(pickleStep), async () => {
|
|
288
|
-
for (const beforeStep of supportCodeLibrary.beforeTestStepHookDefinitions) {
|
|
289
|
-
if (beforeStep.appliesToTestCase(testCase)) {
|
|
290
|
-
const location = getLine(beforeStep);
|
|
291
|
-
await test.step(
|
|
292
|
-
HOOK_NAMES.BEFORE_STEP,
|
|
293
|
-
() => beforeStep.code.apply(world, [{
|
|
294
|
-
gherkinDocument: feature.gherkinDocument,
|
|
295
|
-
pickle: testCase,
|
|
296
|
-
pickleStep
|
|
297
|
-
}]),
|
|
298
|
-
location
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
+
const tag = createScenarioTags(testCase);
|
|
302
|
+
const annotation = createScenarioAnnotation(testCase);
|
|
303
|
+
const testBody = async (fixtures: any) => {
|
|
304
|
+
const world = new supportCodeLibrary.World({
|
|
305
|
+
log,
|
|
306
|
+
attach,
|
|
307
|
+
supportCodeLibrary,
|
|
308
|
+
config
|
|
309
|
+
});
|
|
310
|
+
world.init(fixtures);
|
|
311
|
+
|
|
312
|
+
const result = createTestExecutionResult();
|
|
313
|
+
|
|
314
|
+
await executeCaseHooks(
|
|
315
|
+
test,
|
|
316
|
+
supportCodeLibrary.beforeTestCaseHookDefinitions,
|
|
317
|
+
world,
|
|
318
|
+
testCase,
|
|
319
|
+
HOOK_NAMES.BEFORE,
|
|
320
|
+
() => createCaseHookPayload(feature, testCase)
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
for (const pickleStep of testCase.steps) {
|
|
325
|
+
if (result.status !== 'passed') {
|
|
326
|
+
break;
|
|
301
327
|
}
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
328
|
+
const step = findSingleStepDefinition(supportCodeLibrary, pickleStep);
|
|
329
|
+
const location = getLine(step);
|
|
330
|
+
await test.step(stepName(pickleStep), async () => {
|
|
331
|
+
await executeStepHooks(
|
|
332
|
+
test,
|
|
333
|
+
supportCodeLibrary.beforeTestStepHookDefinitions,
|
|
334
|
+
world,
|
|
335
|
+
testCase,
|
|
336
|
+
HOOK_NAMES.BEFORE_STEP,
|
|
337
|
+
() => ({
|
|
338
|
+
gherkinDocument: feature.gherkinDocument,
|
|
339
|
+
pickle: testCase,
|
|
340
|
+
pickleStep
|
|
341
|
+
})
|
|
342
|
+
);
|
|
343
|
+
const {parameters} = await step.getInvocationParameters({
|
|
344
|
+
step: {
|
|
345
|
+
text: pickleStep.text,
|
|
346
|
+
argument: pickleStep.argument
|
|
347
|
+
},
|
|
348
|
+
world
|
|
349
|
+
} as any);
|
|
350
|
+
try {
|
|
351
|
+
await step.code.apply(world, parameters);
|
|
352
|
+
} catch (err: any) {
|
|
353
|
+
result.status = 'failed';
|
|
354
|
+
result.error = err;
|
|
355
|
+
throw err;
|
|
356
|
+
} finally {
|
|
357
|
+
await executeStepHooks(
|
|
358
|
+
test,
|
|
359
|
+
supportCodeLibrary.afterTestStepHookDefinitions,
|
|
360
|
+
world,
|
|
361
|
+
testCase,
|
|
362
|
+
HOOK_NAMES.AFTER_STEP,
|
|
363
|
+
() => createStepHookParameter(feature, testCase, pickleStep, result)
|
|
364
|
+
);
|
|
338
365
|
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const testCase = tests.find((t) => t.id === testId);
|
|
350
|
-
|
|
351
|
-
if (!testCase) {
|
|
352
|
-
throw new Error(`Test case with ID ${testId} not found`);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
for (const afterHook of supportCodeLibrary.afterTestCaseHookDefinitions) {
|
|
356
|
-
if (afterHook.appliesToTestCase(testCase)) {
|
|
357
|
-
const hookName = afterHook.name ?? HOOK_NAMES.AFTER;
|
|
358
|
-
const location = getLine(afterHook);
|
|
359
|
-
await test.step(
|
|
360
|
-
hookName,
|
|
361
|
-
() => afterHook.code.apply(world, [{
|
|
362
|
-
gherkinDocument: feature.gherkinDocument,
|
|
363
|
-
testCaseStartedId: DEFAULT_VALUES.TEST_CASE_ID,
|
|
364
|
-
pickle: testCase,
|
|
365
|
-
result: getResult(testInfo)
|
|
366
|
-
} as any]),
|
|
367
|
-
location
|
|
366
|
+
}, location);
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
await executeCaseHooks(
|
|
370
|
+
test,
|
|
371
|
+
supportCodeLibrary.afterTestCaseHookDefinitions,
|
|
372
|
+
world,
|
|
373
|
+
testCase,
|
|
374
|
+
HOOK_NAMES.AFTER,
|
|
375
|
+
() => createCaseHookParameter(feature, testCase, result)
|
|
368
376
|
);
|
|
369
377
|
}
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
378
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
379
|
+
}
|
|
380
|
+
testBody.toString = () => fixture.init.toString();
|
|
381
|
+
test(testCase.name, {tag, annotation}, testBody);
|
|
382
|
+
}
|
|
377
383
|
|
|
378
384
|
});
|
|
379
385
|
}
|
package/tsconfig.js.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"include": [
|
|
3
|
+
"src/**/*.ts"
|
|
4
|
+
],
|
|
5
|
+
"exclude": [
|
|
6
|
+
"test/**/*.spec.ts"
|
|
7
|
+
],
|
|
8
|
+
"compilerOptions": {
|
|
9
|
+
"target": "es2023",
|
|
10
|
+
"module": "node16",
|
|
11
|
+
"moduleResolution": "node16",
|
|
12
|
+
"outDir": "./adapter/js",
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"sourceMap": false,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"strict": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"declaration": true,
|
|
19
|
+
"types": [
|
|
20
|
+
"node"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
}
|