@shivaedev/test-story 0.13.0 → 0.15.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/CHANGELOG.md +15 -0
- package/README.md +28 -3
- package/dist/server/fakeHttpService.d.ts +16 -4
- package/dist/server/fakeHttpService.d.ts.map +1 -1
- package/dist/server/fakeHttpService.js +5 -4
- package/dist/server/fakeHttpService.js.map +1 -1
- package/dist/server/internal/fakeHttpAnswer.d.ts +5 -0
- package/dist/server/internal/fakeHttpAnswer.d.ts.map +1 -0
- package/dist/server/internal/fakeHttpAnswer.js +37 -0
- package/dist/server/internal/fakeHttpAnswer.js.map +1 -0
- package/dist/server/internal/fakeHttpControls.d.ts +8 -7
- package/dist/server/internal/fakeHttpControls.d.ts.map +1 -1
- package/dist/server/internal/fakeHttpControls.js +20 -16
- package/dist/server/internal/fakeHttpControls.js.map +1 -1
- package/dist/server/internal/fakeHttpGates.d.ts +5 -3
- package/dist/server/internal/fakeHttpGates.d.ts.map +1 -1
- package/dist/server/internal/fakeHttpGates.js +13 -9
- package/dist/server/internal/fakeHttpGates.js.map +1 -1
- package/dist/server/internal/fakeHttpReceive.d.ts.map +1 -1
- package/dist/server/internal/fakeHttpReceive.js +3 -18
- package/dist/server/internal/fakeHttpReceive.js.map +1 -1
- package/dist/server/internal/fakeHttpScripts.d.ts +7 -0
- package/dist/server/internal/fakeHttpScripts.d.ts.map +1 -0
- package/dist/server/internal/fakeHttpScripts.js +35 -0
- package/dist/server/internal/fakeHttpScripts.js.map +1 -0
- package/dist/server/internal/fakeHttpSelection.d.ts +6 -0
- package/dist/server/internal/fakeHttpSelection.d.ts.map +1 -0
- package/dist/server/internal/fakeHttpSelection.js +9 -0
- package/dist/server/internal/fakeHttpSelection.js.map +1 -0
- package/dist/server/internal/fakeHttpSend.d.ts +4 -1
- package/dist/server/internal/fakeHttpSend.d.ts.map +1 -1
- package/dist/server/internal/fakeHttpSend.js +8 -0
- package/dist/server/internal/fakeHttpSend.js.map +1 -1
- package/dist/server/internal/screenAccessibility.d.ts +9 -0
- package/dist/server/internal/screenAccessibility.d.ts.map +1 -0
- package/dist/server/internal/screenAccessibility.js +44 -0
- package/dist/server/internal/screenAccessibility.js.map +1 -0
- package/dist/server/internal/screenElements.d.ts +6 -5
- package/dist/server/internal/screenElements.d.ts.map +1 -1
- package/dist/server/internal/screenElements.js +9 -11
- package/dist/server/internal/screenElements.js.map +1 -1
- package/dist/server/screenDriver.d.ts +10 -6
- package/dist/server/screenDriver.d.ts.map +1 -1
- package/dist/server/screenDriver.js +10 -7
- package/dist/server/screenDriver.js.map +1 -1
- package/package.json +2 -2
- package/src/server/fakeHttpService.ts +23 -4
- package/src/server/internal/fakeHttpAnswer.ts +48 -0
- package/src/server/internal/fakeHttpControls.ts +23 -19
- package/src/server/internal/fakeHttpGates.ts +16 -11
- package/src/server/internal/fakeHttpReceive.ts +4 -20
- package/src/server/internal/fakeHttpScripts.ts +40 -0
- package/src/server/internal/fakeHttpSelection.ts +10 -0
- package/src/server/internal/fakeHttpSend.ts +10 -1
- package/src/server/internal/screenAccessibility.ts +50 -0
- package/src/server/internal/screenElements.ts +18 -16
- package/src/server/screenDriver.ts +16 -11
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import type {
|
|
2
|
+
import type { FakeHttpRequest, FakeHttpRoute, FakeHttpServiceOptions } from "#fakeHttpService.ts";
|
|
3
|
+
import { fakeHttpAnswer } from "#internal/fakeHttpAnswer.ts";
|
|
3
4
|
import type { fakeHttpControls } from "#internal/fakeHttpControls.ts";
|
|
4
5
|
import { fakeHttpSend } from "#internal/fakeHttpSend.ts";
|
|
5
6
|
|
|
@@ -35,17 +36,6 @@ function decodeParams(params: Record<string, string>) {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
function pageReply(route: FakeHttpRoute, request: FakeHttpRequest): FakeHttpReply {
|
|
39
|
-
if (route.reply) {
|
|
40
|
-
return route.reply(request);
|
|
41
|
-
}
|
|
42
|
-
const page = Number(request.query.get("page") ?? "1");
|
|
43
|
-
if (!Number.isInteger(page) || page < 1) {
|
|
44
|
-
return { body: { error: "page must be a positive integer" }, status: 400 };
|
|
45
|
-
}
|
|
46
|
-
return { body: { items: route.pages[page - 1] ?? [], next: page < route.pages.length ? page + 1 : null } };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
39
|
export async function fakeHttpReceive(
|
|
50
40
|
incoming: IncomingMessage,
|
|
51
41
|
response: ServerResponse,
|
|
@@ -82,7 +72,7 @@ export async function fakeHttpReceive(
|
|
|
82
72
|
await fakeHttpSend(response, { status: 400 });
|
|
83
73
|
return;
|
|
84
74
|
}
|
|
85
|
-
if (options.guard && !options.guard(request)) {
|
|
75
|
+
if ((options.guard && !options.guard(request)) || (found?.route.guard && !found.route.guard(request))) {
|
|
86
76
|
tell(`fake HTTP rejected ${method} ${path} with 401`);
|
|
87
77
|
await fakeHttpSend(response, { status: 401 });
|
|
88
78
|
return;
|
|
@@ -92,13 +82,7 @@ export async function fakeHttpReceive(
|
|
|
92
82
|
await fakeHttpSend(response, { status: 404 });
|
|
93
83
|
return;
|
|
94
84
|
}
|
|
95
|
-
|
|
96
|
-
if (!(await controls.pause(found.name, response))) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
const reply = controls.takeReply(found.name) ?? pageReply(found.route, request);
|
|
100
|
-
tell(`fake HTTP answered ${method} ${path} with ${reply.status ?? 200}`);
|
|
101
|
-
await fakeHttpSend(response, reply, controls.stallBody(found.name));
|
|
85
|
+
await fakeHttpAnswer(found.name, found.route, request, response, controls, tell);
|
|
102
86
|
} catch (error) {
|
|
103
87
|
tell(`fake HTTP could not answer: ${String(error)}`);
|
|
104
88
|
if (response.headersSent) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { FakeHttpFault, FakeHttpRequest, FakeHttpScript, FakeHttpScriptOptions } from "#fakeHttpService.ts";
|
|
3
|
+
import { fakeHttpSelection } from "#internal/fakeHttpSelection.ts";
|
|
4
|
+
|
|
5
|
+
export function fakeHttpScripts(tell: (line: string) => void) {
|
|
6
|
+
const scripts = new Set<{
|
|
7
|
+
selection: ReturnType<typeof fakeHttpSelection>;
|
|
8
|
+
fault: FakeHttpFault;
|
|
9
|
+
options: FakeHttpScriptOptions;
|
|
10
|
+
}>();
|
|
11
|
+
return {
|
|
12
|
+
clear: () => scripts.clear(),
|
|
13
|
+
make: (route: string, fault: FakeHttpFault, options: FakeHttpScriptOptions = {}, narrate = true): FakeHttpScript => {
|
|
14
|
+
const selection = fakeHttpSelection(route, options);
|
|
15
|
+
const script = { fault, options, selection };
|
|
16
|
+
scripts.add(script);
|
|
17
|
+
if (narrate) {
|
|
18
|
+
tell(`fake HTTP ${selection.label} will fault ${fault.kind} ${options.phase ?? "before"} reply ${options.repeat ? "until lifted" : "once"}`);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
lift: Effect.sync(() => {
|
|
22
|
+
if (scripts.delete(script)) {
|
|
23
|
+
tell(`fake HTTP ${selection.label} fault ${fault.kind} lifted`);
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
take: (route: string, request: FakeHttpRequest, phase: "before" | "after") => {
|
|
29
|
+
for (const script of scripts) {
|
|
30
|
+
if ((script.options.phase ?? "before") === phase && script.selection.matches(route, request)) {
|
|
31
|
+
if (!script.options.repeat) {
|
|
32
|
+
scripts.delete(script);
|
|
33
|
+
}
|
|
34
|
+
return script.fault;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FakeHttpRequest, FakeHttpScope } from "#fakeHttpService.ts";
|
|
2
|
+
|
|
3
|
+
export function fakeHttpSelection(route: string, scope: FakeHttpScope = {}) {
|
|
4
|
+
const params = Object.entries(scope.params ?? {}).sort(([left], [right]) => left.localeCompare(right));
|
|
5
|
+
const label = params.length > 0 ? `${route} matching ${JSON.stringify(Object.fromEntries(params))}` : route;
|
|
6
|
+
return {
|
|
7
|
+
label,
|
|
8
|
+
matches: (name: string, request: FakeHttpRequest) => name === route && params.every(([key, value]) => request.params[key] === value),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServerResponse } from "node:http";
|
|
2
|
-
import type { FakeHttpReply } from "#fakeHttpService.ts";
|
|
2
|
+
import type { FakeHttpFault, FakeHttpReply } from "#fakeHttpService.ts";
|
|
3
3
|
|
|
4
4
|
export function fakeHttpConnection(response: ServerResponse) {
|
|
5
5
|
const closed = new Promise<undefined>((resolve) => response.once("close", () => resolve(undefined)));
|
|
@@ -53,3 +53,12 @@ export async function fakeHttpSend(response: ServerResponse, reply: FakeHttpRepl
|
|
|
53
53
|
response.end(body);
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
export async function fakeHttpStrike(response: ServerResponse, fault: Exclude<FakeHttpFault, { kind: "status" }>, reply: FakeHttpReply = {}) {
|
|
58
|
+
if (fault.kind === "reset") {
|
|
59
|
+
response.writeHead(reply.status ?? 200, { "content-type": "application/json", ...reply.headers });
|
|
60
|
+
response.flushHeaders();
|
|
61
|
+
await new Promise<void>((resolve) => response.write("", () => resolve()));
|
|
62
|
+
}
|
|
63
|
+
response.destroy();
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { computeAccessibleName, getRole, isInaccessible } from "dom-accessibility-api";
|
|
2
|
+
import type { ScreenTarget } from "#screenDriver.ts";
|
|
3
|
+
|
|
4
|
+
export function accessibility(element: Element) {
|
|
5
|
+
return { name: computeAccessibleName(element), role: getRole(element) };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function targetName(target: ScreenTarget): string {
|
|
9
|
+
return typeof target === "string"
|
|
10
|
+
? target
|
|
11
|
+
: `${target.role} named ${typeof target.name === "string" ? JSON.stringify(target.name) : String(target.name)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isRendered(found: HTMLElement) {
|
|
15
|
+
const visibility = getComputedStyle(found).visibility;
|
|
16
|
+
if (visibility === "hidden" || visibility === "collapse") {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
for (let ancestor: HTMLElement | null = found; ancestor; ancestor = ancestor.parentElement) {
|
|
20
|
+
const style = getComputedStyle(ancestor);
|
|
21
|
+
if (ancestor.hidden || style.display === "none" || style.contentVisibility === "hidden") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function roleElement(root: HTMLElement, target: Exclude<ScreenTarget, string>) {
|
|
29
|
+
const candidates = Array.from(root.querySelectorAll<HTMLElement>("*"))
|
|
30
|
+
.filter((element) => isRendered(element) && !isInaccessible(element))
|
|
31
|
+
.map((element) => ({ ...accessibility(element), element }))
|
|
32
|
+
.filter(({ role }) => role !== null);
|
|
33
|
+
const matches = candidates.filter(({ role, name }) => {
|
|
34
|
+
if (role !== target.role) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (typeof target.name === "string") {
|
|
38
|
+
return name === target.name;
|
|
39
|
+
}
|
|
40
|
+
return new RegExp(target.name.source, target.name.flags).test(name);
|
|
41
|
+
});
|
|
42
|
+
const found = matches[0];
|
|
43
|
+
if (matches.length === 1 && found) {
|
|
44
|
+
return found.element;
|
|
45
|
+
}
|
|
46
|
+
const listing = candidates.map(({ role, name }) => ` - ${role} named ${JSON.stringify(name)}`).join("\n");
|
|
47
|
+
throw new Error(
|
|
48
|
+
`The screen has ${matches.length} matches for ${targetName(target)}.\nhelp: Use a role and name for exactly one visible target.\nCandidates on screen:\n${listing || " (none)"}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
+
import { isRendered, roleElement, targetName } from "#internal/screenAccessibility.ts";
|
|
2
|
+
import type { ScreenTarget } from "#screenDriver.ts";
|
|
3
|
+
|
|
1
4
|
export function screenElements(root: () => HTMLElement) {
|
|
2
5
|
function rendered(found: HTMLElement, selector: string) {
|
|
3
|
-
|
|
4
|
-
let hidden = visibility === "hidden" || visibility === "collapse";
|
|
5
|
-
for (let ancestor: HTMLElement | null = found; ancestor; ancestor = ancestor.parentElement) {
|
|
6
|
-
const style = getComputedStyle(ancestor);
|
|
7
|
-
hidden ||= Boolean(ancestor.hidden) || style.display === "none" || style.contentVisibility === "hidden";
|
|
8
|
-
}
|
|
9
|
-
if (hidden) {
|
|
6
|
+
if (!isRendered(found)) {
|
|
10
7
|
throw new Error(`The screen's ${selector} is not rendered.\nhelp: Reveal the target and its ancestors before acting on or reading it.`);
|
|
11
8
|
}
|
|
12
9
|
return found;
|
|
13
10
|
}
|
|
14
|
-
function element(selector:
|
|
11
|
+
function element(selector: ScreenTarget) {
|
|
12
|
+
if (typeof selector !== "string") {
|
|
13
|
+
return roleElement(root(), selector);
|
|
14
|
+
}
|
|
15
15
|
const matches = root().querySelectorAll<HTMLElement>(selector);
|
|
16
16
|
const found = matches[0];
|
|
17
17
|
if (matches.length !== 1 || found === undefined) {
|
|
@@ -21,26 +21,28 @@ export function screenElements(root: () => HTMLElement) {
|
|
|
21
21
|
}
|
|
22
22
|
return rendered(found, selector);
|
|
23
23
|
}
|
|
24
|
-
function input(selector:
|
|
24
|
+
function input(selector: ScreenTarget) {
|
|
25
25
|
const found = element(selector);
|
|
26
26
|
if (!(found instanceof HTMLInputElement || found instanceof HTMLTextAreaElement)) {
|
|
27
|
-
throw new Error(
|
|
27
|
+
throw new Error(
|
|
28
|
+
`The screen's ${targetName(selector)} is not a text field.\nhelp: Select a rendered input or textarea to enter or read a value.`,
|
|
29
|
+
);
|
|
28
30
|
}
|
|
29
31
|
return found;
|
|
30
32
|
}
|
|
31
|
-
function enabled(found: HTMLElement, selector:
|
|
33
|
+
function enabled(found: HTMLElement, selector: ScreenTarget) {
|
|
32
34
|
if (found.matches(":disabled") || found.closest("[inert]")) {
|
|
33
|
-
throw new Error(`The screen's ${selector} is disabled.\nhelp: Wait for the app to enable the control before acting on it.`);
|
|
35
|
+
throw new Error(`The screen's ${targetName(selector)} is disabled.\nhelp: Wait for the app to enable the control before acting on it.`);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
return {
|
|
37
39
|
element,
|
|
38
40
|
enabled,
|
|
39
|
-
enter: (selector:
|
|
41
|
+
enter: (selector: ScreenTarget, value: string) => {
|
|
40
42
|
const field = input(selector);
|
|
41
43
|
enabled(field, selector);
|
|
42
44
|
if (field.readOnly) {
|
|
43
|
-
throw new Error(`The screen's ${selector} is read-only.\nhelp: Select an editable input or textarea.`);
|
|
45
|
+
throw new Error(`The screen's ${targetName(selector)} is read-only.\nhelp: Select an editable input or textarea.`);
|
|
44
46
|
}
|
|
45
47
|
field.focus();
|
|
46
48
|
const prototype = field instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
|
|
@@ -48,10 +50,10 @@ export function screenElements(root: () => HTMLElement) {
|
|
|
48
50
|
field.dispatchEvent(new Event("input", { bubbles: true }));
|
|
49
51
|
field.dispatchEvent(new Event("change", { bubbles: true }));
|
|
50
52
|
},
|
|
51
|
-
form: (selector:
|
|
53
|
+
form: (selector: ScreenTarget) => {
|
|
52
54
|
const found = element(selector);
|
|
53
55
|
if (!(found instanceof HTMLFormElement)) {
|
|
54
|
-
throw new Error(`The screen's ${selector} is not a form.\nhelp: Select a rendered form to submit.`);
|
|
56
|
+
throw new Error(`The screen's ${targetName(selector)} is not a form.\nhelp: Select a rendered form to submit.`);
|
|
55
57
|
}
|
|
56
58
|
enabled(found, selector);
|
|
57
59
|
return found;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { computeAccessibleName, getRole } from "dom-accessibility-api";
|
|
2
1
|
import { Effect } from "effect";
|
|
3
2
|
import { withLive } from "@shivaedev/effect-test/withLive.ts";
|
|
3
|
+
import { accessibility, targetName } from "#internal/screenAccessibility.ts";
|
|
4
4
|
import { screenElements } from "#internal/screenElements.ts";
|
|
5
5
|
|
|
6
|
+
export type ScreenTarget = string | { readonly role: string; readonly name: string | RegExp };
|
|
7
|
+
|
|
6
8
|
export interface ScreenDriverOptions {
|
|
7
9
|
readonly act?: (action: () => Promise<void>) => PromiseLike<void>;
|
|
8
10
|
readonly pending: () => boolean;
|
|
@@ -45,30 +47,33 @@ export function screenDriver({ root, pending, tell, act = (action) => action(),
|
|
|
45
47
|
return withLive(wait.pipe(Effect.timeoutOrElse({ duration: limit, orElse: timedOut })));
|
|
46
48
|
}
|
|
47
49
|
return {
|
|
48
|
-
accessible: (selector: string) => {
|
|
50
|
+
accessible: (selector: string | Element) => {
|
|
51
|
+
if (typeof selector !== "string") {
|
|
52
|
+
return accessibility(selector);
|
|
53
|
+
}
|
|
49
54
|
const element = elements.element(selector);
|
|
50
|
-
const accessible =
|
|
55
|
+
const accessible = accessibility(element);
|
|
51
56
|
tell(`read the accessibility of ${selector}: ${JSON.stringify(accessible)}`);
|
|
52
57
|
return accessible;
|
|
53
58
|
},
|
|
54
|
-
click: (selector:
|
|
55
|
-
perform(`click ${selector}`, () => {
|
|
59
|
+
click: (selector: ScreenTarget) =>
|
|
60
|
+
perform(`click ${targetName(selector)}`, () => {
|
|
56
61
|
const element = elements.element(selector);
|
|
57
62
|
elements.enabled(element, selector);
|
|
58
63
|
element.focus();
|
|
59
64
|
element.click();
|
|
60
65
|
}),
|
|
61
|
-
enter: (selector:
|
|
66
|
+
enter: (selector: ScreenTarget, value: string) => perform(`enter a value in ${targetName(selector)}`, () => elements.enter(selector, value)),
|
|
62
67
|
settle,
|
|
63
|
-
submit: (selector = "form") => perform(`submit ${selector}`, () => elements.form(selector).requestSubmit()),
|
|
64
|
-
text: (selector?:
|
|
68
|
+
submit: (selector: ScreenTarget = "form") => perform(`submit ${targetName(selector)}`, () => elements.form(selector).requestSubmit()),
|
|
69
|
+
text: (selector?: ScreenTarget) => {
|
|
65
70
|
const text = (selector === undefined ? elements.rendered(currentRoot(), "root") : elements.element(selector)).innerText;
|
|
66
|
-
tell(`read ${selector
|
|
71
|
+
tell(`read ${selector === undefined ? "the screen" : targetName(selector)}: ${JSON.stringify(text)}`);
|
|
67
72
|
return text;
|
|
68
73
|
},
|
|
69
|
-
value: (selector:
|
|
74
|
+
value: (selector: ScreenTarget) => {
|
|
70
75
|
const value = elements.input(selector).value;
|
|
71
|
-
tell(`read the value in ${selector}: ${JSON.stringify(value)}`);
|
|
76
|
+
tell(`read the value in ${targetName(selector)}: ${JSON.stringify(value)}`);
|
|
72
77
|
return value;
|
|
73
78
|
},
|
|
74
79
|
};
|