@ruakit/runtime 0.0.0-stage → 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +4 -0
- package/dist/action-feedback.d.ts +4 -0
- package/dist/action-feedback.d.ts.map +1 -0
- package/dist/action-feedback.js +3 -0
- package/dist/action-feedback.js.map +7 -0
- package/dist/dom.d.ts +17 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +3 -0
- package/dist/dom.js.map +7 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +7 -0
- package/dist/experimental/index.d.ts +24 -0
- package/dist/experimental/index.d.ts.map +1 -0
- package/dist/experimental/index.js +3 -0
- package/dist/experimental/index.js.map +7 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +7 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +3 -0
- package/dist/operations.js.map +7 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +3 -0
- package/dist/runtime.js.map +7 -0
- package/dist/screenshot.d.ts +3 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +5 -0
- package/dist/screenshot.js.map +7 -0
- package/dist/targets.d.ts +15 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +3 -0
- package/dist/targets.js.map +7 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +7 -0
- package/package.json +37 -3
- package/src/action-feedback.ts +85 -0
- package/src/dom.ts +307 -0
- package/src/errors.ts +14 -0
- package/src/experimental/index.ts +192 -0
- package/src/index.ts +21 -0
- package/src/operations.ts +67 -0
- package/src/runtime.ts +541 -0
- package/src/screenshot.ts +172 -0
- package/src/targets.ts +141 -0
- package/src/types.ts +135 -0
- package/README.md +0 -3
package/src/targets.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import {
|
|
3
|
+
configuration,
|
|
4
|
+
activeElement,
|
|
5
|
+
contains,
|
|
6
|
+
controls,
|
|
7
|
+
isField,
|
|
8
|
+
isVisible,
|
|
9
|
+
observeControl,
|
|
10
|
+
visibleText,
|
|
11
|
+
} from './dom.js';
|
|
12
|
+
import type { ControlData } from './dom.js';
|
|
13
|
+
import { RuntimeError } from './errors.js';
|
|
14
|
+
import type { PageState, Target } from './types.js';
|
|
15
|
+
|
|
16
|
+
interface Binding {
|
|
17
|
+
element: HTMLElement;
|
|
18
|
+
target: Target;
|
|
19
|
+
data: ControlData;
|
|
20
|
+
configuration: string;
|
|
21
|
+
parent: Element | null;
|
|
22
|
+
form: HTMLFormElement | null;
|
|
23
|
+
url: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let nextRuntime = 0;
|
|
27
|
+
|
|
28
|
+
export class Targets {
|
|
29
|
+
readonly document: Document;
|
|
30
|
+
private readonly prefix = `runtime-${++nextRuntime}`;
|
|
31
|
+
private readonly ids = new WeakMap<HTMLElement, string>();
|
|
32
|
+
private bindings = new Map<string, Binding>();
|
|
33
|
+
private sequence = 0;
|
|
34
|
+
private version = 0;
|
|
35
|
+
|
|
36
|
+
constructor(readonly root: Document | HTMLElement) {
|
|
37
|
+
this.document = root.ownerDocument ?? (root as Document);
|
|
38
|
+
if (!this.document.defaultView) {
|
|
39
|
+
throw new TypeError('Create the runtime in a browser, after the app has mounted.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getState(): PageState {
|
|
44
|
+
const next = new Map<string, Binding>();
|
|
45
|
+
let truncated = false;
|
|
46
|
+
|
|
47
|
+
for (const element of controls(this.root)) {
|
|
48
|
+
if (
|
|
49
|
+
!isVisible(element) ||
|
|
50
|
+
element.matches('input[type="hidden"], input[type="password"], input[type="file"]')
|
|
51
|
+
) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (next.size === 200) {
|
|
56
|
+
truncated = true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let id = this.ids.get(element);
|
|
61
|
+
if (!id) {
|
|
62
|
+
id = `${this.prefix}:${++this.sequence}`;
|
|
63
|
+
this.ids.set(element, id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const data = observeControl(element, this.root);
|
|
67
|
+
const previous = this.bindings.get(id);
|
|
68
|
+
const unchanged = previous && this.matches(previous, data);
|
|
69
|
+
|
|
70
|
+
next.set(id, {
|
|
71
|
+
element,
|
|
72
|
+
target: { id, version: unchanged ? previous.target.version : ++this.version },
|
|
73
|
+
data,
|
|
74
|
+
configuration: configuration(element),
|
|
75
|
+
parent: element.parentElement,
|
|
76
|
+
form: isField(element) ? element.form : null,
|
|
77
|
+
url: this.document.URL,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.bindings = next;
|
|
82
|
+
const text = visibleText(this.root, 20001);
|
|
83
|
+
const focused = [...next.values()].find(
|
|
84
|
+
({ element }) => element === activeElement(this.document),
|
|
85
|
+
)?.target;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
url: this.document.URL,
|
|
89
|
+
title: this.document.title,
|
|
90
|
+
text: text.slice(0, 20000),
|
|
91
|
+
controls: [...next.values()].map(({ target, data }) => ({
|
|
92
|
+
target: { ...target },
|
|
93
|
+
...structuredClone(data),
|
|
94
|
+
})),
|
|
95
|
+
focused: focused ? { ...focused } : null,
|
|
96
|
+
truncated:
|
|
97
|
+
truncated ||
|
|
98
|
+
text.length > 20000 ||
|
|
99
|
+
[...next.values()].some(
|
|
100
|
+
({ data }) =>
|
|
101
|
+
data.context.length === 1000 ||
|
|
102
|
+
data.label.length === 1000 ||
|
|
103
|
+
data.description?.length === 1000 ||
|
|
104
|
+
data.placeholder?.length === 1000 ||
|
|
105
|
+
data.groupsTruncated === true,
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
resolve(target: Target, requireEnabled = true): HTMLElement {
|
|
111
|
+
const binding = this.bindings.get(target.id);
|
|
112
|
+
if (
|
|
113
|
+
!binding ||
|
|
114
|
+
binding.target.version !== target.version ||
|
|
115
|
+
!contains(this.root, binding.element) ||
|
|
116
|
+
!isVisible(binding.element) ||
|
|
117
|
+
!this.matches(binding, observeControl(binding.element, this.root))
|
|
118
|
+
) {
|
|
119
|
+
throw new RuntimeError(
|
|
120
|
+
'STALE_TARGET',
|
|
121
|
+
'The control changed. Call getState() and choose it again.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (requireEnabled && !binding.data.enabled) {
|
|
126
|
+
throw new RuntimeError('DISABLED', 'The control is disabled.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return binding.element;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private matches(binding: Binding, data: ControlData): boolean {
|
|
133
|
+
return (
|
|
134
|
+
binding.url === this.document.URL &&
|
|
135
|
+
binding.parent === binding.element.parentElement &&
|
|
136
|
+
binding.form === (isField(binding.element) ? binding.element.form : null) &&
|
|
137
|
+
binding.configuration === configuration(binding.element) &&
|
|
138
|
+
JSON.stringify(binding.data) === JSON.stringify(data)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
/** A reference returned by getState. Keep both fields when passing it across a boundary. */
|
|
4
|
+
export interface Target {
|
|
5
|
+
id: string;
|
|
6
|
+
version: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A named enclosing form, fieldset, section, ARIA group or dialog. Outer groups come first. */
|
|
10
|
+
export interface ControlGroup {
|
|
11
|
+
role: 'form' | 'group' | 'radiogroup' | 'region' | 'dialog' | 'alertdialog';
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ControlState {
|
|
16
|
+
target: Target;
|
|
17
|
+
tag: string;
|
|
18
|
+
type?: string;
|
|
19
|
+
role: string;
|
|
20
|
+
/** Computed accessible name. Empty when the control has no accessible name. */
|
|
21
|
+
label: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
/** A field hint, reported separately from the accessible name. */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Up to four nearest named groups, ordered from outermost to innermost. */
|
|
26
|
+
groups?: ControlGroup[];
|
|
27
|
+
/** A group name or enclosing group was omitted because of observation limits. */
|
|
28
|
+
groupsTruncated?: boolean;
|
|
29
|
+
context: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
readOnly: boolean;
|
|
32
|
+
value?: string;
|
|
33
|
+
checked?: boolean;
|
|
34
|
+
expanded?: boolean;
|
|
35
|
+
selectedValues?: string[];
|
|
36
|
+
options?: { value: string; label: string; enabled: boolean }[];
|
|
37
|
+
valid?: boolean;
|
|
38
|
+
contentEditable?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PageState {
|
|
42
|
+
url: string;
|
|
43
|
+
title: string;
|
|
44
|
+
text: string;
|
|
45
|
+
controls: ControlState[];
|
|
46
|
+
focused: Target | null;
|
|
47
|
+
/** Text or controls were omitted because the observation limit was reached. */
|
|
48
|
+
truncated: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ActionOptions {
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface KeyOptions extends ActionOptions {
|
|
57
|
+
altKey?: boolean;
|
|
58
|
+
ctrlKey?: boolean;
|
|
59
|
+
metaKey?: boolean;
|
|
60
|
+
shiftKey?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type RuntimeAction =
|
|
64
|
+
'click' | 'focus' | 'blur' | 'fill' | 'select' | 'check' | 'insertText' | 'key' | 'scroll';
|
|
65
|
+
|
|
66
|
+
export interface ActionRect {
|
|
67
|
+
readonly left: number;
|
|
68
|
+
readonly top: number;
|
|
69
|
+
readonly width: number;
|
|
70
|
+
readonly height: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Local UI feedback only. DOM references never enter planner requests or receipts. */
|
|
74
|
+
export interface RuntimeActionEvent {
|
|
75
|
+
readonly id: number;
|
|
76
|
+
readonly action: RuntimeAction;
|
|
77
|
+
readonly phase: 'before' | 'after' | 'cancelled' | 'failed';
|
|
78
|
+
readonly element: HTMLElement;
|
|
79
|
+
readonly rect: ActionRect;
|
|
80
|
+
readonly durationMs: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ActionFeedbackOptions {
|
|
84
|
+
/** Observational callback. Exceptions are ignored; targets are revalidated before execution. */
|
|
85
|
+
onAction?: (event: RuntimeActionEvent) => void;
|
|
86
|
+
/** Movement allowance in ms, or a resolver called after scrolling. Integer 0–10000; defaults to 0. */
|
|
87
|
+
actionDelayMs?: number | ((element: HTMLElement) => number);
|
|
88
|
+
/** Scroll feedback targets into view before cursor movement. Defaults to false. */
|
|
89
|
+
scrollToTarget?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RuntimeOptions extends ActionFeedbackOptions {
|
|
93
|
+
/** Defaults to the current document. Use an app container to limit observation and actions. */
|
|
94
|
+
root?: Document | HTMLElement;
|
|
95
|
+
/** Connect your router here. Resolve once navigation has committed. */
|
|
96
|
+
navigate?: (path: string, signal: AbortSignal) => void | Promise<void>;
|
|
97
|
+
/** Default operation deadline, in milliseconds. Defaults to 2000. */
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ScreenshotImage {
|
|
102
|
+
/** Raw base64 image bytes, without a data URL prefix. Maximum decoded size: 5 MiB. */
|
|
103
|
+
data: string;
|
|
104
|
+
mimeType: 'image/png' | 'image/jpeg' | 'image/webp';
|
|
105
|
+
width: number;
|
|
106
|
+
height: number;
|
|
107
|
+
}
|
|
108
|
+
export interface Screenshot extends ScreenshotImage {
|
|
109
|
+
capturedAt: number;
|
|
110
|
+
url: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Each action returns a fresh observation. It does not establish that a larger task succeeded. */
|
|
114
|
+
export interface Runtime {
|
|
115
|
+
getState(): PageState;
|
|
116
|
+
/** Render the configured root to PNG on demand using html-to-image. Private UI is excluded. */
|
|
117
|
+
screenshot(options?: ActionOptions): Promise<Screenshot>;
|
|
118
|
+
click(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
119
|
+
focus(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
120
|
+
blur(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
121
|
+
/** Insert text at the current selection using the browser editing command. */
|
|
122
|
+
insertText(target: Target, text: string, options?: ActionOptions): Promise<PageState>;
|
|
123
|
+
setValue(target: Target, value: string, options?: ActionOptions): Promise<PageState>;
|
|
124
|
+
selectOption(
|
|
125
|
+
target: Target,
|
|
126
|
+
values: readonly string[],
|
|
127
|
+
options?: ActionOptions,
|
|
128
|
+
): Promise<PageState>;
|
|
129
|
+
setChecked(target: Target, checked: boolean, options?: ActionOptions): Promise<PageState>;
|
|
130
|
+
/** Dispatch keydown/keyup to a control. Browser shortcuts and text insertion are not simulated. */
|
|
131
|
+
pressKey(target: Target, key: string, options?: KeyOptions): Promise<PageState>;
|
|
132
|
+
scrollIntoView(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
133
|
+
goto(path: string, options?: ActionOptions): Promise<PageState>;
|
|
134
|
+
waitFor(condition: (state: PageState) => boolean, options?: ActionOptions): Promise<PageState>;
|
|
135
|
+
}
|
package/README.md
DELETED