@testspectra/react 1.1.8-rc.10
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.md +48 -0
- package/bin/testspectra-react.js +7 -0
- package/dist/cli.js +82 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +422 -0
- package/package.json +62 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# TestSpectra Commercial Software License Agreement
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TestSpectra. All rights reserved.
|
|
4
|
+
|
|
5
|
+
IMPORTANT NOTICE: This software is NOT open-source. Although distribution archives,
|
|
6
|
+
npm packages, and editor extensions are publicly downloadable, installation,
|
|
7
|
+
execution, and use of the Software are strictly conditioned upon obtaining a valid
|
|
8
|
+
Commercial License or an authorized Evaluation/Trial License from TestSpectra.
|
|
9
|
+
|
|
10
|
+
1. GRANT OF LICENSE
|
|
11
|
+
Subject to payment of applicable license fees or an active authorized evaluation
|
|
12
|
+
period, TestSpectra grants you a non-exclusive, non-transferable, revocable license
|
|
13
|
+
to install and execute the Software solely for authoring, debugging, and executing
|
|
14
|
+
automated software tests.
|
|
15
|
+
|
|
16
|
+
2. LICENSE ACTIVATION & ENFORCEMENT
|
|
17
|
+
- Use of the Software requires activation via a valid License Key, authorized CI token,
|
|
18
|
+
or active TestSpectra Cloud account.
|
|
19
|
+
- Any attempt to circumvent, disable, modify, reverse engineer, or tamper with the
|
|
20
|
+
license verification mechanisms, cryptographic signatures, or binary checks is
|
|
21
|
+
a material breach of this Agreement and automatically terminates your license.
|
|
22
|
+
|
|
23
|
+
3. RESTRICTIONS
|
|
24
|
+
You shall not, and shall not permit any third party to:
|
|
25
|
+
- Decompile, disassemble, reverse engineer, or derive source code from the binaries,
|
|
26
|
+
compiled scripts, or libraries of the Software.
|
|
27
|
+
- Sublicense, sell, rent, lease, distribute, or host the Software as a service for third parties.
|
|
28
|
+
- Re-brand, white-label, or package the Software or any of its components as part of a
|
|
29
|
+
competing commercial QA, testing, or developer tooling product.
|
|
30
|
+
- Modify, alter, or remove any proprietary notices, copyright labels, or license headers.
|
|
31
|
+
|
|
32
|
+
4. INTELLECTUAL PROPERTY
|
|
33
|
+
The Software, including its source code, architecture, Rust core engine, algorithms,
|
|
34
|
+
matchers, extensions, interfaces, and documentation, represents valuable intellectual
|
|
35
|
+
property and trade secrets of TestSpectra.
|
|
36
|
+
|
|
37
|
+
5. TERMINATION
|
|
38
|
+
This license is effective until terminated. Your rights under this license will terminate
|
|
39
|
+
automatically without notice if you fail to comply with any of its terms, or upon expiration
|
|
40
|
+
of your active subscription or trial term. Upon termination, you must cease all use of
|
|
41
|
+
the Software and delete all copies in your possession.
|
|
42
|
+
|
|
43
|
+
6. DISCLAIMER OF WARRANTY & LIMITATION OF LIABILITY
|
|
44
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
45
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
|
46
|
+
PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
|
47
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
48
|
+
ARISING FROM THE USE OF OR INABILITY TO USE THE SOFTWARE.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
async function runComponentTests(options = {}) {
|
|
5
|
+
const cwd = options.cwd ?? process.cwd();
|
|
6
|
+
const configPath = fileURLToPath(new URL("../vitest.config.ts", import.meta.url));
|
|
7
|
+
const ext = process.platform === "win32" ? ".cmd" : "";
|
|
8
|
+
const vitestBin = fileURLToPath(new URL(`../node_modules/.bin/vitest${ext}`, import.meta.url));
|
|
9
|
+
const args = ["run", "--config", configPath];
|
|
10
|
+
if (options.outputFile) {
|
|
11
|
+
args.push("--reporter=default", "--reporter=json", `--outputFile.json=${options.outputFile}`);
|
|
12
|
+
}
|
|
13
|
+
if (options.spec) {
|
|
14
|
+
args.push(options.spec);
|
|
15
|
+
}
|
|
16
|
+
const semanticReporter = Boolean(options.onLine);
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const child = spawn(vitestBin, args, {
|
|
19
|
+
stdio: semanticReporter ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
20
|
+
cwd,
|
|
21
|
+
env: {
|
|
22
|
+
...process.env,
|
|
23
|
+
TESTSPECTRA_TEST_ROOT: cwd,
|
|
24
|
+
TESTSPECTRA_HEADLESS: options.headless === false ? "false" : "true",
|
|
25
|
+
TESTSPECTRA_STEP_DELAY: String(options.stepDelayMs ?? 0),
|
|
26
|
+
TESTSPECTRA_FILE_PARALLELISM: options.concurrency === 1 ? "false" : "true",
|
|
27
|
+
...options.browserExecutablePath ? { TESTSPECTRA_BROWSER_EXECUTABLE: options.browserExecutablePath } : {},
|
|
28
|
+
TESTSPECTRA_SEMANTIC_REPORTER: semanticReporter ? "1" : "0"
|
|
29
|
+
},
|
|
30
|
+
shell: process.platform === "win32"
|
|
31
|
+
});
|
|
32
|
+
if (semanticReporter) {
|
|
33
|
+
const handleData = (buf) => {
|
|
34
|
+
for (const line of buf.toString().split("\n")) {
|
|
35
|
+
if (line.trim()) options.onLine(line);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
child.stdout?.on("data", handleData);
|
|
39
|
+
child.stderr?.on("data", handleData);
|
|
40
|
+
}
|
|
41
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
42
|
+
child.on("error", (err) => {
|
|
43
|
+
console.error(err);
|
|
44
|
+
resolve(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function getFlagValue(argv, ...names) {
|
|
49
|
+
const index = argv.findIndex((a) => names.includes(a));
|
|
50
|
+
return index !== -1 ? argv[index + 1] : void 0;
|
|
51
|
+
}
|
|
52
|
+
async function runCli(argv = process.argv.slice(2)) {
|
|
53
|
+
const [command] = argv;
|
|
54
|
+
if (command !== "test") {
|
|
55
|
+
console.error(`Unknown command: ${command ?? "(none)"}. Usage: testspectra-react test`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
let headless = true;
|
|
59
|
+
if (argv.includes("--headed") || argv.includes("--no-headless")) headless = false;
|
|
60
|
+
else if (argv.includes("--headless")) headless = true;
|
|
61
|
+
const rawDelay = getFlagValue(argv, "--step-delay", "--delay");
|
|
62
|
+
let stepDelayMs;
|
|
63
|
+
if (rawDelay !== void 0) {
|
|
64
|
+
const parsed = parseInt(String(rawDelay), 10);
|
|
65
|
+
if (!isNaN(parsed) && parsed >= 0) stepDelayMs = parsed;
|
|
66
|
+
}
|
|
67
|
+
const spec = getFlagValue(argv, "--spec", "-s");
|
|
68
|
+
const outputFile = getFlagValue(argv, "--output", "-o");
|
|
69
|
+
const rawConcurrency = getFlagValue(argv, "--concurrency", "-c");
|
|
70
|
+
let concurrency;
|
|
71
|
+
if (rawConcurrency !== void 0) {
|
|
72
|
+
const parsed = parseInt(String(rawConcurrency), 10);
|
|
73
|
+
if (!isNaN(parsed) && parsed >= 1) concurrency = parsed;
|
|
74
|
+
}
|
|
75
|
+
const browserExecutablePath = getFlagValue(argv, "--browser-executable");
|
|
76
|
+
const ok = await runComponentTests({ headless, stepDelayMs, spec, outputFile, concurrency, browserExecutablePath });
|
|
77
|
+
process.exit(ok ? 0 : 1);
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
runCli,
|
|
81
|
+
runComponentTests
|
|
82
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PlatformDriverBridge } from '@testspectra/matchers';
|
|
2
|
+
|
|
3
|
+
export declare class DomDriverBridge implements PlatformDriverBridge {
|
|
4
|
+
[key: string]: any;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export declare function mount<P = any>(element: any): Promise<any>;
|
|
8
|
+
|
|
9
|
+
export interface RunComponentTestsOptions {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
headless?: boolean;
|
|
12
|
+
stepDelayMs?: number;
|
|
13
|
+
spec?: string;
|
|
14
|
+
outputFile?: string;
|
|
15
|
+
concurrency?: number;
|
|
16
|
+
browserExecutablePath?: string;
|
|
17
|
+
onLine?: (line: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export declare function runComponentTests(options?: RunComponentTestsOptions): Promise<boolean>;
|
|
21
|
+
export declare function runCli(argv?: string[]): Promise<void>;
|
|
22
|
+
export type { ExpectStatic } from 'vitest';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// src/dom_driver_bridge.ts
|
|
2
|
+
var cachedUser = null;
|
|
3
|
+
async function getUser() {
|
|
4
|
+
if (!cachedUser) {
|
|
5
|
+
const { userEvent } = await import("@testing-library/user-event");
|
|
6
|
+
cachedUser = userEvent.setup();
|
|
7
|
+
}
|
|
8
|
+
return cachedUser;
|
|
9
|
+
}
|
|
10
|
+
function resolveSelector(selector) {
|
|
11
|
+
const trimmed = selector.trim();
|
|
12
|
+
if (trimmed.startsWith("~")) {
|
|
13
|
+
return `[aria-label=${JSON.stringify(trimmed.slice(1))}]`;
|
|
14
|
+
}
|
|
15
|
+
return trimmed;
|
|
16
|
+
}
|
|
17
|
+
function resolveEl(selector, index) {
|
|
18
|
+
const css = resolveSelector(selector);
|
|
19
|
+
if (index === null) {
|
|
20
|
+
return document.querySelector(css);
|
|
21
|
+
}
|
|
22
|
+
const all = Array.from(document.querySelectorAll(css));
|
|
23
|
+
if (index >= 0) return all[index] ?? null;
|
|
24
|
+
return all[all.length + index] ?? null;
|
|
25
|
+
}
|
|
26
|
+
function isFormEl(el) {
|
|
27
|
+
const tag = el.tagName;
|
|
28
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
29
|
+
}
|
|
30
|
+
var DomDriverBridge = class {
|
|
31
|
+
mockRules = [];
|
|
32
|
+
consoleErrors = [];
|
|
33
|
+
recordedNetwork = [];
|
|
34
|
+
/** Polls until `selector` resolves to an element, then returns it — shared by every
|
|
35
|
+
* interaction method below (user-event needs a real element handle, not a selector string). */
|
|
36
|
+
async waitForEl(selector, index) {
|
|
37
|
+
let found = null;
|
|
38
|
+
const ok = await globalThis.__TS_RUNTIME__.pollCondition(async () => {
|
|
39
|
+
found = resolveEl(selector, index);
|
|
40
|
+
return found !== null;
|
|
41
|
+
}, globalThis.__TS_RUNTIME__.getImplicitWaitMs());
|
|
42
|
+
if (!ok || !found) throw new Error("Element not found: " + JSON.stringify(selector));
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
async connect() {
|
|
46
|
+
}
|
|
47
|
+
async close() {
|
|
48
|
+
}
|
|
49
|
+
async navigate(_targetUrl) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Spectra.navigate() is not supported in component testing mode. Use Spectra.mount(<Component />) to render a component tree instead of navigating a URL."
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
async back() {
|
|
55
|
+
throw new Error("Spectra.back() is not supported in component testing mode.");
|
|
56
|
+
}
|
|
57
|
+
async forward() {
|
|
58
|
+
throw new Error("Spectra.forward() is not supported in component testing mode.");
|
|
59
|
+
}
|
|
60
|
+
async refresh() {
|
|
61
|
+
throw new Error("Spectra.refresh() is not supported in component testing mode.");
|
|
62
|
+
}
|
|
63
|
+
async setViewport(width, height) {
|
|
64
|
+
try {
|
|
65
|
+
window.resizeTo(width, height);
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async pause(ms) {
|
|
70
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
71
|
+
}
|
|
72
|
+
// ─── Element Actions ─────────────────────────────────────────────────────
|
|
73
|
+
async click(selector, index = null) {
|
|
74
|
+
const el = await this.waitForEl(selector, index);
|
|
75
|
+
await (await getUser()).click(el);
|
|
76
|
+
}
|
|
77
|
+
async doubleClick(selector, index = null) {
|
|
78
|
+
const el = await this.waitForEl(selector, index);
|
|
79
|
+
await (await getUser()).dblClick(el);
|
|
80
|
+
}
|
|
81
|
+
async rightClick(selector, index = null) {
|
|
82
|
+
const el = await this.waitForEl(selector, index);
|
|
83
|
+
await (await getUser()).pointer({ keys: "[MouseRight]", target: el });
|
|
84
|
+
}
|
|
85
|
+
async setValue(selector, value, index = null) {
|
|
86
|
+
const el = await this.waitForEl(selector, index);
|
|
87
|
+
if (!isFormEl(el)) throw new Error("Element is not a form field: " + JSON.stringify(selector));
|
|
88
|
+
const u = await getUser();
|
|
89
|
+
await u.clear(el);
|
|
90
|
+
if (String(value).length > 0) {
|
|
91
|
+
await u.type(el, String(value));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async clearValue(selector, index = null) {
|
|
95
|
+
const el = await this.waitForEl(selector, index);
|
|
96
|
+
await (await getUser()).clear(el);
|
|
97
|
+
}
|
|
98
|
+
async select(selector, option, index = null) {
|
|
99
|
+
const ok = await globalThis.__TS_RUNTIME__.pollCondition(async () => {
|
|
100
|
+
const el = resolveEl(selector, index);
|
|
101
|
+
if (!el || el.tagName !== "SELECT") return false;
|
|
102
|
+
for (let i = 0; i < el.options.length; i++) {
|
|
103
|
+
const opt = el.options[i];
|
|
104
|
+
if (opt.text === option || opt.value === option) {
|
|
105
|
+
el.selectedIndex = i;
|
|
106
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
if (!ok) throw new Error("Select option not found: " + JSON.stringify(option));
|
|
113
|
+
}
|
|
114
|
+
async hover(selector, index = null) {
|
|
115
|
+
const el = await this.waitForEl(selector, index);
|
|
116
|
+
await (await getUser()).hover(el);
|
|
117
|
+
}
|
|
118
|
+
async focus(selector, index = null) {
|
|
119
|
+
const ok = await globalThis.__TS_RUNTIME__.pollCondition(async () => {
|
|
120
|
+
const el = resolveEl(selector, index);
|
|
121
|
+
if (!el) return false;
|
|
122
|
+
el.focus?.();
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
if (!ok) throw new Error("Element not found: " + JSON.stringify(selector));
|
|
126
|
+
}
|
|
127
|
+
async dragDrop(sourceSelector, targetSelector, sourceIndex = null) {
|
|
128
|
+
const src = resolveEl(sourceSelector, sourceIndex);
|
|
129
|
+
const dest = resolveEl(targetSelector, null);
|
|
130
|
+
if (!src || !dest) throw new Error("Drag source or destination not found");
|
|
131
|
+
const dataTransfer = new DataTransfer();
|
|
132
|
+
src.dispatchEvent(new DragEvent("dragstart", { bubbles: true, dataTransfer }));
|
|
133
|
+
dest.dispatchEvent(new DragEvent("dragover", { bubbles: true, dataTransfer }));
|
|
134
|
+
dest.dispatchEvent(new DragEvent("drop", { bubbles: true, dataTransfer }));
|
|
135
|
+
src.dispatchEvent(new DragEvent("dragend", { bubbles: true, dataTransfer }));
|
|
136
|
+
}
|
|
137
|
+
async longPress(selector, duration, index = null) {
|
|
138
|
+
const el = resolveEl(selector, index);
|
|
139
|
+
if (!el) throw new Error("Element not found: " + JSON.stringify(selector));
|
|
140
|
+
el.dispatchEvent(new TouchEvent("touchstart", { bubbles: true }));
|
|
141
|
+
await this.pause(duration);
|
|
142
|
+
el.dispatchEvent(new TouchEvent("touchend", { bubbles: true }));
|
|
143
|
+
}
|
|
144
|
+
async scrollIntoView(selector, index = null) {
|
|
145
|
+
const el = resolveEl(selector, index);
|
|
146
|
+
el?.scrollIntoView({ block: "center", inline: "center" });
|
|
147
|
+
}
|
|
148
|
+
// ─── Element State Inspection ────────────────────────────────────────────
|
|
149
|
+
async getText(selector, index = null) {
|
|
150
|
+
const el = resolveEl(selector, index);
|
|
151
|
+
if (!el) return "";
|
|
152
|
+
return el.innerText !== void 0 ? el.innerText : el.textContent || "";
|
|
153
|
+
}
|
|
154
|
+
async getValue(selector, index = null) {
|
|
155
|
+
const el = resolveEl(selector, index);
|
|
156
|
+
if (!el) return "";
|
|
157
|
+
if (isFormEl(el)) return el.value ?? "";
|
|
158
|
+
return el.getAttribute("value") || "";
|
|
159
|
+
}
|
|
160
|
+
async isDisplayed(selector, index = null) {
|
|
161
|
+
const el = resolveEl(selector, index);
|
|
162
|
+
if (!el) return false;
|
|
163
|
+
const style = window.getComputedStyle(el);
|
|
164
|
+
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
|
|
165
|
+
const rect = el.getBoundingClientRect();
|
|
166
|
+
return rect.width > 0 && rect.height > 0 || el.getClientRects().length > 0 || el.offsetWidth > 0 && el.offsetHeight > 0;
|
|
167
|
+
}
|
|
168
|
+
async isExisting(selector, index = null) {
|
|
169
|
+
return resolveEl(selector, index) !== null;
|
|
170
|
+
}
|
|
171
|
+
async isEnabled(selector, index = null) {
|
|
172
|
+
const el = resolveEl(selector, index);
|
|
173
|
+
if (!el) return false;
|
|
174
|
+
return !el.disabled && !el.hasAttribute("disabled") && !el.classList.contains("disabled") && el.getAttribute("aria-disabled") !== "true";
|
|
175
|
+
}
|
|
176
|
+
async isSelected(selector, index = null) {
|
|
177
|
+
const el = resolveEl(selector, index);
|
|
178
|
+
if (!el) return false;
|
|
179
|
+
return !!el.checked || el.getAttribute("aria-checked") === "true" || el.getAttribute("data-state") === "checked";
|
|
180
|
+
}
|
|
181
|
+
async isFocused(selector, index = null) {
|
|
182
|
+
const el = resolveEl(selector, index);
|
|
183
|
+
if (!el) return false;
|
|
184
|
+
return document.activeElement === el || el.matches(":focus");
|
|
185
|
+
}
|
|
186
|
+
async isClickable(selector, index = null) {
|
|
187
|
+
const el = resolveEl(selector, index);
|
|
188
|
+
if (!el) return false;
|
|
189
|
+
const style = window.getComputedStyle(el);
|
|
190
|
+
if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0") return false;
|
|
191
|
+
const rect = el.getBoundingClientRect();
|
|
192
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
193
|
+
return !el.disabled && !el.hasAttribute("disabled") && !el.classList.contains("disabled") && el.getAttribute("aria-disabled") !== "true";
|
|
194
|
+
}
|
|
195
|
+
async hasClass(selector, className, index = null) {
|
|
196
|
+
const el = resolveEl(selector, index);
|
|
197
|
+
return el ? el.classList.contains(className) : false;
|
|
198
|
+
}
|
|
199
|
+
async getAttribute(selector, name, index = null) {
|
|
200
|
+
const el = resolveEl(selector, index);
|
|
201
|
+
return el ? el.getAttribute(name) : null;
|
|
202
|
+
}
|
|
203
|
+
async getCSSProperty(selector, name, index = null) {
|
|
204
|
+
const el = resolveEl(selector, index);
|
|
205
|
+
return { value: el ? window.getComputedStyle(el).getPropertyValue(name) : "" };
|
|
206
|
+
}
|
|
207
|
+
async count(selector) {
|
|
208
|
+
return document.querySelectorAll(resolveSelector(selector)).length;
|
|
209
|
+
}
|
|
210
|
+
// ─── Gestures & Keys ─────────────────────────────────────────────────────
|
|
211
|
+
async pressKey(key) {
|
|
212
|
+
const active = document.activeElement || document.body;
|
|
213
|
+
const keyName = String(key);
|
|
214
|
+
active.dispatchEvent(new KeyboardEvent("keydown", { key: keyName, bubbles: true }));
|
|
215
|
+
active.dispatchEvent(new KeyboardEvent("keyup", { key: keyName, bubbles: true }));
|
|
216
|
+
}
|
|
217
|
+
async scroll(options = {}) {
|
|
218
|
+
if (options.selector) {
|
|
219
|
+
await this.scrollIntoView(String(options.selector), null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const dir = options.direction || "down";
|
|
223
|
+
const px = options.pixels || 500;
|
|
224
|
+
const deltaY = dir === "up" ? -px : dir === "down" ? px : 0;
|
|
225
|
+
const deltaX = dir === "left" ? -px : dir === "right" ? px : 0;
|
|
226
|
+
window.scrollBy(deltaX, deltaY);
|
|
227
|
+
}
|
|
228
|
+
async swipe(options) {
|
|
229
|
+
const dist = options.distance || 300;
|
|
230
|
+
const deltaY = options.direction === "up" ? dist : options.direction === "down" ? -dist : 0;
|
|
231
|
+
const deltaX = options.direction === "left" ? dist : options.direction === "right" ? -dist : 0;
|
|
232
|
+
window.scrollBy(deltaX, deltaY);
|
|
233
|
+
}
|
|
234
|
+
async grantPermission(_name) {
|
|
235
|
+
}
|
|
236
|
+
// ─── Browser-Level Bridge (SpectraBrowserBridge) ────────────────────────
|
|
237
|
+
async getUrl() {
|
|
238
|
+
return window.location.href;
|
|
239
|
+
}
|
|
240
|
+
async getTitle() {
|
|
241
|
+
return document.title;
|
|
242
|
+
}
|
|
243
|
+
async execute(fn, ...args) {
|
|
244
|
+
if (typeof fn === "function") {
|
|
245
|
+
return fn(...args);
|
|
246
|
+
}
|
|
247
|
+
return new Function(`return (${fn});`)();
|
|
248
|
+
}
|
|
249
|
+
async evaluate(expr) {
|
|
250
|
+
return new Function(`return (${expr});`)();
|
|
251
|
+
}
|
|
252
|
+
async waitUntil(fn, opts) {
|
|
253
|
+
const ok = await globalThis.__TS_RUNTIME__.pollCondition(fn, opts?.timeout);
|
|
254
|
+
if (!ok) throw new Error(opts?.timeoutMsg || "waitUntil() condition was not met in time");
|
|
255
|
+
return ok;
|
|
256
|
+
}
|
|
257
|
+
async getLogs(_type) {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
async shouldHaveUrl(expectedUrl) {
|
|
261
|
+
const actual = await this.getUrl();
|
|
262
|
+
if (actual !== expectedUrl) throw new Error(`Expected browser URL to be "${expectedUrl}", but got "${actual}"`);
|
|
263
|
+
}
|
|
264
|
+
async shouldContainUrl(expectedSubstr) {
|
|
265
|
+
const actual = await this.getUrl();
|
|
266
|
+
if (!actual.includes(expectedSubstr)) {
|
|
267
|
+
throw new Error(`Expected browser URL to contain "${expectedSubstr}", but got "${actual}"`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async shouldHaveTitle(expectedTitle) {
|
|
271
|
+
const actual = await this.getTitle();
|
|
272
|
+
if (actual !== expectedTitle) throw new Error(`Expected page title to be "${expectedTitle}", but got "${actual}"`);
|
|
273
|
+
}
|
|
274
|
+
async shouldContainTitle(expectedSubstr) {
|
|
275
|
+
const actual = await this.getTitle();
|
|
276
|
+
if (!actual.includes(expectedSubstr)) {
|
|
277
|
+
throw new Error(`Expected page title to contain "${expectedSubstr}", but got "${actual}"`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async shouldBeLoaded() {
|
|
281
|
+
if (document.readyState !== "complete" && document.readyState !== "interactive") {
|
|
282
|
+
throw new Error(`Expected document to be loaded, but readyState is "${document.readyState}"`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async shouldHaveNoConsoleErrors() {
|
|
286
|
+
if (this.consoleErrors.length > 0) {
|
|
287
|
+
throw new Error(`Expected no console errors, but found ${this.consoleErrors.length}: ${this.consoleErrors.join("; ")}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async clearCookies() {
|
|
291
|
+
document.cookie.split(";").forEach((c) => {
|
|
292
|
+
const name = c.split("=")[0]?.trim();
|
|
293
|
+
if (name) document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async clearLocalStorage() {
|
|
297
|
+
window.localStorage.clear();
|
|
298
|
+
}
|
|
299
|
+
// ─── Network Interception (Out of Scope for Component Testing MVP) ─────
|
|
300
|
+
async intercept(_patternOrOptions, _method, _fixture, _options) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
'Spectra.intercept() is not supported in component testing mode \u2014 there is no network boundary between the test and the mounted component to intercept. Mock fetch/HTTP clients directly instead (e.g. `vi.spyOn(global, "fetch")` or MSW).'
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
clearMocks() {
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/mount.ts
|
|
310
|
+
import { createRoot } from "react-dom/client";
|
|
311
|
+
var ROOT_ID = "spectra-root";
|
|
312
|
+
var cachedRoot = null;
|
|
313
|
+
function ensureContainer() {
|
|
314
|
+
let container = document.getElementById(ROOT_ID);
|
|
315
|
+
if (!container) {
|
|
316
|
+
container = document.createElement("div");
|
|
317
|
+
container.id = ROOT_ID;
|
|
318
|
+
document.body.appendChild(container);
|
|
319
|
+
}
|
|
320
|
+
return container;
|
|
321
|
+
}
|
|
322
|
+
async function mount(element) {
|
|
323
|
+
const container = ensureContainer();
|
|
324
|
+
if (!cachedRoot) {
|
|
325
|
+
cachedRoot = createRoot(container);
|
|
326
|
+
}
|
|
327
|
+
const componentName = (typeof element.type === "string" ? element.type : element.type?.displayName || element.type?.name) || "component";
|
|
328
|
+
return globalThis.__TS_RUNTIME__.trackStep(
|
|
329
|
+
"action",
|
|
330
|
+
{ type: "action", key: "mount", target: componentName, args: [] },
|
|
331
|
+
async () => {
|
|
332
|
+
cachedRoot.render(element);
|
|
333
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
334
|
+
return globalThis.__TS_RUNTIME__.createElementProxy(`#${ROOT_ID}`);
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/cli.ts
|
|
340
|
+
import { spawn } from "node:child_process";
|
|
341
|
+
import { fileURLToPath } from "node:url";
|
|
342
|
+
async function runComponentTests(options = {}) {
|
|
343
|
+
const cwd = options.cwd ?? process.cwd();
|
|
344
|
+
const configPath = fileURLToPath(new URL("../vitest.config.ts", import.meta.url));
|
|
345
|
+
const ext = process.platform === "win32" ? ".cmd" : "";
|
|
346
|
+
const vitestBin = fileURLToPath(new URL(`../node_modules/.bin/vitest${ext}`, import.meta.url));
|
|
347
|
+
const args = ["run", "--config", configPath];
|
|
348
|
+
if (options.outputFile) {
|
|
349
|
+
args.push("--reporter=default", "--reporter=json", `--outputFile.json=${options.outputFile}`);
|
|
350
|
+
}
|
|
351
|
+
if (options.spec) {
|
|
352
|
+
args.push(options.spec);
|
|
353
|
+
}
|
|
354
|
+
const semanticReporter = Boolean(options.onLine);
|
|
355
|
+
return new Promise((resolve) => {
|
|
356
|
+
const child = spawn(vitestBin, args, {
|
|
357
|
+
stdio: semanticReporter ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
358
|
+
cwd,
|
|
359
|
+
env: {
|
|
360
|
+
...process.env,
|
|
361
|
+
TESTSPECTRA_TEST_ROOT: cwd,
|
|
362
|
+
TESTSPECTRA_HEADLESS: options.headless === false ? "false" : "true",
|
|
363
|
+
TESTSPECTRA_STEP_DELAY: String(options.stepDelayMs ?? 0),
|
|
364
|
+
TESTSPECTRA_FILE_PARALLELISM: options.concurrency === 1 ? "false" : "true",
|
|
365
|
+
...options.browserExecutablePath ? { TESTSPECTRA_BROWSER_EXECUTABLE: options.browserExecutablePath } : {},
|
|
366
|
+
TESTSPECTRA_SEMANTIC_REPORTER: semanticReporter ? "1" : "0"
|
|
367
|
+
},
|
|
368
|
+
shell: process.platform === "win32"
|
|
369
|
+
});
|
|
370
|
+
if (semanticReporter) {
|
|
371
|
+
const handleData = (buf) => {
|
|
372
|
+
for (const line of buf.toString().split("\n")) {
|
|
373
|
+
if (line.trim()) options.onLine(line);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
child.stdout?.on("data", handleData);
|
|
377
|
+
child.stderr?.on("data", handleData);
|
|
378
|
+
}
|
|
379
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
380
|
+
child.on("error", (err) => {
|
|
381
|
+
console.error(err);
|
|
382
|
+
resolve(false);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
function getFlagValue(argv, ...names) {
|
|
387
|
+
const index = argv.findIndex((a) => names.includes(a));
|
|
388
|
+
return index !== -1 ? argv[index + 1] : void 0;
|
|
389
|
+
}
|
|
390
|
+
async function runCli(argv = process.argv.slice(2)) {
|
|
391
|
+
const [command] = argv;
|
|
392
|
+
if (command !== "test") {
|
|
393
|
+
console.error(`Unknown command: ${command ?? "(none)"}. Usage: testspectra-react test`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
let headless = true;
|
|
397
|
+
if (argv.includes("--headed") || argv.includes("--no-headless")) headless = false;
|
|
398
|
+
else if (argv.includes("--headless")) headless = true;
|
|
399
|
+
const rawDelay = getFlagValue(argv, "--step-delay", "--delay");
|
|
400
|
+
let stepDelayMs;
|
|
401
|
+
if (rawDelay !== void 0) {
|
|
402
|
+
const parsed = parseInt(String(rawDelay), 10);
|
|
403
|
+
if (!isNaN(parsed) && parsed >= 0) stepDelayMs = parsed;
|
|
404
|
+
}
|
|
405
|
+
const spec = getFlagValue(argv, "--spec", "-s");
|
|
406
|
+
const outputFile = getFlagValue(argv, "--output", "-o");
|
|
407
|
+
const rawConcurrency = getFlagValue(argv, "--concurrency", "-c");
|
|
408
|
+
let concurrency;
|
|
409
|
+
if (rawConcurrency !== void 0) {
|
|
410
|
+
const parsed = parseInt(String(rawConcurrency), 10);
|
|
411
|
+
if (!isNaN(parsed) && parsed >= 1) concurrency = parsed;
|
|
412
|
+
}
|
|
413
|
+
const browserExecutablePath = getFlagValue(argv, "--browser-executable");
|
|
414
|
+
const ok = await runComponentTests({ headless, stepDelayMs, spec, outputFile, concurrency, browserExecutablePath });
|
|
415
|
+
process.exit(ok ? 0 : 1);
|
|
416
|
+
}
|
|
417
|
+
export {
|
|
418
|
+
DomDriverBridge,
|
|
419
|
+
mount,
|
|
420
|
+
runCli,
|
|
421
|
+
runComponentTests
|
|
422
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@testspectra/react",
|
|
3
|
+
"version": "1.1.8-rc.10",
|
|
4
|
+
"description": "Bundled Vitest browser-mode runner for React component testing with the TestSpectra Spectra.* DSL",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"testspectra",
|
|
7
|
+
"spectra",
|
|
8
|
+
"react",
|
|
9
|
+
"component-testing",
|
|
10
|
+
"vitest"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/TestSpectra/testspectra-source",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/TestSpectra/testspectra-source.git",
|
|
16
|
+
"directory": "tools/react"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"testspectra-react": "./bin/testspectra-react.js"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "dist/index.js",
|
|
23
|
+
"types": "dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./vitest.config.ts": "./vitest.config.ts"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"bin"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@testing-library/react": "^16.0.0",
|
|
38
|
+
"@testing-library/user-event": "^14.5.2",
|
|
39
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
40
|
+
"@vitest/browser": "^2.1.8",
|
|
41
|
+
"playwright": "^1.48.0",
|
|
42
|
+
"vitest": "^2.1.8",
|
|
43
|
+
"@testspectra/matchers": "1.1.8-rc.10"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": ">=18.0.0",
|
|
47
|
+
"react-dom": ">=18.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.14.0",
|
|
51
|
+
"@types/react": "^18.3.3",
|
|
52
|
+
"@types/react-dom": "^18.3.0",
|
|
53
|
+
"esbuild": "^0.28.2",
|
|
54
|
+
"typescript": "^5.6.3"
|
|
55
|
+
},
|
|
56
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "node scripts/build.js",
|
|
59
|
+
"typecheck": "tsc --noEmit",
|
|
60
|
+
"test": "vitest run --config ./vitest.config.ts"
|
|
61
|
+
}
|
|
62
|
+
}
|