@ultimat3/cli 20.2.1 → 22.0.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/CLAUDE.md +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
package/src/cdp-browser.ts
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// One responsibility: compose the three halves — find a browser, connect to it, attach a page —
|
|
2
|
-
// into the one object `installE2eDriver({ page })` takes, plus the way to shut it down.
|
|
3
|
-
//
|
|
4
|
-
// **Absent is a SKIP, never a failure, and that is a requirement rather than a state.** A CI box
|
|
5
|
-
// with no Chrome must not turn the `e2e` step red for a reason unrelated to the change, which is
|
|
6
|
-
// the rule `packages/cli/CLAUDE.md` already states about `x shot`, `x pr` and `x ci`.
|
|
7
|
-
// `openE2eBrowserIfAvailable` is that door; `openE2eBrowser` refuses by name for a caller that has
|
|
8
|
-
// already decided a browser is required.
|
|
9
|
-
|
|
10
|
-
import { finiteCount } from '@ultimat3/core';
|
|
11
|
-
import type { CdpConnection } from './cdp-connection';
|
|
12
|
-
import { cdpConnect } from './cdp-connection';
|
|
13
|
-
import { cdpE2ePage } from './cdp-e2e-page';
|
|
14
|
-
import { CdpBrowserMissingError } from './cdp-errors';
|
|
15
|
-
import type { LaunchedBrowser } from './cdp-launch';
|
|
16
|
-
import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
|
|
17
|
-
import type { E2eBrowserPage } from './e2e-page';
|
|
18
|
-
|
|
19
|
-
/** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
|
|
20
|
-
export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
|
|
21
|
-
|
|
22
|
-
export interface E2eBrowser {
|
|
23
|
-
readonly page: E2eBrowserPage;
|
|
24
|
-
/** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
|
|
25
|
-
close(): void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface OpenE2eBrowserOptions {
|
|
29
|
-
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
30
|
-
readonly timeoutMs?: number | undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Screened HERE, before a browser exists, and not where it lands. It becomes three deadlines — the
|
|
35
|
-
* launch, the handshake and every CDP call — and `Number(process.env.E2E_TIMEOUT ?? '')` is `NaN`
|
|
36
|
-
* for an unset variable and is not nullish, so `??` keeps it: a `setTimeout` given `NaN` fires at
|
|
37
|
-
* 1ms in this Bun, which makes every call report `X_CDP_TIMEOUT` against a browser that was
|
|
38
|
-
* answering. A misdiagnosis reported as a test failure is worse than the failure.
|
|
39
|
-
*/
|
|
40
|
-
const budget = (options: OpenE2eBrowserOptions): number =>
|
|
41
|
-
finiteCount('openE2eBrowser', 'timeoutMs', options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS);
|
|
42
|
-
|
|
43
|
-
const compose = (
|
|
44
|
-
launched: LaunchedBrowser,
|
|
45
|
-
connection: CdpConnection,
|
|
46
|
-
page: E2eBrowserPage,
|
|
47
|
-
): E2eBrowser => ({
|
|
48
|
-
page,
|
|
49
|
-
close(): void {
|
|
50
|
-
// The socket first: closing the process out from under an open connection makes every
|
|
51
|
-
// in-flight call report "the browser closed the CDP connection", which is true and useless.
|
|
52
|
-
connection.close();
|
|
53
|
-
launched.close();
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Launch a browser and attach one page to it. Refuses with `X_CDP_BROWSER_MISSING` when there is
|
|
59
|
-
* nothing to launch — the caller that wants a skip asks `openE2eBrowserIfAvailable` instead.
|
|
60
|
-
*/
|
|
61
|
-
export async function openE2eBrowser(options: OpenE2eBrowserOptions = {}): Promise<E2eBrowser> {
|
|
62
|
-
const timeoutMs = budget(options);
|
|
63
|
-
const executable = await findChrome(options.env ?? process.env);
|
|
64
|
-
if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
65
|
-
return openLaunched(executable, timeoutMs);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** `undefined` when this machine has no browser. Every other failure still throws. */
|
|
69
|
-
export async function openE2eBrowserIfAvailable(
|
|
70
|
-
options: OpenE2eBrowserOptions = {},
|
|
71
|
-
): Promise<E2eBrowser | undefined> {
|
|
72
|
-
const timeoutMs = budget(options);
|
|
73
|
-
const executable = await findChrome(options.env ?? process.env);
|
|
74
|
-
if (executable === undefined) return undefined;
|
|
75
|
-
return openLaunched(executable, timeoutMs);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The half both doors share. Each step undoes the ones before it on the way out: a Chrome that
|
|
80
|
-
* launched and then refused the CDP handshake would otherwise be left running, holding its profile
|
|
81
|
-
* directory, for the rest of the test process — one leaked browser per failing suite.
|
|
82
|
-
*/
|
|
83
|
-
async function openLaunched(executable: string, timeoutMs: number): Promise<E2eBrowser> {
|
|
84
|
-
const launched = await launchChrome({ executable, timeoutMs });
|
|
85
|
-
let connection: CdpConnection;
|
|
86
|
-
try {
|
|
87
|
-
connection = await cdpConnect({ endpoint: launched.endpoint, timeoutMs });
|
|
88
|
-
} catch (error) {
|
|
89
|
-
launched.close();
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
try {
|
|
93
|
-
const page = await cdpE2ePage({ connection, loadTimeoutMs: timeoutMs });
|
|
94
|
-
return compose(launched, connection, page);
|
|
95
|
-
} catch (error) {
|
|
96
|
-
connection.close();
|
|
97
|
-
launched.close();
|
|
98
|
-
throw error;
|
|
99
|
-
}
|
|
100
|
-
}
|
package/src/cdp-connection.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
// One responsibility: a Chrome DevTools Protocol connection over Bun's own WebSocket — request
|
|
2
|
-
// framing, response correlation, and the per-call deadline. Launching a browser is `cdp-launch.ts`
|
|
3
|
-
// and the page surface is `cdp-e2e-page.ts`; this file knows nothing about either.
|
|
4
|
-
//
|
|
5
|
-
// **No library, and that is the point rather than an economy.** `packages/scraping/src/cdp-port.ts`
|
|
6
|
-
// declares a ~25-method port because `ScrapePage` is a full scraping surface, and its intended
|
|
7
|
-
// implementation is `puppeteer-core`. `E2eBrowserPage` is FIVE methods, and CDP's wire format is
|
|
8
|
-
// one JSON object with an `id` — so the whole thing an e2e driver needs is this file plus two
|
|
9
|
-
// small ones, on Bun's native `WebSocket`, with no dependency to add to a repo whose first
|
|
10
|
-
// non-negotiable is that Bun's natives replace most of them.
|
|
11
|
-
|
|
12
|
-
import { assert } from '@ultimat3/core';
|
|
13
|
-
import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
|
|
14
|
-
|
|
15
|
-
/** One CDP result. `unknown` because every payload here is somebody else's JSON. */
|
|
16
|
-
export interface CdpResult {
|
|
17
|
-
readonly result?: Record<string, unknown> | undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface CdpConnection {
|
|
21
|
-
/** Send one command. `sessionId` targets an attached page rather than the browser itself. */
|
|
22
|
-
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<CdpResult>;
|
|
23
|
-
/**
|
|
24
|
-
* Wait for the next occurrence of one CDP **event**, or for the deadline. Answers `true` when the
|
|
25
|
-
* event arrived and `false` when it did not — it never throws, because every caller has a better
|
|
26
|
-
* assertion to fail on than "the event was late".
|
|
27
|
-
*
|
|
28
|
-
* It exists because a command's reply is not always the signal. `Page.navigate`'s reply is
|
|
29
|
-
* DROPPED whenever the navigation swaps the render process — measured on Chrome 150: the page
|
|
30
|
-
* loads, the server is hit, a later `Runtime.evaluate` answers, and the navigate frame never
|
|
31
|
-
* comes back at all. A driver that treated the reply as the completion signal waits out its full
|
|
32
|
-
* deadline on the most ordinary navigation there is.
|
|
33
|
-
*/
|
|
34
|
-
once(method: string, sessionId: string | undefined, timeoutMs: number): Promise<boolean>;
|
|
35
|
-
/**
|
|
36
|
-
* Subscribe to every occurrence of one CDP event, until the returned function is called.
|
|
37
|
-
*
|
|
38
|
-
* `once` cannot express what this is for: a target that attaches AFTER the driver stopped
|
|
39
|
-
* listening is a service worker whose network conditions nobody set, which is an `offline()`
|
|
40
|
-
* that silently does nothing to the one thing serving the page.
|
|
41
|
-
*/
|
|
42
|
-
on(method: string, listener: (params: Record<string, unknown>) => void): () => void;
|
|
43
|
-
close(): void;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface Pending {
|
|
47
|
-
readonly resolve: (value: CdpResult) => void;
|
|
48
|
-
readonly reject: (reason: Error) => void;
|
|
49
|
-
readonly timer: ReturnType<typeof setTimeout>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** A CDP error frame: `{ error: { code, message } }`, both fields somebody else's. */
|
|
53
|
-
const errorText = (frame: Record<string, unknown>): string | undefined => {
|
|
54
|
-
const error = frame['error'];
|
|
55
|
-
if (typeof error !== 'object' || error === null) return undefined;
|
|
56
|
-
const message = (error as Record<string, unknown>)['message'];
|
|
57
|
-
return typeof message === 'string' ? message : 'the browser refused the call';
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export interface CdpConnectionOptions {
|
|
61
|
-
readonly endpoint: string;
|
|
62
|
-
/** Per-call deadline. A CDP call that never answers is a suite that never finishes. */
|
|
63
|
-
readonly timeoutMs: number;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConnection> {
|
|
67
|
-
assert(
|
|
68
|
-
options.endpoint.startsWith('ws://') || options.endpoint.startsWith('wss://'),
|
|
69
|
-
`the CDP endpoint is ${options.endpoint === '' ? 'empty' : 'not a WebSocket url'}`,
|
|
70
|
-
'pass the `webSocketDebuggerUrl` Chrome prints on stderr, or the one /json/version answers',
|
|
71
|
-
);
|
|
72
|
-
const socket = new WebSocket(options.endpoint);
|
|
73
|
-
const pending = new Map<number, Pending>();
|
|
74
|
-
const waiters = new Set<(method: string, sessionId: string | undefined) => void>();
|
|
75
|
-
const listeners = new Map<string, Set<(params: Record<string, unknown>) => void>>();
|
|
76
|
-
let nextId = 0;
|
|
77
|
-
let closed = false;
|
|
78
|
-
|
|
79
|
-
// Every in-flight call is settled on close. Without this a suite whose browser died waits out
|
|
80
|
-
// one full deadline per call and reports a timeout, where the true fault is a dead browser.
|
|
81
|
-
const abandon = (reason: string): void => {
|
|
82
|
-
closed = true;
|
|
83
|
-
for (const [, one] of pending) {
|
|
84
|
-
clearTimeout(one.timer);
|
|
85
|
-
one.reject(new CdpCallFailedError({ method: 'the connection', detail: reason }));
|
|
86
|
-
}
|
|
87
|
-
pending.clear();
|
|
88
|
-
// A waiter is a "did this happen" question, and on a dead connection the answer is no. Its
|
|
89
|
-
// own timer settles it, so nothing is left hanging; clearing the set only stops a late frame
|
|
90
|
-
// from resolving a waiter whose connection has gone.
|
|
91
|
-
waiters.clear();
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
socket.onmessage = (event: MessageEvent): void => {
|
|
95
|
-
const raw = typeof event.data === 'string' ? event.data : '';
|
|
96
|
-
let frame: Record<string, unknown>;
|
|
97
|
-
try {
|
|
98
|
-
frame = JSON.parse(raw) as Record<string, unknown>;
|
|
99
|
-
} catch {
|
|
100
|
-
// An unparseable frame is the browser's, not ours, and there is no call to fail with it:
|
|
101
|
-
// correlation is by `id`, and a frame we cannot read has none. Events land here too.
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
const id = frame['id'];
|
|
105
|
-
if (typeof id !== 'number') {
|
|
106
|
-
const method = frame['method'];
|
|
107
|
-
if (typeof method !== 'string') return;
|
|
108
|
-
const on = frame['sessionId'];
|
|
109
|
-
for (const waiter of [...waiters]) waiter(method, typeof on === 'string' ? on : undefined);
|
|
110
|
-
const subscribed = listeners.get(method);
|
|
111
|
-
if (subscribed !== undefined) {
|
|
112
|
-
const params = frame['params'];
|
|
113
|
-
const payload: Record<string, unknown> =
|
|
114
|
-
typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {};
|
|
115
|
-
// A copy, because a listener may unsubscribe itself while this loop is running.
|
|
116
|
-
for (const listener of [...subscribed]) listener(payload);
|
|
117
|
-
}
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
const one = pending.get(id);
|
|
121
|
-
if (one === undefined) return;
|
|
122
|
-
pending.delete(id);
|
|
123
|
-
clearTimeout(one.timer);
|
|
124
|
-
const failed = errorText(frame);
|
|
125
|
-
if (failed !== undefined) {
|
|
126
|
-
one.reject(new CdpCallFailedError({ method: `call ${String(id)}`, detail: failed }));
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
const result = frame['result'];
|
|
130
|
-
one.resolve({
|
|
131
|
-
result:
|
|
132
|
-
typeof result === 'object' && result !== null
|
|
133
|
-
? (result as Record<string, unknown>)
|
|
134
|
-
: undefined,
|
|
135
|
-
});
|
|
136
|
-
};
|
|
137
|
-
socket.onclose = (): void => abandon('the browser closed the CDP connection');
|
|
138
|
-
socket.onerror = (): void => abandon('the CDP connection failed');
|
|
139
|
-
|
|
140
|
-
await new Promise<void>((resolve, reject) => {
|
|
141
|
-
const timer = setTimeout(() => {
|
|
142
|
-
reject(new CdpTimeoutError({ method: 'connect', timeoutMs: options.timeoutMs }));
|
|
143
|
-
}, options.timeoutMs);
|
|
144
|
-
socket.onopen = (): void => {
|
|
145
|
-
clearTimeout(timer);
|
|
146
|
-
resolve();
|
|
147
|
-
};
|
|
148
|
-
// `onerror` is replaced for the handshake only, then restored above: a failure BEFORE open has
|
|
149
|
-
// no pending call to abandon, and rejecting is the only way the caller hears about it.
|
|
150
|
-
socket.onerror = (): void => {
|
|
151
|
-
clearTimeout(timer);
|
|
152
|
-
reject(
|
|
153
|
-
new CdpCallFailedError({ method: 'connect', detail: 'the browser refused the connection' }),
|
|
154
|
-
);
|
|
155
|
-
};
|
|
156
|
-
});
|
|
157
|
-
socket.onerror = (): void => abandon('the CDP connection failed');
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
send(method, params = {}, sessionId): Promise<CdpResult> {
|
|
161
|
-
if (closed) {
|
|
162
|
-
return Promise.reject(
|
|
163
|
-
new CdpCallFailedError({ method, detail: 'the CDP connection is already closed' }),
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
nextId += 1;
|
|
167
|
-
const id = nextId;
|
|
168
|
-
return new Promise<CdpResult>((resolve, reject) => {
|
|
169
|
-
const timer = setTimeout(() => {
|
|
170
|
-
pending.delete(id);
|
|
171
|
-
reject(new CdpTimeoutError({ method, timeoutMs: options.timeoutMs }));
|
|
172
|
-
}, options.timeoutMs);
|
|
173
|
-
pending.set(id, { resolve, reject, timer });
|
|
174
|
-
socket.send(
|
|
175
|
-
JSON.stringify({ id, method, params, ...(sessionId === undefined ? {} : { sessionId }) }),
|
|
176
|
-
);
|
|
177
|
-
});
|
|
178
|
-
},
|
|
179
|
-
once(method, sessionId, timeoutMs): Promise<boolean> {
|
|
180
|
-
if (closed) return Promise.resolve(false);
|
|
181
|
-
return new Promise<boolean>((resolve) => {
|
|
182
|
-
const waiter = (seen: string, on: string | undefined): void => {
|
|
183
|
-
if (seen !== method) return;
|
|
184
|
-
if (sessionId !== undefined && on !== sessionId) return;
|
|
185
|
-
clearTimeout(timer);
|
|
186
|
-
waiters.delete(waiter);
|
|
187
|
-
resolve(true);
|
|
188
|
-
};
|
|
189
|
-
const timer = setTimeout(() => {
|
|
190
|
-
waiters.delete(waiter);
|
|
191
|
-
resolve(false);
|
|
192
|
-
}, timeoutMs);
|
|
193
|
-
waiters.add(waiter);
|
|
194
|
-
});
|
|
195
|
-
},
|
|
196
|
-
on(method, listener): () => void {
|
|
197
|
-
const subscribed = listeners.get(method) ?? new Set();
|
|
198
|
-
subscribed.add(listener);
|
|
199
|
-
listeners.set(method, subscribed);
|
|
200
|
-
return () => {
|
|
201
|
-
subscribed.delete(listener);
|
|
202
|
-
if (subscribed.size === 0) listeners.delete(method);
|
|
203
|
-
};
|
|
204
|
-
},
|
|
205
|
-
close(): void {
|
|
206
|
-
listeners.clear();
|
|
207
|
-
abandon('the driver closed the CDP connection');
|
|
208
|
-
socket.close();
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
}
|
package/src/cdp-e2e-page.ts
DELETED
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
// One responsibility: `E2eBrowserPage` over a raw CDP connection — attach a tab, navigate,
|
|
2
|
-
// evaluate, click, and set the browser's offline condition. Launching is `cdp-launch.ts` and the
|
|
3
|
-
// wire is `cdp-connection.ts`.
|
|
4
|
-
//
|
|
5
|
-
// FIVE methods, which is the whole reason this exists next to `@ultimat3/scraping` rather than
|
|
6
|
-
// through it: `ScrapePage` is a full scraping surface whose intended implementation is
|
|
7
|
-
// `puppeteer-core`, and an e2e driver needs none of it.
|
|
8
|
-
|
|
9
|
-
import { assert } from '@ultimat3/core';
|
|
10
|
-
import type { CdpConnection } from './cdp-connection';
|
|
11
|
-
import { CdpCallFailedError } from './cdp-errors';
|
|
12
|
-
import type { E2eBrowserPage } from './e2e-page';
|
|
13
|
-
|
|
14
|
-
/** `Runtime.evaluate`'s answer, unwrapped. Every field here is somebody else's JSON. */
|
|
15
|
-
const evaluated = (result: Record<string, unknown> | undefined): unknown => {
|
|
16
|
-
const thrown = result?.['exceptionDetails'];
|
|
17
|
-
if (typeof thrown === 'object' && thrown !== null) {
|
|
18
|
-
const text = (thrown as Record<string, unknown>)['text'];
|
|
19
|
-
throw new CdpCallFailedError({
|
|
20
|
-
method: 'Runtime.evaluate',
|
|
21
|
-
detail: typeof text === 'string' ? text : 'the expression threw in the page',
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
const remote = result?.['result'];
|
|
25
|
-
if (typeof remote !== 'object' || remote === null) return undefined;
|
|
26
|
-
return (remote as Record<string, unknown>)['value'];
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
export interface CdpE2ePageOptions {
|
|
30
|
-
readonly connection: CdpConnection;
|
|
31
|
-
/**
|
|
32
|
-
* How long a navigation's load event may take. Distinct from the connection's per-call deadline:
|
|
33
|
-
* `Page.navigate` ANSWERS as soon as the navigation is committed, so the wait for the load event
|
|
34
|
-
* is a second budget and is the one an app makes long.
|
|
35
|
-
*/
|
|
36
|
-
readonly loadTimeoutMs: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Attach a fresh tab and give back the page.
|
|
41
|
-
*
|
|
42
|
-
* `flatten: true` is not optional: without it every page call has to be wrapped in
|
|
43
|
-
* `Target.sendMessageToTarget` and the answers arrive as nested strings. Flattened, a `sessionId`
|
|
44
|
-
* on the frame is the whole of it, which is what keeps `cdp-connection.ts` a single map.
|
|
45
|
-
*/
|
|
46
|
-
export async function cdpE2ePage(options: CdpE2ePageOptions): Promise<E2eBrowserPage> {
|
|
47
|
-
const send = options.connection.send.bind(options.connection);
|
|
48
|
-
const created = await send('Target.createTarget', { url: 'about:blank' });
|
|
49
|
-
const targetId = created.result?.['targetId'];
|
|
50
|
-
assert(
|
|
51
|
-
typeof targetId === 'string',
|
|
52
|
-
'the browser created a tab and answered no targetId',
|
|
53
|
-
'check the Chrome version supports Target.createTarget — every build since 60 does',
|
|
54
|
-
);
|
|
55
|
-
const attached = await send('Target.attachToTarget', { targetId, flatten: true });
|
|
56
|
-
const sessionId = attached.result?.['sessionId'];
|
|
57
|
-
assert(
|
|
58
|
-
typeof sessionId === 'string',
|
|
59
|
-
'the browser attached to the tab and answered no sessionId',
|
|
60
|
-
'check the Chrome version supports Target.attachToTarget with flatten: true — every build since 79 does',
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
await send('Page.enable', {}, sessionId);
|
|
64
|
-
await send('Runtime.enable', {}, sessionId);
|
|
65
|
-
// Enabled at attach rather than inside `offline()`, because `Network.emulateNetworkConditions`
|
|
66
|
-
// is silently ignored on a session whose Network domain was never enabled — the exact shape of
|
|
67
|
-
// an `offline()` that does nothing while the assertion after it reads as proof.
|
|
68
|
-
await send('Network.enable', {}, sessionId);
|
|
69
|
-
|
|
70
|
-
// **A SERVICE WORKER FETCHES ON ITS OWN TARGET, and that is what `offline()` used to miss.**
|
|
71
|
-
// Measured against the framework's own emitted `sw.js`: with the page session offline, a
|
|
72
|
-
// `networkFirst` route the cache had never seen still answered from the network, because the
|
|
73
|
-
// worker's fetches never crossed the session the condition was set on. The assertion after
|
|
74
|
-
// `offline()` then read as proof of an offline fallback that had not run.
|
|
75
|
-
//
|
|
76
|
-
// So worker targets are AUTO-ATTACHED and carry the same condition. `waitForDebuggerOnStart:
|
|
77
|
-
// false`, or every worker starts paused and the page that registered it never becomes
|
|
78
|
-
// controlled.
|
|
79
|
-
const workers = new Set<string>();
|
|
80
|
-
let offline = false;
|
|
81
|
-
const applyOffline = async (target: string | undefined): Promise<void> => {
|
|
82
|
-
// `-1` is CDP's "no throttling" for both throughputs. Passing 0 would be a browser that can
|
|
83
|
-
// never transfer a byte, which is a different failure wearing the same name.
|
|
84
|
-
await send(
|
|
85
|
-
'Network.emulateNetworkConditions',
|
|
86
|
-
{ offline, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
|
|
87
|
-
target,
|
|
88
|
-
);
|
|
89
|
-
};
|
|
90
|
-
options.connection.on('Target.attachedToTarget', (params) => {
|
|
91
|
-
const info = params['targetInfo'];
|
|
92
|
-
const kind =
|
|
93
|
-
typeof info === 'object' && info !== null
|
|
94
|
-
? (info as Record<string, unknown>)['type']
|
|
95
|
-
: undefined;
|
|
96
|
-
const attachedTo = params['sessionId'];
|
|
97
|
-
if (typeof attachedTo !== 'string') return;
|
|
98
|
-
if (kind !== 'service_worker' && kind !== 'worker' && kind !== 'shared_worker') return;
|
|
99
|
-
workers.add(attachedTo);
|
|
100
|
-
// A worker that attaches while the page is offline inherits the condition — it did not exist
|
|
101
|
-
// when `offline(true)` ran, and a worker registered mid-test is the ordinary case for a PWA.
|
|
102
|
-
void (async () => {
|
|
103
|
-
try {
|
|
104
|
-
await send('Network.enable', {}, attachedTo);
|
|
105
|
-
if (offline) await applyOffline(attachedTo);
|
|
106
|
-
} catch {
|
|
107
|
-
// A worker that died between attaching and being configured is not this driver's problem;
|
|
108
|
-
// its session is gone and every later call on it would report the same thing.
|
|
109
|
-
workers.delete(attachedTo);
|
|
110
|
-
}
|
|
111
|
-
})();
|
|
112
|
-
});
|
|
113
|
-
await send(
|
|
114
|
-
'Target.setAutoAttach',
|
|
115
|
-
{ autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
|
|
116
|
-
sessionId,
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
// `url()` is SYNCHRONOUS on the port, and CDP has no synchronous read — so the last committed
|
|
120
|
-
// url is tracked here. Seeded with the tab's own starting url rather than '' so a `reload()`
|
|
121
|
-
// before any `goto()` navigates somewhere real.
|
|
122
|
-
let current = 'about:blank';
|
|
123
|
-
|
|
124
|
-
const evaluate = async (expression: string): Promise<unknown> => {
|
|
125
|
-
const answer = await send(
|
|
126
|
-
'Runtime.evaluate',
|
|
127
|
-
{ expression, returnByValue: true, awaitPromise: true },
|
|
128
|
-
sessionId,
|
|
129
|
-
);
|
|
130
|
-
return evaluated(answer.result);
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
return {
|
|
134
|
-
url: () => current,
|
|
135
|
-
async goto(url: string): Promise<unknown> {
|
|
136
|
-
// **The load EVENT is the signal, not the reply.** Chrome drops `Page.navigate`'s own reply
|
|
137
|
-
// whenever the navigation swaps the render process — measured on Chrome 150 against a local
|
|
138
|
-
// server: the page loads, the server is hit, a later `Runtime.evaluate` answers `document
|
|
139
|
-
// .title` from the new document, and the navigate frame never comes back at all. Awaiting
|
|
140
|
-
// the reply therefore waited out the full deadline on the most ordinary navigation there is,
|
|
141
|
-
// `about:blank` → `http://localhost:<port>/`. So the waiter is registered BEFORE the send,
|
|
142
|
-
// and the reply is raced against it rather than depended on.
|
|
143
|
-
const loaded = options.connection.once(
|
|
144
|
-
'Page.loadEventFired',
|
|
145
|
-
sessionId,
|
|
146
|
-
options.loadTimeoutMs,
|
|
147
|
-
);
|
|
148
|
-
// A dropped reply is expected, so its rejection is answered rather than thrown: what the
|
|
149
|
-
// reply is still worth reading for is `errorText`, which is the ONLY place a refused
|
|
150
|
-
// navigation is named — an unreachable host loads no page and fires no load event.
|
|
151
|
-
const answered = send('Page.navigate', { url }, sessionId).then(
|
|
152
|
-
(answer) => {
|
|
153
|
-
const failed = answer.result?.['errorText'];
|
|
154
|
-
return typeof failed === 'string' && failed !== '' ? failed : undefined;
|
|
155
|
-
},
|
|
156
|
-
() => undefined,
|
|
157
|
-
);
|
|
158
|
-
const failed = await Promise.race([loaded.then(() => undefined), answered]);
|
|
159
|
-
if (failed !== undefined) {
|
|
160
|
-
throw new CdpCallFailedError({ method: `Page.navigate to ${url}`, detail: failed });
|
|
161
|
-
}
|
|
162
|
-
current = url;
|
|
163
|
-
// The load event may already have fired before the waiter was registered on a same-process
|
|
164
|
-
// navigation, and `answered` can win the race on one too. So the document is asked directly:
|
|
165
|
-
// a `readyState` that is already `complete` resolves at once, and the deadline resolves
|
|
166
|
-
// rather than throwing — a slow page is the app's business, and the assertion after this is
|
|
167
|
-
// what should fail.
|
|
168
|
-
await evaluate(`(() => new Promise((resolve) => {
|
|
169
|
-
if (document.readyState === 'complete') { resolve(true); return; }
|
|
170
|
-
const done = () => resolve(true);
|
|
171
|
-
addEventListener('load', done, { once: true });
|
|
172
|
-
setTimeout(done, ${String(options.loadTimeoutMs)});
|
|
173
|
-
}))()`);
|
|
174
|
-
// The app may have redirected, so the committed url is re-read rather than assumed.
|
|
175
|
-
const settled = await evaluate('location.href');
|
|
176
|
-
if (typeof settled === 'string' && settled !== '') current = settled;
|
|
177
|
-
return undefined;
|
|
178
|
-
},
|
|
179
|
-
evaluate,
|
|
180
|
-
async click(selector: string): Promise<void> {
|
|
181
|
-
// In-page rather than a synthesised `Input.dispatchMouseEvent`: the port takes a SELECTOR,
|
|
182
|
-
// and turning one into coordinates means a box model read, a scroll and a hit test — three
|
|
183
|
-
// more CDP calls, each with its own way to be wrong about an element the page can click.
|
|
184
|
-
const clicked = await evaluate(
|
|
185
|
-
`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`,
|
|
186
|
-
);
|
|
187
|
-
if (clicked !== true) {
|
|
188
|
-
throw new CdpCallFailedError({
|
|
189
|
-
method: `click(${selector})`,
|
|
190
|
-
detail: 'no element in the page matches that selector',
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
},
|
|
194
|
-
async offline(enabled: boolean): Promise<void> {
|
|
195
|
-
offline = enabled;
|
|
196
|
-
// The page first, then every worker attached to it. Sequential rather than `Promise.all`:
|
|
197
|
-
// the set is small, and a worker session that has gone away must not take the page's own
|
|
198
|
-
// condition down with it.
|
|
199
|
-
await applyOffline(sessionId);
|
|
200
|
-
for (const worker of [...workers]) {
|
|
201
|
-
try {
|
|
202
|
-
await applyOffline(worker);
|
|
203
|
-
} catch {
|
|
204
|
-
workers.delete(worker);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
},
|
|
208
|
-
};
|
|
209
|
-
}
|
package/src/cdp-errors.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
// One constructor per way the raw-CDP e2e browser refuses. Every cause quotes a value that came
|
|
2
|
-
// out of a BROWSER or off a spawned process's stderr, so every one is rendered rather than
|
|
3
|
-
// interpolated — the rule `e2e-errors.ts` already states.
|
|
4
|
-
|
|
5
|
-
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* No browser to drive. This is the one an author meets first, so its fix names the two ways out:
|
|
9
|
-
* point the driver at a binary, or accept that this machine cannot run the check.
|
|
10
|
-
*/
|
|
11
|
-
export class CdpBrowserMissingError extends UltimateError {
|
|
12
|
-
constructor(input: { readonly tried: readonly string[] }) {
|
|
13
|
-
super({
|
|
14
|
-
code: 'X_CDP_BROWSER_MISSING',
|
|
15
|
-
cause: `no Chrome or Chromium executable was found — tried ${renderCauseValue(input.tried)}`,
|
|
16
|
-
fix: 'set CHROME_PATH to a Chrome or Chromium binary (GitHub-hosted ubuntu runners ship one at /usr/bin/google-chrome), or skip the browser-backed e2e suite by leaving it unset',
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** The binary ran and never announced an endpoint — a crash, a bad flag, or a sandbox refusal. */
|
|
22
|
-
export class CdpLaunchFailedError extends UltimateError {
|
|
23
|
-
constructor(input: { readonly executable: string; readonly detail: string }) {
|
|
24
|
-
super({
|
|
25
|
-
code: 'X_CDP_LAUNCH_FAILED',
|
|
26
|
-
cause: `${renderCauseValue(input.executable)} did not announce a DevTools endpoint: ${renderCauseValue(input.detail)}`,
|
|
27
|
-
fix: 'run the same binary by hand with --headless=new --remote-debugging-port=0 and read its stderr; inside a container add --no-sandbox --disable-dev-shm-usage, which this launcher already passes',
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A CDP call answered with an error frame, or the connection died under it. */
|
|
33
|
-
export class CdpCallFailedError extends UltimateError {
|
|
34
|
-
constructor(input: { readonly method: string; readonly detail: string }) {
|
|
35
|
-
super({
|
|
36
|
-
code: 'X_CDP_CALL_FAILED',
|
|
37
|
-
cause: `the browser refused ${renderCauseValue(input.method)}: ${renderCauseValue(input.detail)}`,
|
|
38
|
-
fix: 'print what the page had: await page.evaluate(() => document.body.innerHTML) — a call that refuses the same way means the browser itself is gone, so read the launch above it',
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* A call that never answered. Its own code rather than `X_TIMEOUT`, because the actionable half is
|
|
45
|
-
* WHICH call: a hung `Page.navigate` is an app that never finishes responding, and a hung
|
|
46
|
-
* `Runtime.evaluate` is an expression that never settles.
|
|
47
|
-
*/
|
|
48
|
-
export class CdpTimeoutError extends UltimateError {
|
|
49
|
-
constructor(input: { readonly method: string; readonly timeoutMs: number }) {
|
|
50
|
-
super({
|
|
51
|
-
code: 'X_CDP_TIMEOUT',
|
|
52
|
-
cause: `${renderCauseValue(input.method)} did not answer inside ${String(input.timeoutMs)}ms`,
|
|
53
|
-
fix: 'raise timeoutMs on installE2eDriver({ timeoutMs }), or find the request the page is still waiting on — a navigation that never settles is an app that never finishes its response',
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
}
|