guardrail-ship 1.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/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mock-implementation.d.ts +1 -0
- package/dist/mock-implementation.d.ts.map +1 -0
- package/dist/mock-implementation.js +2 -0
- package/dist/mock-implementation.js.map +1 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts +5 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts.map +1 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.js +92 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.js.map +1 -0
- package/dist/mockproof/import-graph-scanner.d.ts +93 -0
- package/dist/mockproof/import-graph-scanner.d.ts.map +1 -0
- package/dist/mockproof/import-graph-scanner.js +411 -0
- package/dist/mockproof/import-graph-scanner.js.map +1 -0
- package/dist/mockproof/index.d.ts +10 -0
- package/dist/mockproof/index.d.ts.map +1 -0
- package/dist/mockproof/index.js +10 -0
- package/dist/mockproof/index.js.map +1 -0
- package/dist/reality-mode/auth-enforcer.d.ts +13 -0
- package/dist/reality-mode/auth-enforcer.d.ts.map +1 -0
- package/dist/reality-mode/auth-enforcer.js +90 -0
- package/dist/reality-mode/auth-enforcer.js.map +1 -0
- package/dist/reality-mode/explorer/critical-flows.d.ts +71 -0
- package/dist/reality-mode/explorer/critical-flows.d.ts.map +1 -0
- package/dist/reality-mode/explorer/critical-flows.js +463 -0
- package/dist/reality-mode/explorer/critical-flows.js.map +1 -0
- package/dist/reality-mode/explorer/flow-parser.d.ts +52 -0
- package/dist/reality-mode/explorer/flow-parser.d.ts.map +1 -0
- package/dist/reality-mode/explorer/flow-parser.js +250 -0
- package/dist/reality-mode/explorer/flow-parser.js.map +1 -0
- package/dist/reality-mode/explorer/index.d.ts +11 -0
- package/dist/reality-mode/explorer/index.d.ts.map +1 -0
- package/dist/reality-mode/explorer/index.js +11 -0
- package/dist/reality-mode/explorer/index.js.map +1 -0
- package/dist/reality-mode/explorer/runtime-explorer.d.ts +35 -0
- package/dist/reality-mode/explorer/runtime-explorer.d.ts.map +1 -0
- package/dist/reality-mode/explorer/runtime-explorer.js +688 -0
- package/dist/reality-mode/explorer/runtime-explorer.js.map +1 -0
- package/dist/reality-mode/explorer/surface-discovery.d.ts +60 -0
- package/dist/reality-mode/explorer/surface-discovery.d.ts.map +1 -0
- package/dist/reality-mode/explorer/surface-discovery.js +357 -0
- package/dist/reality-mode/explorer/surface-discovery.js.map +1 -0
- package/dist/reality-mode/explorer/types.d.ts +275 -0
- package/dist/reality-mode/explorer/types.d.ts.map +1 -0
- package/dist/reality-mode/explorer/types.js +8 -0
- package/dist/reality-mode/explorer/types.js.map +1 -0
- package/dist/reality-mode/fake-success-detector.d.ts +10 -0
- package/dist/reality-mode/fake-success-detector.d.ts.map +1 -0
- package/dist/reality-mode/fake-success-detector.js +76 -0
- package/dist/reality-mode/fake-success-detector.js.map +1 -0
- package/dist/reality-mode/index.d.ts +14 -0
- package/dist/reality-mode/index.d.ts.map +1 -0
- package/dist/reality-mode/index.js +14 -0
- package/dist/reality-mode/index.js.map +1 -0
- package/dist/reality-mode/reality-scanner.d.ts +48 -0
- package/dist/reality-mode/reality-scanner.d.ts.map +1 -0
- package/dist/reality-mode/reality-scanner.js +516 -0
- package/dist/reality-mode/reality-scanner.js.map +1 -0
- package/dist/reality-mode/report-generator.d.ts +11 -0
- package/dist/reality-mode/report-generator.d.ts.map +1 -0
- package/dist/reality-mode/report-generator.js +233 -0
- package/dist/reality-mode/report-generator.js.map +1 -0
- package/dist/reality-mode/traffic-classifier.d.ts +14 -0
- package/dist/reality-mode/traffic-classifier.d.ts.map +1 -0
- package/dist/reality-mode/traffic-classifier.js +131 -0
- package/dist/reality-mode/traffic-classifier.js.map +1 -0
- package/dist/reality-mode/types.d.ts +90 -0
- package/dist/reality-mode/types.d.ts.map +1 -0
- package/dist/reality-mode/types.js +2 -0
- package/dist/reality-mode/types.js.map +1 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts +5 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts.map +1 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.js +146 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.js.map +1 -0
- package/dist/ship-badge/index.d.ts +9 -0
- package/dist/ship-badge/index.d.ts.map +1 -0
- package/dist/ship-badge/index.js +9 -0
- package/dist/ship-badge/index.js.map +1 -0
- package/dist/ship-badge/ship-badge-generator.d.ts +136 -0
- package/dist/ship-badge/ship-badge-generator.d.ts.map +1 -0
- package/dist/ship-badge/ship-badge-generator.js +681 -0
- package/dist/ship-badge/ship-badge-generator.js.map +1 -0
- package/package.json +20 -0
- package/src/index.ts +7 -0
- package/src/mock-implementation.ts +0 -0
- package/src/mockproof/__tests__/import-graph-scanner.test.ts +115 -0
- package/src/mockproof/import-graph-scanner.d.ts +93 -0
- package/src/mockproof/import-graph-scanner.d.ts.map +1 -0
- package/src/mockproof/import-graph-scanner.js +482 -0
- package/src/mockproof/import-graph-scanner.ts +540 -0
- package/src/mockproof/index.ts +18 -0
- package/src/reality-mode/auth-enforcer.ts +97 -0
- package/src/reality-mode/explorer/critical-flows.ts +504 -0
- package/src/reality-mode/explorer/flow-parser.ts +293 -0
- package/src/reality-mode/explorer/index.ts +22 -0
- package/src/reality-mode/explorer/runtime-explorer.ts +715 -0
- package/src/reality-mode/explorer/surface-discovery.ts +498 -0
- package/src/reality-mode/explorer/templates/example-flows/auth-flow.yaml +41 -0
- package/src/reality-mode/explorer/templates/example-flows/checkout-flow.yaml +66 -0
- package/src/reality-mode/explorer/templates/example-flows/contact-form.yaml +43 -0
- package/src/reality-mode/explorer/templates/github-action.yml +132 -0
- package/src/reality-mode/explorer/types.ts +356 -0
- package/src/reality-mode/fake-success-detector.ts +89 -0
- package/src/reality-mode/index.ts +19 -0
- package/src/reality-mode/reality-scanner.d.ts +123 -0
- package/src/reality-mode/reality-scanner.d.ts.map +1 -0
- package/src/reality-mode/reality-scanner.js +526 -0
- package/src/reality-mode/reality-scanner.ts +576 -0
- package/src/reality-mode/report-generator.ts +253 -0
- package/src/reality-mode/traffic-classifier.ts +169 -0
- package/src/reality-mode/types.ts +95 -0
- package/src/ship-badge/__tests__/ship-badge-generator.test.ts +162 -0
- package/src/ship-badge/index.ts +16 -0
- package/src/ship-badge/ship-badge-generator.d.ts +136 -0
- package/src/ship-badge/ship-badge-generator.d.ts.map +1 -0
- package/src/ship-badge/ship-badge-generator.js +779 -0
- package/src/ship-badge/ship-badge-generator.ts +873 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Surface Discovery
|
|
3
|
+
*
|
|
4
|
+
* Discovers all testable elements in the app:
|
|
5
|
+
* - Routes (from links, router config, redirects)
|
|
6
|
+
* - Interactive elements (buttons, links, tabs, accordions)
|
|
7
|
+
* - Forms (with their fields and validation)
|
|
8
|
+
* - API endpoints (from network interception)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Page } from "@playwright/test";
|
|
12
|
+
|
|
13
|
+
// Type for element info extracted from browser context
|
|
14
|
+
interface ElementInfo {
|
|
15
|
+
tag: string;
|
|
16
|
+
text: string;
|
|
17
|
+
id: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
ariaLabel: string;
|
|
21
|
+
dataTestId?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
idx: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface LinkInfo {
|
|
27
|
+
href: string;
|
|
28
|
+
text?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FormInfo {
|
|
32
|
+
id: string;
|
|
33
|
+
action: string;
|
|
34
|
+
method: string;
|
|
35
|
+
fields: Array<{
|
|
36
|
+
name: string;
|
|
37
|
+
type: string;
|
|
38
|
+
required: boolean;
|
|
39
|
+
placeholder: string;
|
|
40
|
+
pattern: string;
|
|
41
|
+
selector: string;
|
|
42
|
+
}>;
|
|
43
|
+
submitSelector?: string;
|
|
44
|
+
idx: number;
|
|
45
|
+
}
|
|
46
|
+
import type {
|
|
47
|
+
AppSurface,
|
|
48
|
+
DiscoveredRoute,
|
|
49
|
+
DiscoveredElement,
|
|
50
|
+
DiscoveredForm,
|
|
51
|
+
DiscoveredAPI,
|
|
52
|
+
FormField,
|
|
53
|
+
} from "./types";
|
|
54
|
+
|
|
55
|
+
// Patterns that indicate destructive actions
|
|
56
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
57
|
+
/delete/i,
|
|
58
|
+
/remove/i,
|
|
59
|
+
/destroy/i,
|
|
60
|
+
/cancel.*subscription/i,
|
|
61
|
+
/deactivate/i,
|
|
62
|
+
/terminate/i,
|
|
63
|
+
/close.*account/i,
|
|
64
|
+
/reset.*all/i,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Patterns that indicate auth-required routes
|
|
68
|
+
const AUTH_ROUTE_PATTERNS = [
|
|
69
|
+
/\/admin/i,
|
|
70
|
+
/\/dashboard/i,
|
|
71
|
+
/\/settings/i,
|
|
72
|
+
/\/profile/i,
|
|
73
|
+
/\/account/i,
|
|
74
|
+
/\/billing/i,
|
|
75
|
+
/\/api\/.*private/i,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
export class SurfaceDiscovery {
|
|
79
|
+
private surface: AppSurface = {
|
|
80
|
+
routes: [],
|
|
81
|
+
elements: [],
|
|
82
|
+
forms: [],
|
|
83
|
+
apis: [],
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
private visitedUrls = new Set<string>();
|
|
88
|
+
private discoveredSelectors = new Set<string>();
|
|
89
|
+
private baseUrl: string;
|
|
90
|
+
|
|
91
|
+
constructor(baseUrl: string) {
|
|
92
|
+
this.baseUrl = baseUrl;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Main discovery entry point - crawls the page and discovers everything
|
|
97
|
+
*/
|
|
98
|
+
async discoverPage(page: Page): Promise<AppSurface> {
|
|
99
|
+
const currentUrl = page.url();
|
|
100
|
+
|
|
101
|
+
// Discover routes from links
|
|
102
|
+
await this.discoverRoutes(page);
|
|
103
|
+
|
|
104
|
+
// Discover interactive elements
|
|
105
|
+
await this.discoverElements(page, currentUrl);
|
|
106
|
+
|
|
107
|
+
// Discover forms
|
|
108
|
+
await this.discoverForms(page, currentUrl);
|
|
109
|
+
|
|
110
|
+
// API discovery happens via network interception (setup separately)
|
|
111
|
+
|
|
112
|
+
this.surface.timestamp = new Date().toISOString();
|
|
113
|
+
return this.surface;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Discover all navigable routes from the current page
|
|
118
|
+
*/
|
|
119
|
+
private async discoverRoutes(page: Page): Promise<void> {
|
|
120
|
+
// Get all links
|
|
121
|
+
const links: LinkInfo[] = await page.$$eval("a[href]", (anchors) =>
|
|
122
|
+
anchors.map((a) => ({
|
|
123
|
+
href: a.getAttribute("href") || "",
|
|
124
|
+
text: a.textContent?.trim() || "",
|
|
125
|
+
})),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
for (const link of links) {
|
|
129
|
+
const href = link.href;
|
|
130
|
+
|
|
131
|
+
// Skip external links, anchors, javascript
|
|
132
|
+
if (
|
|
133
|
+
!href ||
|
|
134
|
+
href.startsWith("#") ||
|
|
135
|
+
href.startsWith("javascript:") ||
|
|
136
|
+
href.startsWith("mailto:") ||
|
|
137
|
+
href.startsWith("tel:")
|
|
138
|
+
) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Normalize URL
|
|
143
|
+
let fullUrl: string;
|
|
144
|
+
try {
|
|
145
|
+
fullUrl = new URL(href, this.baseUrl).pathname;
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if already discovered
|
|
151
|
+
if (this.visitedUrls.has(fullUrl)) continue;
|
|
152
|
+
this.visitedUrls.add(fullUrl);
|
|
153
|
+
|
|
154
|
+
const route: DiscoveredRoute = {
|
|
155
|
+
path: fullUrl,
|
|
156
|
+
method: "GET",
|
|
157
|
+
source: "link",
|
|
158
|
+
requiresAuth: AUTH_ROUTE_PATTERNS.some((p) => p.test(fullUrl)),
|
|
159
|
+
visited: false,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
this.surface.routes.push(route);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Also check for Next.js/React Router links
|
|
166
|
+
const routerLinks: LinkInfo[] = await page.$$eval(
|
|
167
|
+
'[data-href], [href^="/"]',
|
|
168
|
+
(elements) =>
|
|
169
|
+
elements.map((el) => ({
|
|
170
|
+
href: el.getAttribute("data-href") || el.getAttribute("href") || "",
|
|
171
|
+
})),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
for (const link of routerLinks) {
|
|
175
|
+
const path = link.href;
|
|
176
|
+
if (!path || this.visitedUrls.has(path)) continue;
|
|
177
|
+
|
|
178
|
+
this.visitedUrls.add(path);
|
|
179
|
+
this.surface.routes.push({
|
|
180
|
+
path,
|
|
181
|
+
method: "GET",
|
|
182
|
+
source: "router",
|
|
183
|
+
requiresAuth: AUTH_ROUTE_PATTERNS.some((p) => p.test(path)),
|
|
184
|
+
visited: false,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Discover all interactive elements on the page
|
|
191
|
+
*/
|
|
192
|
+
private async discoverElements(
|
|
193
|
+
page: Page,
|
|
194
|
+
currentPage: string,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
// Buttons
|
|
197
|
+
const buttons: ElementInfo[] = await page.$$eval(
|
|
198
|
+
'button, [role="button"], input[type="submit"], input[type="button"]',
|
|
199
|
+
(elements) =>
|
|
200
|
+
elements.map((el, idx) => ({
|
|
201
|
+
tag: el.tagName.toLowerCase(),
|
|
202
|
+
text: el.textContent?.trim() || el.getAttribute("value") || "",
|
|
203
|
+
id: el.id || "",
|
|
204
|
+
className: (el.className as string) || "",
|
|
205
|
+
type: el.getAttribute("type") || "",
|
|
206
|
+
ariaLabel: el.getAttribute("aria-label") || "",
|
|
207
|
+
dataTestId: el.getAttribute("data-testid") || "",
|
|
208
|
+
disabled: (el as HTMLButtonElement).disabled,
|
|
209
|
+
idx,
|
|
210
|
+
})),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
for (const btn of buttons) {
|
|
214
|
+
if (btn.disabled) continue;
|
|
215
|
+
|
|
216
|
+
const selector = this.buildSelector(btn);
|
|
217
|
+
if (this.discoveredSelectors.has(selector)) continue;
|
|
218
|
+
this.discoveredSelectors.add(selector);
|
|
219
|
+
|
|
220
|
+
const text =
|
|
221
|
+
btn.text || btn.ariaLabel || btn.dataTestId || `Button ${btn.idx}`;
|
|
222
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(text));
|
|
223
|
+
|
|
224
|
+
this.surface.elements.push({
|
|
225
|
+
id: `btn-${this.surface.elements.length}`,
|
|
226
|
+
selector,
|
|
227
|
+
type: "button",
|
|
228
|
+
text,
|
|
229
|
+
page: currentPage,
|
|
230
|
+
isDestructive,
|
|
231
|
+
tested: false,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Modal triggers
|
|
236
|
+
const modalTriggers: ElementInfo[] = await page.$$eval(
|
|
237
|
+
'[data-toggle="modal"], [aria-haspopup="dialog"], [aria-controls*="modal"]',
|
|
238
|
+
(elements) =>
|
|
239
|
+
elements.map((el, idx) => ({
|
|
240
|
+
tag: el.tagName.toLowerCase(),
|
|
241
|
+
text: el.textContent?.trim() || "",
|
|
242
|
+
id: el.id || "",
|
|
243
|
+
ariaLabel: el.getAttribute("aria-label") || "",
|
|
244
|
+
dataTestId: el.getAttribute("data-testid") || "",
|
|
245
|
+
idx,
|
|
246
|
+
})),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
for (const trigger of modalTriggers) {
|
|
250
|
+
const selector = this.buildSelector(trigger);
|
|
251
|
+
if (this.discoveredSelectors.has(selector)) continue;
|
|
252
|
+
this.discoveredSelectors.add(selector);
|
|
253
|
+
|
|
254
|
+
this.surface.elements.push({
|
|
255
|
+
id: `modal-${this.surface.elements.length}`,
|
|
256
|
+
selector,
|
|
257
|
+
type: "modal-trigger",
|
|
258
|
+
text: trigger.text || trigger.ariaLabel || "Modal trigger",
|
|
259
|
+
page: currentPage,
|
|
260
|
+
isDestructive: false,
|
|
261
|
+
tested: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Dropdowns
|
|
266
|
+
const dropdowns: ElementInfo[] = await page.$$eval(
|
|
267
|
+
'[role="combobox"], [aria-haspopup="listbox"], select, [data-dropdown]',
|
|
268
|
+
(elements) =>
|
|
269
|
+
elements.map((el, idx) => ({
|
|
270
|
+
tag: el.tagName.toLowerCase(),
|
|
271
|
+
text: el.textContent?.trim().slice(0, 50) || "",
|
|
272
|
+
id: el.id || "",
|
|
273
|
+
ariaLabel: el.getAttribute("aria-label") || "",
|
|
274
|
+
idx,
|
|
275
|
+
})),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
for (const dropdown of dropdowns) {
|
|
279
|
+
const selector = this.buildSelector(dropdown);
|
|
280
|
+
if (this.discoveredSelectors.has(selector)) continue;
|
|
281
|
+
this.discoveredSelectors.add(selector);
|
|
282
|
+
|
|
283
|
+
this.surface.elements.push({
|
|
284
|
+
id: `dropdown-${this.surface.elements.length}`,
|
|
285
|
+
selector,
|
|
286
|
+
type: "dropdown",
|
|
287
|
+
text: dropdown.ariaLabel || dropdown.text || "Dropdown",
|
|
288
|
+
page: currentPage,
|
|
289
|
+
isDestructive: false,
|
|
290
|
+
tested: false,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Tabs
|
|
295
|
+
const tabs: ElementInfo[] = await page.$$eval(
|
|
296
|
+
'[role="tab"], [data-tab], .tab',
|
|
297
|
+
(elements) =>
|
|
298
|
+
elements.map((el, idx) => ({
|
|
299
|
+
tag: el.tagName.toLowerCase(),
|
|
300
|
+
text: el.textContent?.trim() || "",
|
|
301
|
+
id: el.id || "",
|
|
302
|
+
ariaLabel: el.getAttribute("aria-label") || "",
|
|
303
|
+
idx,
|
|
304
|
+
})),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
for (const tab of tabs) {
|
|
308
|
+
const selector = this.buildSelector(tab);
|
|
309
|
+
if (this.discoveredSelectors.has(selector)) continue;
|
|
310
|
+
this.discoveredSelectors.add(selector);
|
|
311
|
+
|
|
312
|
+
this.surface.elements.push({
|
|
313
|
+
id: `tab-${this.surface.elements.length}`,
|
|
314
|
+
selector,
|
|
315
|
+
type: "tab",
|
|
316
|
+
text: tab.text || tab.ariaLabel || "Tab",
|
|
317
|
+
page: currentPage,
|
|
318
|
+
isDestructive: false,
|
|
319
|
+
tested: false,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Accordions
|
|
324
|
+
const accordions: ElementInfo[] = await page.$$eval(
|
|
325
|
+
"[data-accordion], [aria-expanded], details > summary",
|
|
326
|
+
(elements) =>
|
|
327
|
+
elements.map((el, idx) => ({
|
|
328
|
+
tag: el.tagName.toLowerCase(),
|
|
329
|
+
text: el.textContent?.trim().slice(0, 50) || "",
|
|
330
|
+
id: el.id || "",
|
|
331
|
+
ariaLabel: el.getAttribute("aria-label") || "",
|
|
332
|
+
idx,
|
|
333
|
+
})),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
for (const accordion of accordions) {
|
|
337
|
+
const selector = this.buildSelector(accordion);
|
|
338
|
+
if (this.discoveredSelectors.has(selector)) continue;
|
|
339
|
+
this.discoveredSelectors.add(selector);
|
|
340
|
+
|
|
341
|
+
this.surface.elements.push({
|
|
342
|
+
id: `accordion-${this.surface.elements.length}`,
|
|
343
|
+
selector,
|
|
344
|
+
type: "accordion",
|
|
345
|
+
text: accordion.text || "Accordion",
|
|
346
|
+
page: currentPage,
|
|
347
|
+
isDestructive: false,
|
|
348
|
+
tested: false,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Discover all forms on the page
|
|
355
|
+
*/
|
|
356
|
+
private async discoverForms(page: Page, currentPage: string): Promise<void> {
|
|
357
|
+
const forms: FormInfo[] = await page.$$eval("form", (formElements) =>
|
|
358
|
+
formElements.map((form, idx) => {
|
|
359
|
+
const fields: Array<{
|
|
360
|
+
name: string;
|
|
361
|
+
type: string;
|
|
362
|
+
required: boolean;
|
|
363
|
+
placeholder: string;
|
|
364
|
+
pattern: string;
|
|
365
|
+
selector: string;
|
|
366
|
+
}> = [];
|
|
367
|
+
|
|
368
|
+
// Get all input fields
|
|
369
|
+
form
|
|
370
|
+
.querySelectorAll("input, textarea, select")
|
|
371
|
+
.forEach((field, fieldIdx) => {
|
|
372
|
+
const input = field as HTMLInputElement;
|
|
373
|
+
fields.push({
|
|
374
|
+
name: input.name || input.id || `field-${fieldIdx}`,
|
|
375
|
+
type: input.type || field.tagName.toLowerCase(),
|
|
376
|
+
required: input.required,
|
|
377
|
+
placeholder: input.placeholder || "",
|
|
378
|
+
pattern: input.pattern || "",
|
|
379
|
+
selector: input.id ? `#${input.id}` : `[name="${input.name}"]`,
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Find submit button
|
|
384
|
+
const submitBtn = form.querySelector(
|
|
385
|
+
'button[type="submit"], input[type="submit"]',
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
id: form.id || `form-${idx}`,
|
|
390
|
+
action: form.action || "",
|
|
391
|
+
method: form.method?.toUpperCase() || "POST",
|
|
392
|
+
fields,
|
|
393
|
+
submitSelector: submitBtn
|
|
394
|
+
? submitBtn.id
|
|
395
|
+
? `#${submitBtn.id}`
|
|
396
|
+
: 'button[type="submit"]'
|
|
397
|
+
: undefined,
|
|
398
|
+
idx,
|
|
399
|
+
};
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
for (const formData of forms) {
|
|
404
|
+
const selector = formData.id
|
|
405
|
+
? `#${formData.id}`
|
|
406
|
+
: `form:nth-of-type(${formData.idx + 1})`;
|
|
407
|
+
|
|
408
|
+
const form: DiscoveredForm = {
|
|
409
|
+
id: `form-${this.surface.forms.length}`,
|
|
410
|
+
selector,
|
|
411
|
+
page: currentPage,
|
|
412
|
+
action: formData.action,
|
|
413
|
+
method: formData.method,
|
|
414
|
+
fields: formData.fields.map(
|
|
415
|
+
(f): FormField => ({
|
|
416
|
+
name: f.name,
|
|
417
|
+
type: f.type,
|
|
418
|
+
required: f.required,
|
|
419
|
+
selector: f.selector,
|
|
420
|
+
placeholder: f.placeholder,
|
|
421
|
+
pattern: f.pattern,
|
|
422
|
+
}),
|
|
423
|
+
),
|
|
424
|
+
submitButton: formData.submitSelector,
|
|
425
|
+
tested: false,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
this.surface.forms.push(form);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Add an API call discovered via network interception
|
|
434
|
+
*/
|
|
435
|
+
addDiscoveredAPI(api: DiscoveredAPI): void {
|
|
436
|
+
// Avoid duplicates
|
|
437
|
+
const exists = this.surface.apis.some(
|
|
438
|
+
(a) => a.url === api.url && a.method === api.method,
|
|
439
|
+
);
|
|
440
|
+
if (!exists) {
|
|
441
|
+
this.surface.apis.push(api);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Add a route discovered via redirect or API call
|
|
447
|
+
*/
|
|
448
|
+
addDiscoveredRoute(route: DiscoveredRoute): void {
|
|
449
|
+
if (!this.visitedUrls.has(route.path)) {
|
|
450
|
+
this.visitedUrls.add(route.path);
|
|
451
|
+
this.surface.routes.push(route);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Build a stable selector for an element
|
|
457
|
+
*/
|
|
458
|
+
private buildSelector(el: {
|
|
459
|
+
id?: string;
|
|
460
|
+
dataTestId?: string;
|
|
461
|
+
ariaLabel?: string;
|
|
462
|
+
className?: string;
|
|
463
|
+
tag?: string;
|
|
464
|
+
idx?: number;
|
|
465
|
+
}): string {
|
|
466
|
+
// Prefer stable selectors
|
|
467
|
+
if (el.dataTestId) return `[data-testid="${el.dataTestId}"]`;
|
|
468
|
+
if (el.id) return `#${el.id}`;
|
|
469
|
+
if (el.ariaLabel) return `[aria-label="${el.ariaLabel}"]`;
|
|
470
|
+
|
|
471
|
+
// Fallback to nth-of-type
|
|
472
|
+
return `${el.tag || "button"}:nth-of-type(${(el.idx || 0) + 1})`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Get the current surface
|
|
477
|
+
*/
|
|
478
|
+
getSurface(): AppSurface {
|
|
479
|
+
return this.surface;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Get discovery stats
|
|
484
|
+
*/
|
|
485
|
+
getStats(): {
|
|
486
|
+
routes: number;
|
|
487
|
+
elements: number;
|
|
488
|
+
forms: number;
|
|
489
|
+
apis: number;
|
|
490
|
+
} {
|
|
491
|
+
return {
|
|
492
|
+
routes: this.surface.routes.length,
|
|
493
|
+
elements: this.surface.elements.length,
|
|
494
|
+
forms: this.surface.forms.length,
|
|
495
|
+
apis: this.surface.apis.length,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Authentication Flow - Login/Signup Testing
|
|
2
|
+
# Copy to .guardrail/flows/auth-flow.yaml and customize
|
|
3
|
+
|
|
4
|
+
id: auth-login
|
|
5
|
+
name: User Login
|
|
6
|
+
description: Tests the standard email/password login flow
|
|
7
|
+
required: true
|
|
8
|
+
|
|
9
|
+
steps:
|
|
10
|
+
- action: navigate
|
|
11
|
+
target: /login
|
|
12
|
+
|
|
13
|
+
- action: wait
|
|
14
|
+
timeout: 2000
|
|
15
|
+
|
|
16
|
+
- action: fill
|
|
17
|
+
target: input[name="email"], input[type="email"], #email
|
|
18
|
+
value: "{{email}}"
|
|
19
|
+
|
|
20
|
+
- action: fill
|
|
21
|
+
target: input[name="password"], input[type="password"], #password
|
|
22
|
+
value: "{{password}}"
|
|
23
|
+
|
|
24
|
+
- action: click
|
|
25
|
+
target: button[type="submit"], button:has-text("Log in"), button:has-text("Sign in")
|
|
26
|
+
|
|
27
|
+
- action: wait
|
|
28
|
+
timeout: 5000
|
|
29
|
+
|
|
30
|
+
assertions:
|
|
31
|
+
- type: url-contains
|
|
32
|
+
value: /dashboard|/home|/app
|
|
33
|
+
critical: true
|
|
34
|
+
|
|
35
|
+
- type: no-errors
|
|
36
|
+
value: ""
|
|
37
|
+
critical: true
|
|
38
|
+
|
|
39
|
+
- type: element-hidden
|
|
40
|
+
value: input[type="password"]
|
|
41
|
+
critical: false
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# E-commerce Checkout Flow
|
|
2
|
+
# Copy to .guardrail/flows/checkout-flow.yaml and customize
|
|
3
|
+
|
|
4
|
+
id: ecommerce-checkout
|
|
5
|
+
name: Checkout Flow
|
|
6
|
+
description: Tests the complete checkout process
|
|
7
|
+
required: false
|
|
8
|
+
|
|
9
|
+
steps:
|
|
10
|
+
- action: navigate
|
|
11
|
+
target: /products
|
|
12
|
+
|
|
13
|
+
- action: wait
|
|
14
|
+
timeout: 2000
|
|
15
|
+
|
|
16
|
+
- action: click
|
|
17
|
+
target: button:has-text("Add to Cart"), [data-testid="add-to-cart"], .add-to-cart
|
|
18
|
+
|
|
19
|
+
- action: wait
|
|
20
|
+
timeout: 1000
|
|
21
|
+
|
|
22
|
+
- action: navigate
|
|
23
|
+
target: /cart
|
|
24
|
+
|
|
25
|
+
- action: wait
|
|
26
|
+
timeout: 2000
|
|
27
|
+
|
|
28
|
+
- action: click
|
|
29
|
+
target: button:has-text("Checkout"), a:has-text("Checkout"), [data-testid="checkout"]
|
|
30
|
+
|
|
31
|
+
- action: wait
|
|
32
|
+
timeout: 3000
|
|
33
|
+
|
|
34
|
+
# Fill shipping info
|
|
35
|
+
- action: fill
|
|
36
|
+
target: input[name="email"], #email
|
|
37
|
+
value: "{{email}}"
|
|
38
|
+
|
|
39
|
+
- action: fill
|
|
40
|
+
target: input[name="name"], input[name="fullName"], #name
|
|
41
|
+
value: "{{name}}"
|
|
42
|
+
|
|
43
|
+
- action: fill
|
|
44
|
+
target: input[name="address"], #address
|
|
45
|
+
value: "123 Test Street"
|
|
46
|
+
|
|
47
|
+
- action: fill
|
|
48
|
+
target: input[name="city"], #city
|
|
49
|
+
value: "Test City"
|
|
50
|
+
|
|
51
|
+
- action: fill
|
|
52
|
+
target: input[name="zip"], input[name="postalCode"], #zip
|
|
53
|
+
value: "12345"
|
|
54
|
+
|
|
55
|
+
assertions:
|
|
56
|
+
- type: url-contains
|
|
57
|
+
value: /checkout|/payment
|
|
58
|
+
critical: true
|
|
59
|
+
|
|
60
|
+
- type: no-errors
|
|
61
|
+
value: ""
|
|
62
|
+
critical: true
|
|
63
|
+
|
|
64
|
+
- type: element-visible
|
|
65
|
+
value: button:has-text("Pay"), button:has-text("Place Order")
|
|
66
|
+
critical: false
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Contact Form Flow
|
|
2
|
+
# Copy to .guardrail/flows/contact-form.yaml and customize
|
|
3
|
+
|
|
4
|
+
id: contact-form
|
|
5
|
+
name: Contact Form Submission
|
|
6
|
+
description: Tests the contact form submission process
|
|
7
|
+
required: false
|
|
8
|
+
|
|
9
|
+
steps:
|
|
10
|
+
- action: navigate
|
|
11
|
+
target: /contact
|
|
12
|
+
|
|
13
|
+
- action: wait
|
|
14
|
+
timeout: 2000
|
|
15
|
+
|
|
16
|
+
- action: fill
|
|
17
|
+
target: input[name="name"], #name
|
|
18
|
+
value: "{{name}}"
|
|
19
|
+
|
|
20
|
+
- action: fill
|
|
21
|
+
target: input[name="email"], #email
|
|
22
|
+
value: "{{email}}"
|
|
23
|
+
|
|
24
|
+
- action: fill
|
|
25
|
+
target: input[name="subject"], #subject
|
|
26
|
+
value: "Test inquiry from Reality Mode"
|
|
27
|
+
|
|
28
|
+
- action: fill
|
|
29
|
+
target: textarea[name="message"], #message, textarea
|
|
30
|
+
value: "This is an automated test message from Guardrail Reality Mode. Please disregard."
|
|
31
|
+
|
|
32
|
+
# Don't actually submit in test mode to avoid spam
|
|
33
|
+
# - action: click
|
|
34
|
+
# target: button[type="submit"]
|
|
35
|
+
|
|
36
|
+
assertions:
|
|
37
|
+
- type: no-errors
|
|
38
|
+
value: ""
|
|
39
|
+
critical: true
|
|
40
|
+
|
|
41
|
+
- type: element-visible
|
|
42
|
+
value: button[type="submit"], input[type="submit"]
|
|
43
|
+
critical: true
|