@cronn/playwright-utils 0.4.0 → 0.6.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/dist/index.d.mts +66 -16
- package/dist/index.mjs +145 -1
- package/package.json +17 -18
package/dist/index.d.mts
CHANGED
|
@@ -7,7 +7,7 @@ import { APIRequestContext, APIResponse, ConsoleMessage, Locator, Page, Request,
|
|
|
7
7
|
*
|
|
8
8
|
* Evaluated once when the module is loaded.
|
|
9
9
|
*/
|
|
10
|
-
declare const isCI: boolean;
|
|
10
|
+
export declare const isCI: boolean;
|
|
11
11
|
//#endregion
|
|
12
12
|
//#region src/api/fetch-adapter.d.ts
|
|
13
13
|
type FetchAPI = typeof fetch;
|
|
@@ -21,7 +21,7 @@ type FetchAPI = typeof fetch;
|
|
|
21
21
|
* @param requestContext - Playwright request context to send requests with
|
|
22
22
|
* @returns A function with the signature of the global `fetch`
|
|
23
23
|
*/
|
|
24
|
-
declare function createFetchAdapter(requestContext: APIRequestContext): FetchAPI;
|
|
24
|
+
export declare function createFetchAdapter(requestContext: APIRequestContext): FetchAPI;
|
|
25
25
|
//#endregion
|
|
26
26
|
//#region src/capturing/console-captor.d.ts
|
|
27
27
|
/**
|
|
@@ -57,7 +57,7 @@ type ConsoleMessageLevel = ReturnType<ConsoleMessage["type"]>;
|
|
|
57
57
|
* ]);
|
|
58
58
|
* ```
|
|
59
59
|
*/
|
|
60
|
-
declare class ConsoleCaptor {
|
|
60
|
+
export declare class ConsoleCaptor {
|
|
61
61
|
private readonly page;
|
|
62
62
|
private readonly consoleListener;
|
|
63
63
|
/**
|
|
@@ -112,6 +112,21 @@ declare class ConsoleCaptor {
|
|
|
112
112
|
* The already captured {@link messages} are kept.
|
|
113
113
|
*/
|
|
114
114
|
stopCapture(): void;
|
|
115
|
+
/**
|
|
116
|
+
* Remove all captured {@link messages}.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* captor.startCapture();
|
|
121
|
+
*
|
|
122
|
+
* await page.goto("/users");
|
|
123
|
+
* captor.clearMessages();
|
|
124
|
+
*
|
|
125
|
+
* await page.getByRole("button", { name: "Only enabled" }).click();
|
|
126
|
+
* expect(captor.messages).toHaveLength(1);
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
clearMessages(): void;
|
|
115
130
|
/**
|
|
116
131
|
* Collect the console messages reported while the callback runs.
|
|
117
132
|
*
|
|
@@ -156,7 +171,7 @@ declare class ConsoleCaptor {
|
|
|
156
171
|
* );
|
|
157
172
|
* ```
|
|
158
173
|
*/
|
|
159
|
-
declare function captureConsole(page: Page, levelOrFilter?: ConsoleMessageLevel | Array<ConsoleMessageLevel> | ConsoleMessageFilter): ConsoleCaptor;
|
|
174
|
+
export declare function captureConsole(page: Page, levelOrFilter?: ConsoleMessageLevel | Array<ConsoleMessageLevel> | ConsoleMessageFilter): ConsoleCaptor;
|
|
160
175
|
/**
|
|
161
176
|
* Tests whether the given console message has one of the specified log levels.
|
|
162
177
|
*
|
|
@@ -166,7 +181,7 @@ declare function captureConsole(page: Page, levelOrFilter?: ConsoleMessageLevel
|
|
|
166
181
|
* @param level - The log level
|
|
167
182
|
* @returns True if the message has one of the given log levels
|
|
168
183
|
*/
|
|
169
|
-
declare function hasLogLevel(message: ConsoleMessage, ...level: Array<ConsoleMessageLevel>): boolean;
|
|
184
|
+
export declare function hasLogLevel(message: ConsoleMessage, ...level: Array<ConsoleMessageLevel>): boolean;
|
|
170
185
|
//#endregion
|
|
171
186
|
//#region src/api/route-interceptor/filter.d.ts
|
|
172
187
|
/**
|
|
@@ -196,7 +211,7 @@ interface RouteFilter {
|
|
|
196
211
|
* });
|
|
197
212
|
* ```
|
|
198
213
|
*/
|
|
199
|
-
declare function matchPath(pattern: RegExp | string): RouteFilter;
|
|
214
|
+
export declare function matchPath(pattern: RegExp | string): RouteFilter;
|
|
200
215
|
//#endregion
|
|
201
216
|
//#region src/api/route-interceptor/response-handler.d.ts
|
|
202
217
|
/**
|
|
@@ -221,7 +236,7 @@ type ResponseHandler = (response: APIResponse) => Promise<FulfillOptions> | Fulf
|
|
|
221
236
|
* });
|
|
222
237
|
* ```
|
|
223
238
|
*/
|
|
224
|
-
declare function modifyJsonBody<T>(modifier: (body: T) => T): ResponseHandler;
|
|
239
|
+
export declare function modifyJsonBody<T>(modifier: (body: T) => T): ResponseHandler;
|
|
225
240
|
/**
|
|
226
241
|
* Helper to create a {@link ResponseHandler} that modifies the response body as a plaintext string.
|
|
227
242
|
*
|
|
@@ -237,7 +252,7 @@ declare function modifyJsonBody<T>(modifier: (body: T) => T): ResponseHandler;
|
|
|
237
252
|
* });
|
|
238
253
|
* ```
|
|
239
254
|
*/
|
|
240
|
-
declare function modifyTextBody(modifier: (body: string) => string): ResponseHandler;
|
|
255
|
+
export declare function modifyTextBody(modifier: (body: string) => string): ResponseHandler;
|
|
241
256
|
//#endregion
|
|
242
257
|
//#region src/api/route-interceptor/interceptor.d.ts
|
|
243
258
|
type Action<T> = () => Promise<T> | T;
|
|
@@ -245,7 +260,7 @@ type FulfillOptions = Parameters<Route["fulfill"]>[0];
|
|
|
245
260
|
/**
|
|
246
261
|
* Entrypoint to create various network interceptors using the {@link Page#route} API.
|
|
247
262
|
*/
|
|
248
|
-
declare class RouteInterceptor {
|
|
263
|
+
export declare class RouteInterceptor {
|
|
249
264
|
readonly page: Page;
|
|
250
265
|
readonly filter: RouteFilter;
|
|
251
266
|
constructor(page: Page, filter: RouteFilter);
|
|
@@ -383,7 +398,7 @@ declare class ExecutableRouteInterceptor {
|
|
|
383
398
|
* });
|
|
384
399
|
* ```
|
|
385
400
|
*/
|
|
386
|
-
declare function interceptRoute(page: Page, filter: RouteFilter): RouteInterceptor;
|
|
401
|
+
export declare function interceptRoute(page: Page, filter: RouteFilter): RouteInterceptor;
|
|
387
402
|
/**
|
|
388
403
|
* Base class to build re-usable route interceptors.
|
|
389
404
|
*
|
|
@@ -413,7 +428,7 @@ declare function interceptRoute(page: Page, filter: RouteFilter): RouteIntercept
|
|
|
413
428
|
* });
|
|
414
429
|
* ```
|
|
415
430
|
*/
|
|
416
|
-
declare class RouteInterceptorFixture {
|
|
431
|
+
export declare class RouteInterceptorFixture {
|
|
417
432
|
private readonly page;
|
|
418
433
|
constructor(page: Page);
|
|
419
434
|
/**
|
|
@@ -445,7 +460,42 @@ declare class RouteInterceptorFixture {
|
|
|
445
460
|
* @returns The absolute path to the resolved location
|
|
446
461
|
* @throws If no package root can be found
|
|
447
462
|
*/
|
|
448
|
-
declare function resolveFromPackageRoot(...paths: Array<string>): string;
|
|
463
|
+
export declare function resolveFromPackageRoot(...paths: Array<string>): string;
|
|
464
|
+
//#endregion
|
|
465
|
+
//#region src/page-object-model/locator-extensions.d.ts
|
|
466
|
+
type RoleLocators = Record<Role, RoleLocator>;
|
|
467
|
+
interface RoleLocator {
|
|
468
|
+
(options?: RoleLocatorOptions): Locator;
|
|
469
|
+
(name: string | RegExp, options?: RoleLocatorOptions): Locator;
|
|
470
|
+
}
|
|
471
|
+
type RoleLocatorOptions = Omit<GetByRoleOptions, "name">;
|
|
472
|
+
type Role = Parameters<Locator["getByRole"]>[0];
|
|
473
|
+
type GetByRoleOptions = Parameters<Locator["getByRole"]>[1];
|
|
474
|
+
/**
|
|
475
|
+
* Creates a set of locator functions for every ARIA role, so tests don't
|
|
476
|
+
* have to repeat `getByRole` and its role string throughout a page object.
|
|
477
|
+
*
|
|
478
|
+
* Every generated locator applies `exact: true` by default, keeping name
|
|
479
|
+
* matches from accidentally matching unrelated elements with a similar
|
|
480
|
+
* accessible name. This can be overridden per call through `options`, or
|
|
481
|
+
* replaced entirely for every locator by passing a custom `defaultOptions`.
|
|
482
|
+
*
|
|
483
|
+
* The accessible name can be omitted when an element should be located by
|
|
484
|
+
* role alone, in which case `options` can be passed as the first argument.
|
|
485
|
+
*
|
|
486
|
+
* @param locator - The page or locator to scope the role locators to
|
|
487
|
+
* @param defaultOptions - Optional. Options applied to every generated locator, overridden by per-call options; defaults to `{ exact: true }`
|
|
488
|
+
* @returns A `RoleLocators` object with one locator function per ARIA role
|
|
489
|
+
*
|
|
490
|
+
* @example
|
|
491
|
+
* ```ts
|
|
492
|
+
* const { button, textbox } = roleLocators(page);
|
|
493
|
+
*
|
|
494
|
+
* await textbox("Email").fill("user@example.com");
|
|
495
|
+
* await button("Submit").click();
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
export declare function roleLocators(locator: Pick<Locator, "getByRole">, defaultOptions?: GetByRoleOptions): RoleLocators;
|
|
449
499
|
//#endregion
|
|
450
500
|
//#region src/normalizers/mask-base-url.d.ts
|
|
451
501
|
/**
|
|
@@ -454,7 +504,7 @@ declare function resolveFromPackageRoot(...paths: Array<string>): string;
|
|
|
454
504
|
*
|
|
455
505
|
* @param baseURL - Base URL to mask; if `undefined`, values are left unchanged
|
|
456
506
|
*/
|
|
457
|
-
declare function maskBaseURL<TValue>(baseURL: string | undefined): Normalizer<TValue>;
|
|
507
|
+
export declare function maskBaseURL<TValue>(baseURL: string | undefined): Normalizer<TValue>;
|
|
458
508
|
//#endregion
|
|
459
509
|
//#region src/normalizers/masked-value.d.ts
|
|
460
510
|
/**
|
|
@@ -463,14 +513,14 @@ declare function maskBaseURL<TValue>(baseURL: string | undefined): Normalizer<TV
|
|
|
463
513
|
*
|
|
464
514
|
* @param name - Name of the masked value
|
|
465
515
|
*/
|
|
466
|
-
declare function maskedValue(name: string): string;
|
|
516
|
+
export declare function maskedValue(name: string): string;
|
|
467
517
|
/**
|
|
468
518
|
* Creates a factory for indexed masked placeholders, e.g. `[USER_1]`.
|
|
469
519
|
*
|
|
470
520
|
* @param name - Name of the masked value
|
|
471
521
|
* @returns A function mapping an index to a masked placeholder
|
|
472
522
|
*/
|
|
473
|
-
declare function maskedValueWithIndex(name: string): (index: number) => string;
|
|
523
|
+
export declare function maskedValueWithIndex(name: string): (index: number) => string;
|
|
474
524
|
//#endregion
|
|
475
525
|
//#region src/types/playwright.d.ts
|
|
476
526
|
/**
|
|
@@ -479,4 +529,4 @@ declare function maskedValueWithIndex(name: string): (index: number) => string;
|
|
|
479
529
|
*/
|
|
480
530
|
type PlaywrightTarget = Page | Locator;
|
|
481
531
|
//#endregion
|
|
482
|
-
export {
|
|
532
|
+
export type { ConsoleMessageFilter, ConsoleMessageLevel, HttpMethod, PlaywrightTarget, ResponseHandler, RoleLocators, RouteFilter };
|
package/dist/index.mjs
CHANGED
|
@@ -148,6 +148,23 @@ var ConsoleCaptor = class {
|
|
|
148
148
|
this.page.off("console", this.consoleListener);
|
|
149
149
|
}
|
|
150
150
|
/**
|
|
151
|
+
* Remove all captured {@link messages}.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* captor.startCapture();
|
|
156
|
+
*
|
|
157
|
+
* await page.goto("/users");
|
|
158
|
+
* captor.clearMessages();
|
|
159
|
+
*
|
|
160
|
+
* await page.getByRole("button", { name: "Only enabled" }).click();
|
|
161
|
+
* expect(captor.messages).toHaveLength(1);
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
clearMessages() {
|
|
165
|
+
this.messages.length = 0;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
151
168
|
* Collect the console messages reported while the callback runs.
|
|
152
169
|
*
|
|
153
170
|
* The capturing is stopped once the action has finished, even if it throws.
|
|
@@ -592,6 +609,133 @@ function resolveFromPackageRoot(...paths) {
|
|
|
592
609
|
return path.resolve(packageDir, ...paths);
|
|
593
610
|
}
|
|
594
611
|
//#endregion
|
|
612
|
+
//#region src/page-object-model/locator-extensions.ts
|
|
613
|
+
const ROLES = [
|
|
614
|
+
"alert",
|
|
615
|
+
"alertdialog",
|
|
616
|
+
"application",
|
|
617
|
+
"article",
|
|
618
|
+
"banner",
|
|
619
|
+
"blockquote",
|
|
620
|
+
"button",
|
|
621
|
+
"caption",
|
|
622
|
+
"cell",
|
|
623
|
+
"checkbox",
|
|
624
|
+
"code",
|
|
625
|
+
"columnheader",
|
|
626
|
+
"combobox",
|
|
627
|
+
"complementary",
|
|
628
|
+
"contentinfo",
|
|
629
|
+
"definition",
|
|
630
|
+
"deletion",
|
|
631
|
+
"dialog",
|
|
632
|
+
"directory",
|
|
633
|
+
"document",
|
|
634
|
+
"emphasis",
|
|
635
|
+
"feed",
|
|
636
|
+
"figure",
|
|
637
|
+
"form",
|
|
638
|
+
"generic",
|
|
639
|
+
"grid",
|
|
640
|
+
"gridcell",
|
|
641
|
+
"group",
|
|
642
|
+
"heading",
|
|
643
|
+
"img",
|
|
644
|
+
"insertion",
|
|
645
|
+
"link",
|
|
646
|
+
"list",
|
|
647
|
+
"listbox",
|
|
648
|
+
"listitem",
|
|
649
|
+
"log",
|
|
650
|
+
"main",
|
|
651
|
+
"marquee",
|
|
652
|
+
"math",
|
|
653
|
+
"meter",
|
|
654
|
+
"menu",
|
|
655
|
+
"menubar",
|
|
656
|
+
"menuitem",
|
|
657
|
+
"menuitemcheckbox",
|
|
658
|
+
"menuitemradio",
|
|
659
|
+
"navigation",
|
|
660
|
+
"none",
|
|
661
|
+
"note",
|
|
662
|
+
"option",
|
|
663
|
+
"paragraph",
|
|
664
|
+
"presentation",
|
|
665
|
+
"progressbar",
|
|
666
|
+
"radio",
|
|
667
|
+
"radiogroup",
|
|
668
|
+
"region",
|
|
669
|
+
"row",
|
|
670
|
+
"rowgroup",
|
|
671
|
+
"rowheader",
|
|
672
|
+
"scrollbar",
|
|
673
|
+
"search",
|
|
674
|
+
"searchbox",
|
|
675
|
+
"separator",
|
|
676
|
+
"slider",
|
|
677
|
+
"spinbutton",
|
|
678
|
+
"status",
|
|
679
|
+
"strong",
|
|
680
|
+
"subscript",
|
|
681
|
+
"superscript",
|
|
682
|
+
"switch",
|
|
683
|
+
"tab",
|
|
684
|
+
"table",
|
|
685
|
+
"tablist",
|
|
686
|
+
"tabpanel",
|
|
687
|
+
"term",
|
|
688
|
+
"textbox",
|
|
689
|
+
"time",
|
|
690
|
+
"timer",
|
|
691
|
+
"toolbar",
|
|
692
|
+
"tooltip",
|
|
693
|
+
"tree",
|
|
694
|
+
"treegrid",
|
|
695
|
+
"treeitem"
|
|
696
|
+
];
|
|
697
|
+
const DEFAULT_OPTIONS = { exact: true };
|
|
698
|
+
/**
|
|
699
|
+
* Creates a set of locator functions for every ARIA role, so tests don't
|
|
700
|
+
* have to repeat `getByRole` and its role string throughout a page object.
|
|
701
|
+
*
|
|
702
|
+
* Every generated locator applies `exact: true` by default, keeping name
|
|
703
|
+
* matches from accidentally matching unrelated elements with a similar
|
|
704
|
+
* accessible name. This can be overridden per call through `options`, or
|
|
705
|
+
* replaced entirely for every locator by passing a custom `defaultOptions`.
|
|
706
|
+
*
|
|
707
|
+
* The accessible name can be omitted when an element should be located by
|
|
708
|
+
* role alone, in which case `options` can be passed as the first argument.
|
|
709
|
+
*
|
|
710
|
+
* @param locator - The page or locator to scope the role locators to
|
|
711
|
+
* @param defaultOptions - Optional. Options applied to every generated locator, overridden by per-call options; defaults to `{ exact: true }`
|
|
712
|
+
* @returns A `RoleLocators` object with one locator function per ARIA role
|
|
713
|
+
*
|
|
714
|
+
* @example
|
|
715
|
+
* ```ts
|
|
716
|
+
* const { button, textbox } = roleLocators(page);
|
|
717
|
+
*
|
|
718
|
+
* await textbox("Email").fill("user@example.com");
|
|
719
|
+
* await button("Submit").click();
|
|
720
|
+
* ```
|
|
721
|
+
*/
|
|
722
|
+
function roleLocators(locator, defaultOptions = DEFAULT_OPTIONS) {
|
|
723
|
+
function getByRole(role) {
|
|
724
|
+
return (nameOrOptions, options) => {
|
|
725
|
+
if (typeof nameOrOptions === "string" || nameOrOptions instanceof RegExp) return locator.getByRole(role, {
|
|
726
|
+
name: nameOrOptions,
|
|
727
|
+
...defaultOptions,
|
|
728
|
+
...options
|
|
729
|
+
});
|
|
730
|
+
return locator.getByRole(role, {
|
|
731
|
+
...defaultOptions,
|
|
732
|
+
...nameOrOptions
|
|
733
|
+
});
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return Object.fromEntries(ROLES.map((role) => [role, getByRole(role)]));
|
|
737
|
+
}
|
|
738
|
+
//#endregion
|
|
595
739
|
//#region src/normalizers/masked-value.ts
|
|
596
740
|
/**
|
|
597
741
|
* Formats a name as a masked placeholder, e.g. `maskedValue("base url")`
|
|
@@ -627,4 +771,4 @@ function identity(value) {
|
|
|
627
771
|
return value;
|
|
628
772
|
}
|
|
629
773
|
//#endregion
|
|
630
|
-
export { ConsoleCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, createFetchAdapter, hasLogLevel, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot };
|
|
774
|
+
export { ConsoleCaptor, RouteInterceptor, RouteInterceptorFixture, captureConsole, createFetchAdapter, hasLogLevel, interceptRoute, isCI, maskBaseURL, maskedValue, maskedValueWithIndex, matchPath, modifyJsonBody, modifyTextBody, resolveFromPackageRoot, roleLocators };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cronn/playwright-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Utilities for writing tests with Playwright",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -33,30 +33,29 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@arethetypeswrong/core": "0.18.5",
|
|
36
|
-
"@changesets/cli": "3.0.
|
|
37
|
-
"@cronn/element-snapshot": "0.
|
|
38
|
-
"@cronn/playwright-file-snapshots": "2.2.
|
|
39
|
-
"@playwright/test": "1.
|
|
40
|
-
"@types/node": "24.13.
|
|
36
|
+
"@changesets/cli": "3.0.2",
|
|
37
|
+
"@cronn/element-snapshot": "0.30.0",
|
|
38
|
+
"@cronn/playwright-file-snapshots": "2.2.2",
|
|
39
|
+
"@playwright/test": "1.63.0",
|
|
40
|
+
"@types/node": "24.13.4",
|
|
41
41
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
42
|
-
"@vitest/coverage-v8": "
|
|
43
|
-
"eslint": "10.
|
|
42
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
43
|
+
"eslint": "10.10.0",
|
|
44
44
|
"eslint-config-prettier": "10.1.8",
|
|
45
45
|
"eslint-plugin-check-file": "3.3.2",
|
|
46
46
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
47
|
-
"knip": "6.
|
|
48
|
-
"oxc-minify": "0.
|
|
49
|
-
"oxfmt": "0.
|
|
47
|
+
"knip": "6.35.1",
|
|
48
|
+
"oxc-minify": "0.149.0",
|
|
49
|
+
"oxfmt": "0.67.0",
|
|
50
50
|
"publint": "0.3.24",
|
|
51
51
|
"rimraf": "6.1.3",
|
|
52
|
-
"tsdown": "0.
|
|
52
|
+
"tsdown": "0.23.0",
|
|
53
53
|
"turbo": "2.10.12",
|
|
54
54
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
55
|
-
"typescript-eslint": "8.
|
|
56
|
-
"vitepress": "2.0.0-alpha.
|
|
55
|
+
"typescript-eslint": "8.70.0",
|
|
56
|
+
"vitepress": "2.0.0-alpha.20",
|
|
57
57
|
"vitepress-plugin-group-icons": "1.7.6",
|
|
58
|
-
"vitest": "
|
|
59
|
-
"node": "runtime:24.20.0"
|
|
58
|
+
"vitest": "5.0.0"
|
|
60
59
|
},
|
|
61
60
|
"peerDependencies": {
|
|
62
61
|
"@cronn/playwright-file-snapshots": "^2",
|
|
@@ -65,12 +64,12 @@
|
|
|
65
64
|
"devEngines": {
|
|
66
65
|
"packageManager": {
|
|
67
66
|
"name": "pnpm",
|
|
68
|
-
"version": "
|
|
67
|
+
"version": "12.5.1",
|
|
69
68
|
"onFail": "download"
|
|
70
69
|
},
|
|
71
70
|
"runtime": {
|
|
72
71
|
"name": "node",
|
|
73
|
-
"version": "24.
|
|
72
|
+
"version": "24.21.0",
|
|
74
73
|
"onFail": "download"
|
|
75
74
|
}
|
|
76
75
|
},
|