@ultimat3/cli 21.0.0 → 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 -1650
- package/README.md +40 -2
- package/package.json +30 -30
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- 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 +11 -1
- 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 +111 -43
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +55 -36
- 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 +3 -3
- 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 +28 -30
- package/src/error-pages.ts +19 -7
- package/src/errors.ts +14 -0
- 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 -348
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +6 -1
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +15 -8
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +1 -1
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +28 -27
- 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/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/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} +18 -8
- 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} +39 -16
- 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} +18 -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-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 -347
- 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/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 +49 -12
- package/src/templates/resource.ts +10 -4
- 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 +24 -159
- 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-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 +10 -7
- package/src/verify-run.ts +11 -1
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +1 -26
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -94
- package/src/cdp-connection.ts +0 -247
- package/src/cdp-e2e-page.ts +0 -180
- package/src/cdp-e2e-session.ts +0 -199
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -193
- package/src/cdp-offline-script.ts +0 -73
- package/src/cdp-pipe.ts +0 -77
- package/src/e2e-app.ts +0 -103
- package/src/e2e-browser-handle.ts +0 -55
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -117
- package/src/e2e-errors.ts +0 -117
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -153
- package/src/e2e-preload.ts +0 -64
- package/src/e2e-probe.ts +0 -23
- package/src/e2e-selection.ts +0 -182
- package/src/e2e-spawn.ts +0 -169
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
- /package/src/{dev-render.ts → runtime-render.ts} +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The accessibility tree, read over the page's own CDP session: what the BROWSER computed for a
|
|
2
|
+
// screen reader, after ARIA, label association and `aria-hidden` pruning. A `<div onclick>`
|
|
3
|
+
// answers no role and no name, which is the finding — no read of the markup can reproduce it.
|
|
4
|
+
import type { AxNode } from './browser-launcher-port';
|
|
5
|
+
|
|
6
|
+
/** One session-scoped CDP call. Every answer is somebody else's JSON, read and never cast. */
|
|
7
|
+
export type SessionSend = (
|
|
8
|
+
method: string,
|
|
9
|
+
params?: Record<string, unknown>,
|
|
10
|
+
) => Promise<Readonly<Record<string, unknown>> | undefined>;
|
|
11
|
+
|
|
12
|
+
const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
13
|
+
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
|
|
14
|
+
|
|
15
|
+
/** CDP's `AXValue.value` is any scalar; a reader is told text. */
|
|
16
|
+
const textOf = (wrapped: unknown): string | undefined => {
|
|
17
|
+
const value = record(wrapped)?.['value'];
|
|
18
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
19
|
+
? String(value)
|
|
20
|
+
: undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const flagOf = (node: Readonly<Record<string, unknown>>, name: string): boolean | undefined => {
|
|
24
|
+
const properties = node['properties'];
|
|
25
|
+
if (!Array.isArray(properties)) return undefined;
|
|
26
|
+
const found = properties.map(record).find((property) => property?.['name'] === name);
|
|
27
|
+
return found === undefined ? undefined : record(found['value'])?.['value'] === true;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `nodes[0]` is the element's own node when `fetchRelatives` is false. An EMPTY list is a real
|
|
32
|
+
* answer — an element that never entered the tree — and is kept as ignored, so the count the
|
|
33
|
+
* caller gets is the count of matches.
|
|
34
|
+
*/
|
|
35
|
+
export const toAxNode = (answer: Readonly<Record<string, unknown>> | undefined): AxNode => {
|
|
36
|
+
const nodes = answer?.['nodes'];
|
|
37
|
+
const node = Array.isArray(nodes) ? record(nodes[0]) : undefined;
|
|
38
|
+
if (node === undefined) return { role: '', name: '', ignored: true };
|
|
39
|
+
const description = textOf(node['description']);
|
|
40
|
+
const value = textOf(node['value']);
|
|
41
|
+
const focused = flagOf(node, 'focused');
|
|
42
|
+
const disabled = flagOf(node, 'disabled');
|
|
43
|
+
return {
|
|
44
|
+
role: textOf(node['role']) ?? '',
|
|
45
|
+
name: textOf(node['name']) ?? '',
|
|
46
|
+
...(description === undefined ? {} : { description }),
|
|
47
|
+
...(value === undefined ? {} : { value }),
|
|
48
|
+
...(focused === undefined ? {} : { focused }),
|
|
49
|
+
...(disabled === undefined ? {} : { disabled }),
|
|
50
|
+
ignored: node['ignored'] === true,
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Every match of `selector`, in document order, at most `max` — one round trip per match. */
|
|
55
|
+
export async function axNodesFor(
|
|
56
|
+
send: SessionSend,
|
|
57
|
+
selector: string,
|
|
58
|
+
max: number,
|
|
59
|
+
): Promise<readonly AxNode[]> {
|
|
60
|
+
const root = record(record((await send('DOM.getDocument', { depth: 0 }))?.['root']));
|
|
61
|
+
const nodeIds = (await send('DOM.querySelectorAll', { nodeId: root?.['nodeId'], selector }))?.[
|
|
62
|
+
'nodeIds'
|
|
63
|
+
];
|
|
64
|
+
const out: AxNode[] = [];
|
|
65
|
+
for (const nodeId of Array.isArray(nodeIds) ? nodeIds.slice(0, max) : []) {
|
|
66
|
+
const described = record((await send('DOM.describeNode', { nodeId }))?.['node']);
|
|
67
|
+
out.push(
|
|
68
|
+
toAxNode(
|
|
69
|
+
await send('Accessibility.getPartialAXTree', {
|
|
70
|
+
backendNodeId: described?.['backendNodeId'],
|
|
71
|
+
fetchRelatives: false,
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The clock a shot session runs on, and the page deadline a run uses when it names none. The
|
|
2
|
+
// system clock and `Bun.sleep`: a test hands `open()` its own `ShotClock`, where sleeping IS
|
|
3
|
+
// advancing, so a 30-second deadline under test finishes in microseconds.
|
|
4
|
+
import { systemClock } from '@ultimat3/core';
|
|
5
|
+
import type { ShotClock } from './browser-launcher-port';
|
|
6
|
+
|
|
7
|
+
/** A navigation or a wait with no `timeout` of its own gets this. */
|
|
8
|
+
export const DEFAULT_PAGE_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export const systemShotClock: ShotClock = Object.freeze({
|
|
11
|
+
now: () => systemClock.now(),
|
|
12
|
+
monotonic: () => systemClock.monotonic(),
|
|
13
|
+
sleep: (ms: number) => Bun.sleep(ms),
|
|
14
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `x shot`'s browser, over raw CDP and with no library: launch Chrome here on its debugging pipe
|
|
2
|
+
// (`@ultimat3/testing`'s launcher, the one the e2e step already runs on) or ATTACH to one somebody
|
|
3
|
+
// else runs over `--cdp-url`. One `open()` is one browser and one page, so a picture's console and
|
|
4
|
+
// network are its own. The page is `cdp-shot-page.ts`; this file is the session around it.
|
|
5
|
+
import type { CdpConnection, LaunchedBrowser } from '@ultimat3/testing';
|
|
6
|
+
import {
|
|
7
|
+
CdpBrowserMissingError,
|
|
8
|
+
CdpCallFailedError,
|
|
9
|
+
CHROME_CANDIDATES,
|
|
10
|
+
cdpConnect,
|
|
11
|
+
launchChrome,
|
|
12
|
+
} from '@ultimat3/testing';
|
|
13
|
+
import type { ShotDriver, ShotSession, ShotSessionInit } from './browser-launcher-port';
|
|
14
|
+
import { cdpShotPage } from './cdp-shot-page';
|
|
15
|
+
import { watchPage } from './cdp-shot-watch';
|
|
16
|
+
|
|
17
|
+
export const CDP_SHOT_DRIVER = 'cdp';
|
|
18
|
+
|
|
19
|
+
/** What a picture is laid out in when nothing declares a size — the size every earlier shot had. */
|
|
20
|
+
export const DEFAULT_SHOT_VIEWPORT = Object.freeze({ width: 800, height: 600 });
|
|
21
|
+
|
|
22
|
+
export interface CdpShotDriverOptions {
|
|
23
|
+
/** Launch this binary here. Ignored when `cdpUrl` is set. */
|
|
24
|
+
readonly executablePath?: string | undefined;
|
|
25
|
+
/** Attach here: a provider's `wss://` session, or a sidecar's `http://host:9222`. */
|
|
26
|
+
readonly cdpUrl?: string | undefined;
|
|
27
|
+
readonly viewport?: { readonly width: number; readonly height: number } | undefined;
|
|
28
|
+
/** Test seams: the two ways a connection comes to exist, so no test needs a browser. */
|
|
29
|
+
readonly launch?:
|
|
30
|
+
| ((executable: string, timeoutMs: number) => Promise<LaunchedBrowser>)
|
|
31
|
+
| undefined;
|
|
32
|
+
readonly connect?: ((endpoint: string, timeoutMs: number) => Promise<CdpConnection>) | undefined;
|
|
33
|
+
readonly fetchJson?: ((url: string) => Promise<unknown>) | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const field = (value: unknown, key: string): string | undefined => {
|
|
37
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
38
|
+
const found = (value as Record<string, unknown>)[key];
|
|
39
|
+
return typeof found === 'string' ? found : undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A sidecar publishes `http://host:9222`, and the socket is what `/json/version` names. A provider
|
|
44
|
+
* hands out the socket itself. Either way the answer is one `ws:`/`wss:` url.
|
|
45
|
+
*/
|
|
46
|
+
async function socketUrl(
|
|
47
|
+
cdpUrl: string,
|
|
48
|
+
fetchJson: (url: string) => Promise<unknown>,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
if (cdpUrl.startsWith('ws://') || cdpUrl.startsWith('wss://')) return cdpUrl;
|
|
51
|
+
const version = new URL('/json/version', cdpUrl).toString();
|
|
52
|
+
const socket = field(await fetchJson(version), 'webSocketDebuggerUrl');
|
|
53
|
+
if (socket === undefined) {
|
|
54
|
+
throw new CdpCallFailedError({
|
|
55
|
+
method: `GET ${version}`,
|
|
56
|
+
detail: 'the endpoint answered no webSocketDebuggerUrl',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return socket;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const defaultFetchJson = async (url: string): Promise<unknown> => (await fetch(url)).json();
|
|
63
|
+
|
|
64
|
+
export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
|
|
65
|
+
const viewport = options.viewport ?? DEFAULT_SHOT_VIEWPORT;
|
|
66
|
+
const launch =
|
|
67
|
+
options.launch ??
|
|
68
|
+
((executable: string, timeoutMs: number) => launchChrome({ executable, timeoutMs }));
|
|
69
|
+
const connect =
|
|
70
|
+
options.connect ??
|
|
71
|
+
((endpoint: string, timeoutMs: number) => cdpConnect({ endpoint, timeoutMs }));
|
|
72
|
+
|
|
73
|
+
/** The browser half of a session: its connection, and how to end it. */
|
|
74
|
+
const browser = async (
|
|
75
|
+
timeoutMs: number,
|
|
76
|
+
): Promise<{ connection: CdpConnection; end: () => Promise<void> }> => {
|
|
77
|
+
if (options.cdpUrl !== undefined) {
|
|
78
|
+
const endpoint = await socketUrl(options.cdpUrl, options.fetchJson ?? defaultFetchJson);
|
|
79
|
+
const connection = await connect(endpoint, timeoutMs);
|
|
80
|
+
return {
|
|
81
|
+
connection,
|
|
82
|
+
// BOTH halves: a remote browser is somebody else's bill, and a close that only hung up
|
|
83
|
+
// would leave it running until the provider timed it out.
|
|
84
|
+
end: async () => {
|
|
85
|
+
await connection.send('Browser.close').catch(() => undefined);
|
|
86
|
+
connection.close();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (options.executablePath === undefined) {
|
|
91
|
+
throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
92
|
+
}
|
|
93
|
+
const launched = await launch(options.executablePath, timeoutMs);
|
|
94
|
+
return { connection: launched.connection, end: async () => launched.close() };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
name: CDP_SHOT_DRIVER,
|
|
99
|
+
async open(init: ShotSessionInit): Promise<ShotSession> {
|
|
100
|
+
const { connection, end } = await browser(init.timeoutMs);
|
|
101
|
+
let closed = false;
|
|
102
|
+
let stopWatch: () => void = () => undefined;
|
|
103
|
+
const close = async (): Promise<void> => {
|
|
104
|
+
if (closed) return;
|
|
105
|
+
closed = true;
|
|
106
|
+
stopWatch();
|
|
107
|
+
await end().catch(() => undefined);
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
const created = await connection.send('Target.createTarget', { url: 'about:blank' });
|
|
111
|
+
const targetId = field(created.result, 'targetId');
|
|
112
|
+
const attached = await connection.send('Target.attachToTarget', {
|
|
113
|
+
targetId,
|
|
114
|
+
flatten: true,
|
|
115
|
+
});
|
|
116
|
+
const sessionId = field(attached.result, 'sessionId');
|
|
117
|
+
if (targetId === undefined || sessionId === undefined) {
|
|
118
|
+
throw new CdpCallFailedError({
|
|
119
|
+
method: 'Target.attachToTarget',
|
|
120
|
+
detail: 'the browser opened a page and answered no target or session id',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// Watching BEFORE the domains are on, so the first request is already counted.
|
|
124
|
+
const watch = watchPage({
|
|
125
|
+
connection,
|
|
126
|
+
sessionId,
|
|
127
|
+
clock: init.clock,
|
|
128
|
+
allowHosts: init.rules.allowHosts,
|
|
129
|
+
});
|
|
130
|
+
stopWatch = () => watch.stop();
|
|
131
|
+
const on = (method: string, params: Record<string, unknown> = {}) =>
|
|
132
|
+
connection.send(method, params, sessionId);
|
|
133
|
+
await on('Runtime.enable');
|
|
134
|
+
await on('Page.enable');
|
|
135
|
+
await on('Network.enable');
|
|
136
|
+
await on('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
137
|
+
await on('Emulation.setDeviceMetricsOverride', {
|
|
138
|
+
width: viewport.width,
|
|
139
|
+
height: viewport.height,
|
|
140
|
+
deviceScaleFactor: 1,
|
|
141
|
+
mobile: false,
|
|
142
|
+
});
|
|
143
|
+
return { page: cdpShotPage({ connection, sessionId, init, watch }), close };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
await close();
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// What "the element is ready" means for the raw-CDP shot page: the in-page snapshot of every match
|
|
2
|
+
// (layout box and hit-test included, which no DOM-only check can see), and the poll that waits for
|
|
3
|
+
// the first match to reach a state. Two refusals, because they are two questions: the page does
|
|
4
|
+
// not have the element, or it does and something keeps it from being acted on.
|
|
5
|
+
import type { ActionabilityState, ElementSnapshot, ShotClock } from './browser-launcher-port';
|
|
6
|
+
import { ShotElementMissingError, ShotElementUnreadyError } from './cdp-shot-errors';
|
|
7
|
+
|
|
8
|
+
/** JSON text, so the answer has one deserialiser: `parseSnapshots` below. */
|
|
9
|
+
export const snapshotExpression = (selector: string): string => `(() => {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const el of document.querySelectorAll(${JSON.stringify(selector)})) {
|
|
12
|
+
const style = getComputedStyle(el);
|
|
13
|
+
const rect = el.getBoundingClientRect();
|
|
14
|
+
const attrs = [];
|
|
15
|
+
for (const attribute of el.attributes) attrs.push([attribute.name, attribute.value]);
|
|
16
|
+
const top = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
17
|
+
out.push({
|
|
18
|
+
tag: el.tagName.toLowerCase(),
|
|
19
|
+
attrs,
|
|
20
|
+
text: (el.textContent || '').replace(/\\s+/g, ' ').trim(),
|
|
21
|
+
value: typeof el.value === 'string' ? el.value : '',
|
|
22
|
+
visible: style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0',
|
|
23
|
+
enabled: el.disabled !== true && el.getAttribute('aria-disabled') !== 'true',
|
|
24
|
+
box: { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
|
25
|
+
hitTarget: top !== null && (top === el || el.contains(top)),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(out);
|
|
29
|
+
})()`;
|
|
30
|
+
|
|
31
|
+
const record = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
32
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
33
|
+
? (value as Record<string, unknown>)
|
|
34
|
+
: undefined;
|
|
35
|
+
|
|
36
|
+
const str = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
37
|
+
const num = (value: unknown): number => (typeof value === 'number' ? value : 0);
|
|
38
|
+
|
|
39
|
+
/** Attribute pairs, rebuilt with `fromEntries` so a page's `constructor="…"` stays an own key. */
|
|
40
|
+
const attrsOf = (value: unknown): Readonly<Record<string, string>> =>
|
|
41
|
+
Object.fromEntries(
|
|
42
|
+
(Array.isArray(value) ? value : [])
|
|
43
|
+
.filter((pair): pair is [string, string] => Array.isArray(pair) && pair.length === 2)
|
|
44
|
+
.map(([name, attr]) => [str(name), str(attr)]),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/** The page's answer is `unknown` until this reads it. A row that is not an element is dropped. */
|
|
48
|
+
export function parseSnapshots(raw: unknown): readonly ElementSnapshot[] {
|
|
49
|
+
let rows: unknown;
|
|
50
|
+
try {
|
|
51
|
+
rows = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(rows)) return [];
|
|
56
|
+
const out: ElementSnapshot[] = [];
|
|
57
|
+
for (const row of rows) {
|
|
58
|
+
const el = record(row);
|
|
59
|
+
if (el === undefined || typeof el['tag'] !== 'string') continue;
|
|
60
|
+
const box = record(el['box']);
|
|
61
|
+
out.push({
|
|
62
|
+
tag: el['tag'],
|
|
63
|
+
attrs: attrsOf(el['attrs']),
|
|
64
|
+
text: str(el['text']),
|
|
65
|
+
value: str(el['value']),
|
|
66
|
+
visible: el['visible'] === true,
|
|
67
|
+
enabled: el['enabled'] === true,
|
|
68
|
+
box: {
|
|
69
|
+
x: num(box?.['x']),
|
|
70
|
+
y: num(box?.['y']),
|
|
71
|
+
width: num(box?.['width']),
|
|
72
|
+
height: num(box?.['height']),
|
|
73
|
+
},
|
|
74
|
+
hitTarget: el['hitTarget'] === true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sameBox = (a: ElementSnapshot, b: ElementSnapshot): boolean =>
|
|
81
|
+
a.box?.x === b.box?.x &&
|
|
82
|
+
a.box?.y === b.box?.y &&
|
|
83
|
+
a.box?.width === b.box?.width &&
|
|
84
|
+
a.box?.height === b.box?.height;
|
|
85
|
+
|
|
86
|
+
/** Why this snapshot is not yet at `state`, or `undefined` when it is. Still = two polls agree. */
|
|
87
|
+
export function unreadiness(
|
|
88
|
+
current: ElementSnapshot,
|
|
89
|
+
previous: ElementSnapshot | undefined,
|
|
90
|
+
state: ActionabilityState,
|
|
91
|
+
): string | undefined {
|
|
92
|
+
if (state === 'attached') return undefined;
|
|
93
|
+
if (!current.visible) return 'not visible';
|
|
94
|
+
if (current.box !== undefined && (current.box.width === 0 || current.box.height === 0)) {
|
|
95
|
+
return 'a zero-sized box';
|
|
96
|
+
}
|
|
97
|
+
if (state === 'visible') return undefined;
|
|
98
|
+
if (!current.enabled) return 'disabled';
|
|
99
|
+
if (state === 'enabled') return undefined;
|
|
100
|
+
if (current.hitTarget === false) return 'covered by another element at its centre';
|
|
101
|
+
if (previous === undefined || !sameBox(current, previous)) return 'moving';
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const READY_POLL_MS = 50;
|
|
106
|
+
|
|
107
|
+
export interface ReadyWait {
|
|
108
|
+
readonly selector: string;
|
|
109
|
+
readonly state: ActionabilityState;
|
|
110
|
+
readonly timeoutMs: number;
|
|
111
|
+
readonly clock: ShotClock;
|
|
112
|
+
/** Re-read on every poll: never a handle captured before the loop. */
|
|
113
|
+
readonly snapshot: () => Promise<ElementSnapshot | undefined>;
|
|
114
|
+
readonly url: () => string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Poll until the first match reaches `state`, or refuse with the reason it never did. */
|
|
118
|
+
export async function awaitReady(wait: ReadyWait): Promise<ElementSnapshot> {
|
|
119
|
+
const started = wait.clock.monotonic();
|
|
120
|
+
let previous: ElementSnapshot | undefined;
|
|
121
|
+
let problem: string | undefined;
|
|
122
|
+
let seen = false;
|
|
123
|
+
for (;;) {
|
|
124
|
+
const current = await wait.snapshot();
|
|
125
|
+
if (current !== undefined) {
|
|
126
|
+
seen = true;
|
|
127
|
+
problem = unreadiness(current, previous, wait.state);
|
|
128
|
+
if (problem === undefined) return current;
|
|
129
|
+
previous = current;
|
|
130
|
+
}
|
|
131
|
+
const left = wait.timeoutMs - (wait.clock.monotonic() - started);
|
|
132
|
+
if (!(left > 0)) break;
|
|
133
|
+
await wait.clock.sleep(Math.max(1, Math.min(READY_POLL_MS, left)));
|
|
134
|
+
}
|
|
135
|
+
if (!seen) {
|
|
136
|
+
throw new ShotElementMissingError({
|
|
137
|
+
selector: wait.selector,
|
|
138
|
+
url: wait.url(),
|
|
139
|
+
timeoutMs: wait.timeoutMs,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
throw new ShotElementUnreadyError({
|
|
143
|
+
selector: wait.selector,
|
|
144
|
+
problem: problem ?? 'not ready',
|
|
145
|
+
timeoutMs: wait.timeoutMs,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The four ways `x shot`'s raw-CDP page refuses a verb. A browser that is
|
|
2
|
+
// missing, dies or never answers is `@ultimat3/testing`'s `X_CDP_*` — the wire is the same one the
|
|
3
|
+
// e2e driver runs on. These four are what only a shot can be wrong about: which host it may open,
|
|
4
|
+
// which element it was pointed at, and which key it was asked to press. Their codes and titles are
|
|
5
|
+
// registered with every other CLI code, in `error-codes.ts` — never a second registration here.
|
|
6
|
+
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** A navigation to a host the run did not allow. Refused before a byte leaves. */
|
|
9
|
+
export class ShotHostRefusedError extends UltimateError {
|
|
10
|
+
constructor(input: { readonly url: string; readonly allowHosts: readonly string[] }) {
|
|
11
|
+
super({
|
|
12
|
+
code: 'X_SHOT_HOST_REFUSED',
|
|
13
|
+
cause: `${renderCauseValue(input.url)} is on no host in the allow list ${renderCauseValue(input.allowHosts)}`,
|
|
14
|
+
fix: 'x shot /route --allow-hosts cdn.example.com # name every extra host the page needs; the app host is always allowed',
|
|
15
|
+
meta: { allowHosts: [...input.allowHosts] },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Nothing ever matched: the markup changed, or the selector was never right. */
|
|
21
|
+
export class ShotElementMissingError extends UltimateError {
|
|
22
|
+
constructor(input: {
|
|
23
|
+
readonly selector: string;
|
|
24
|
+
readonly url: string;
|
|
25
|
+
readonly timeoutMs: number;
|
|
26
|
+
}) {
|
|
27
|
+
super({
|
|
28
|
+
code: 'X_SHOT_ELEMENT_MISSING',
|
|
29
|
+
cause: `nothing on ${renderCauseValue(input.url)} matched ${renderCauseValue(input.selector)} within ${String(input.timeoutMs)}ms`,
|
|
30
|
+
fix: 'run ui.inspect on the route first and copy a selector it reports with count >= 1',
|
|
31
|
+
meta: { selector: input.selector },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** It matched, and something kept it from being acted on: hidden, disabled, covered, moving. */
|
|
37
|
+
export class ShotElementUnreadyError extends UltimateError {
|
|
38
|
+
constructor(input: {
|
|
39
|
+
readonly selector: string;
|
|
40
|
+
readonly problem: string;
|
|
41
|
+
readonly timeoutMs: number;
|
|
42
|
+
}) {
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_SHOT_ELEMENT_UNREADY',
|
|
45
|
+
cause: `${renderCauseValue(input.selector)} was still ${renderCauseValue(input.problem)} after ${String(input.timeoutMs)}ms`,
|
|
46
|
+
fix: 'run ui.inspect with the selector and "a11y": true to see what covers or disables it, then act on that element first',
|
|
47
|
+
meta: { selector: input.selector, problem: input.problem },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A chord the grammar refuses — the caller's own literal, so the refusal is terminal. */
|
|
53
|
+
export class ShotKeyInvalidError extends UltimateError {
|
|
54
|
+
constructor(input: { readonly chord: string; readonly reason: string }) {
|
|
55
|
+
super({
|
|
56
|
+
code: 'X_SHOT_KEY_INVALID',
|
|
57
|
+
cause: `the key chord ${renderCauseValue(input.chord)} ${input.reason}`,
|
|
58
|
+
fix: "spell it as modifiers then one key, in the browser's names: 'Meta+K', 'Control+Enter', 'Shift+Tab', 'Escape'",
|
|
59
|
+
meta: { chord: input.chord },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// A key chord — `'Meta+K'`, `'Shift+Tab'`, `'Escape'` — parsed once and turned into the
|
|
2
|
+
// `Input.dispatchKeyEvent` frames a real keyboard would send. Parsed BEFORE any key goes down: a
|
|
3
|
+
// refusal halfway through would leave a modifier held for every later verb on the page.
|
|
4
|
+
import { ShotKeyInvalidError } from './cdp-shot-errors';
|
|
5
|
+
|
|
6
|
+
/** The four modifiers, in the browser's own names. */
|
|
7
|
+
type Modifier = 'Alt' | 'Control' | 'Meta' | 'Shift';
|
|
8
|
+
|
|
9
|
+
/** CDP's bit for each modifier. A `Map`, so a chord word like `constructor` is simply absent. */
|
|
10
|
+
const MODIFIER_BITS: ReadonlyMap<string, number> = new Map([
|
|
11
|
+
['Alt', 1],
|
|
12
|
+
['Control', 2],
|
|
13
|
+
['Meta', 4],
|
|
14
|
+
['Shift', 8],
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const isModifier = (word: string): word is Modifier => MODIFIER_BITS.has(word);
|
|
18
|
+
|
|
19
|
+
const bitOf = (modifier: Modifier): number => MODIFIER_BITS.get(modifier) ?? 0;
|
|
20
|
+
|
|
21
|
+
/** The modifiers that make a key a SHORTCUT rather than a character. */
|
|
22
|
+
const SHORTCUT_BITS = bitOf('Alt') | bitOf('Control') | bitOf('Meta');
|
|
23
|
+
|
|
24
|
+
/** The named keys a shot presses: code, Windows virtual key code, and the text it types if any. */
|
|
25
|
+
const NAMED_KEYS: ReadonlyMap<string, { code: string; keyCode: number; text?: string }> = new Map([
|
|
26
|
+
['Enter', { code: 'Enter', keyCode: 13, text: '\r' }],
|
|
27
|
+
['Tab', { code: 'Tab', keyCode: 9 }],
|
|
28
|
+
['Escape', { code: 'Escape', keyCode: 27 }],
|
|
29
|
+
['Backspace', { code: 'Backspace', keyCode: 8 }],
|
|
30
|
+
['Delete', { code: 'Delete', keyCode: 46 }],
|
|
31
|
+
['Insert', { code: 'Insert', keyCode: 45 }],
|
|
32
|
+
['Home', { code: 'Home', keyCode: 36 }],
|
|
33
|
+
['End', { code: 'End', keyCode: 35 }],
|
|
34
|
+
['PageUp', { code: 'PageUp', keyCode: 33 }],
|
|
35
|
+
['PageDown', { code: 'PageDown', keyCode: 34 }],
|
|
36
|
+
['ArrowUp', { code: 'ArrowUp', keyCode: 38 }],
|
|
37
|
+
['ArrowDown', { code: 'ArrowDown', keyCode: 40 }],
|
|
38
|
+
['ArrowLeft', { code: 'ArrowLeft', keyCode: 37 }],
|
|
39
|
+
['ArrowRight', { code: 'ArrowRight', keyCode: 39 }],
|
|
40
|
+
['Space', { code: 'Space', keyCode: 32, text: ' ' }],
|
|
41
|
+
['Alt', { code: 'AltLeft', keyCode: 18 }],
|
|
42
|
+
['Control', { code: 'ControlLeft', keyCode: 17 }],
|
|
43
|
+
['Meta', { code: 'MetaLeft', keyCode: 91 }],
|
|
44
|
+
['Shift', { code: 'ShiftLeft', keyCode: 16 }],
|
|
45
|
+
...Array.from({ length: 12 }, (_, i): [string, { code: string; keyCode: number }] => [
|
|
46
|
+
`F${String(i + 1)}`,
|
|
47
|
+
{ code: `F${String(i + 1)}`, keyCode: 112 + i },
|
|
48
|
+
]),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** One key, as the dispatch frames need it. `key` is what `KeyboardEvent.key` will read. */
|
|
52
|
+
export interface KeyDefinition {
|
|
53
|
+
readonly key: string;
|
|
54
|
+
readonly code: string;
|
|
55
|
+
readonly keyCode: number;
|
|
56
|
+
readonly text?: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A printable character, or a named key; `undefined` for a name the browser does not have. */
|
|
60
|
+
export function keyDefinition(key: string): KeyDefinition | undefined {
|
|
61
|
+
const named = NAMED_KEYS.get(key);
|
|
62
|
+
if (named !== undefined) return { key: key === 'Space' ? ' ' : key, ...named };
|
|
63
|
+
if ([...key].length !== 1) return undefined;
|
|
64
|
+
if (key === ' ') return { key, code: 'Space', keyCode: 32, text: ' ' };
|
|
65
|
+
const upper = key.toUpperCase();
|
|
66
|
+
if (/^[A-Z]$/.test(upper))
|
|
67
|
+
return { key, code: `Key${upper}`, keyCode: upper.charCodeAt(0), text: key };
|
|
68
|
+
if (/^[0-9]$/.test(key))
|
|
69
|
+
return { key, code: `Digit${key}`, keyCode: key.charCodeAt(0), text: key };
|
|
70
|
+
return { key, code: '', keyCode: 0, text: key };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface KeyChord {
|
|
74
|
+
/** In the order written, which is the order they go down; they come up in reverse. */
|
|
75
|
+
readonly modifiers: readonly Modifier[];
|
|
76
|
+
readonly key: KeyDefinition;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Refuses an empty chord, a trailing `+`, an unknown modifier or key, and a modifier held twice. */
|
|
80
|
+
export function parseKeyChord(chord: string): KeyChord {
|
|
81
|
+
if (chord === '') throw new ShotKeyInvalidError({ chord, reason: 'is empty' });
|
|
82
|
+
const parts = chord.split('+');
|
|
83
|
+
const last = parts.at(-1) ?? '';
|
|
84
|
+
if (last === '') {
|
|
85
|
+
throw new ShotKeyInvalidError({ chord, reason: "ends in '+', so it names no key to press" });
|
|
86
|
+
}
|
|
87
|
+
const modifiers: Modifier[] = [];
|
|
88
|
+
for (const word of parts.slice(0, -1)) {
|
|
89
|
+
if (!isModifier(word)) {
|
|
90
|
+
throw new ShotKeyInvalidError({
|
|
91
|
+
chord,
|
|
92
|
+
reason: `names ${JSON.stringify(word)} as a modifier, and the browser holds only Meta, Control, Alt, Shift`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (modifiers.includes(word)) {
|
|
96
|
+
throw new ShotKeyInvalidError({ chord, reason: `holds ${word} twice` });
|
|
97
|
+
}
|
|
98
|
+
modifiers.push(word);
|
|
99
|
+
}
|
|
100
|
+
const key = keyDefinition(last);
|
|
101
|
+
if (key === undefined) {
|
|
102
|
+
throw new ShotKeyInvalidError({
|
|
103
|
+
chord,
|
|
104
|
+
reason: `names ${JSON.stringify(last)}, which is neither one character nor a key the browser names`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { modifiers, key };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One `Input.dispatchKeyEvent` payload. */
|
|
111
|
+
export type KeyFrame = Readonly<Record<string, unknown>>;
|
|
112
|
+
|
|
113
|
+
const frame = (type: string, key: KeyDefinition, bits: number, text?: string): KeyFrame => ({
|
|
114
|
+
type,
|
|
115
|
+
key: key.key,
|
|
116
|
+
code: key.code,
|
|
117
|
+
windowsVirtualKeyCode: key.keyCode,
|
|
118
|
+
modifiers: bits,
|
|
119
|
+
...(text === undefined ? {} : { text, unmodifiedText: text }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every frame of one chord, in order: each modifier down, the key down and up, the modifiers up in
|
|
124
|
+
* reverse. A key under Control, Alt or Meta sends NO text — a shortcut types nothing, and a
|
|
125
|
+
* `keyDown` carrying text would put the letter into whatever field holds focus.
|
|
126
|
+
*/
|
|
127
|
+
export function chordFrames(chord: KeyChord): readonly KeyFrame[] {
|
|
128
|
+
const frames: KeyFrame[] = [];
|
|
129
|
+
let bits = 0;
|
|
130
|
+
for (const modifier of chord.modifiers) {
|
|
131
|
+
bits |= bitOf(modifier);
|
|
132
|
+
const held = keyDefinition(modifier);
|
|
133
|
+
if (held !== undefined) frames.push(frame('rawKeyDown', held, bits));
|
|
134
|
+
}
|
|
135
|
+
const shortcut = (bits & SHORTCUT_BITS) !== 0;
|
|
136
|
+
const text = shortcut ? undefined : chord.key.text;
|
|
137
|
+
frames.push(frame(text === undefined ? 'rawKeyDown' : 'keyDown', chord.key, bits, text));
|
|
138
|
+
frames.push(frame('keyUp', chord.key, bits));
|
|
139
|
+
for (const modifier of [...chord.modifiers].reverse()) {
|
|
140
|
+
const held = keyDefinition(modifier);
|
|
141
|
+
bits &= ~bitOf(modifier);
|
|
142
|
+
if (held !== undefined) frames.push(frame('keyUp', held, bits));
|
|
143
|
+
}
|
|
144
|
+
return frames;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The frames that type one character into whatever holds focus. */
|
|
148
|
+
export function characterFrames(character: string): readonly KeyFrame[] {
|
|
149
|
+
const key = keyDefinition(character) ?? { key: character, code: '', keyCode: 0, text: character };
|
|
150
|
+
const text = key.text ?? character;
|
|
151
|
+
return [frame('keyDown', key, 0, text), frame('keyUp', key, 0)];
|
|
152
|
+
}
|