@rettangoli/vt 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -126,6 +126,7 @@ Step action reference:
126
126
 
127
127
  - `docs/step-actions.md`
128
128
  - canonical format is structured action objects (`- action: ...`); legacy one-line string steps are not supported.
129
+ - `action: select` accepts exactly one of `testId` or `selector` for interaction targeting.
129
130
  - `assert` supports `js` deep-equal checks for object/array values.
130
131
 
131
132
  Screenshot naming:
@@ -141,15 +142,15 @@ Screenshot naming:
141
142
  A pre-built Docker image with `rtgl` and Playwright browsers is available:
142
143
 
143
144
  ```bash
144
- docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27
145
+ docker pull han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.10
145
146
  ```
146
147
 
147
148
  Run commands against a local project:
148
149
 
149
150
  ```bash
150
- docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27 rtgl vt screenshot
151
- docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27 rtgl vt report
152
- docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.0-rc27 rtgl vt accept
151
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.10 rtgl vt screenshot
152
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.10 rtgl vt report
153
+ docker run --rm -v "$(pwd):/workspace" han4wluc/rtgl:playwright-v1.57.0-rtgl-v1.0.10 rtgl vt accept
153
154
  ```
154
155
 
155
156
  Note:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/vt",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Rettangoli Visual Testing",
5
5
  "type": "module",
6
6
  "repository": {
@@ -60,8 +60,8 @@
60
60
  {% for file in files %}
61
61
  <rtgl-view w="f">
62
62
  <a style="display: contents; text-decoration: none; color: inherit;"
63
- href="#{{ file.frontMatter.title | slug }}">
64
- <rtgl-text id="{{ file.frontMatter.title | slug }}" s="h3">{{ file.frontMatter.title | default: file.path
63
+ href="#{{ file.anchorId }}">
64
+ <rtgl-text id="{{ file.anchorId }}" s="h3">{{ file.frontMatter.title | default: file.path
65
65
  }}</rtgl-text>
66
66
  </a>
67
67
 
package/src/common.js CHANGED
@@ -15,7 +15,7 @@ import path from "path";
15
15
  import { validateFiniteNumber, validateFrontMatter } from "./validation.js";
16
16
  import { createCaptureTasks } from "./capture/spec-loader.js";
17
17
  import { runCaptureScheduler } from "./capture/capture-scheduler.js";
18
- import { deriveSectionPageKey } from "./section-page-key.js";
18
+ import { deriveAnchorId, deriveSectionPageKey } from "./section-page-key.js";
19
19
 
20
20
  const removeExtension = (filePath) => filePath.replace(/\.[^/.]+$/, "");
21
21
 
@@ -44,10 +44,9 @@ async function readYaml(filePath) {
44
44
  }
45
45
  }
46
46
 
47
- // Add custom filter to convert string to lowercase and replace spaces with hyphens
48
- engine.registerFilter("slug", (value) => {
49
- if (typeof value !== "string") return "";
50
- return value.toLowerCase().replace(/\s+/g, "-");
47
+ // Add custom filter to derive slug-safe ids for URLs and anchors.
48
+ engine.registerFilter("slug", (value, fallbackValue) => {
49
+ return deriveAnchorId(value, fallbackValue);
51
50
  });
52
51
 
53
52
  // Add custom filter to remove file extension
@@ -416,13 +415,18 @@ function generateOverview(data, templatePath, outputPath, configData) {
416
415
  try {
417
416
  renderedContent = engine.parseAndRenderSync(templateContent, {
418
417
  ...configData,
419
- files: data.filter((file) => {
420
- const filePath = path.normalize(file.path);
421
- const sectionPath = path.normalize(section.files);
422
- // Check if file is in the folder or any subfolder
423
- const fileDir = path.dirname(filePath);
424
- return fileDir === sectionPath || fileDir.startsWith(sectionPath + path.sep);
425
- }),
418
+ files: data
419
+ .filter((file) => {
420
+ const filePath = path.normalize(file.path);
421
+ const sectionPath = path.normalize(section.files);
422
+ // Check if file is in the folder or any subfolder
423
+ const fileDir = path.dirname(filePath);
424
+ return fileDir === sectionPath || fileDir.startsWith(sectionPath + path.sep);
425
+ })
426
+ .map((file) => ({
427
+ ...file,
428
+ anchorId: deriveAnchorId(file.frontMatter?.title, removeExtension(file.path)),
429
+ })),
426
430
  currentSection: section,
427
431
  sidebarItems: encodeURIComponent(JSON.stringify(sidebarItems)),
428
432
  });
@@ -180,6 +180,27 @@ function requireStructuredString(stepObject, key, actionName) {
180
180
  return value;
181
181
  }
182
182
 
183
+ function resolveStructuredSelectTarget(stepObject, actionName) {
184
+ const hasTestId = Object.prototype.hasOwnProperty.call(stepObject, "testId");
185
+ const hasSelector = Object.prototype.hasOwnProperty.call(stepObject, "selector");
186
+
187
+ if (hasTestId === hasSelector) {
188
+ throw new Error(`Structured action "${actionName}" requires exactly one of \`testId\` or \`selector\`.`);
189
+ }
190
+
191
+ if (hasTestId) {
192
+ return {
193
+ type: "testId",
194
+ value: requireStructuredString(stepObject, "testId", actionName),
195
+ };
196
+ }
197
+
198
+ return {
199
+ type: "selector",
200
+ value: requireStructuredString(stepObject, "selector", actionName),
201
+ };
202
+ }
203
+
183
204
  function requireStructuredNumber(stepObject, key, actionName) {
184
205
  const value = stepObject[key];
185
206
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -224,13 +245,13 @@ function normalizeStructuredActionStep(stepObject) {
224
245
  }
225
246
 
226
247
  if (action === "select") {
227
- assertStructuredKeys(stepObject, new Set(["action", "testId", "steps"]), action);
228
- const testId = requireStructuredString(stepObject, "testId", action);
248
+ assertStructuredKeys(stepObject, new Set(["action", "testId", "selector", "steps"]), action);
249
+ const target = resolveStructuredSelectTarget(stepObject, action);
229
250
  if (!Array.isArray(stepObject.steps)) {
230
251
  throw new Error('Structured action "select" requires array `steps`.');
231
252
  }
232
253
  const nestedSteps = stepObject.steps.map((nestedStep) => normalizeStepValue(nestedStep));
233
- return { kind: "block", command: "select", args: [testId], nestedSteps };
254
+ return { kind: "block", command: "select", args: [`${target.type}=${target.value}`], nestedSteps };
234
255
  }
235
256
 
236
257
  if (action === "click" || action === "dblclick" || action === "hover" || action === "rclick") {
@@ -828,22 +849,32 @@ async function assertStructured(page, assertionConfig, selectedElement) {
828
849
  }
829
850
 
830
851
  async function select(page, args) {
831
- const testId = args[0];
832
- if (!testId) {
833
- throw new Error("`select` requires a test id.");
834
- }
835
- const hostElementLocator = page.getByTestId(testId);
836
-
837
- const interactiveElementLocator = hostElementLocator.locator(
838
- 'input, textarea, button, select, a'
839
- ).first();
840
-
852
+ const { named, positional } = parseNamedArgs(args);
853
+ const testId = typeof named.testId === "string" && named.testId.length > 0
854
+ ? named.testId
855
+ : positional[0];
856
+ const selector = typeof named.selector === "string" && named.selector.length > 0
857
+ ? named.selector
858
+ : undefined;
859
+
860
+ if ((testId ? 1 : 0) + (selector ? 1 : 0) !== 1) {
861
+ throw new Error("`select` requires exactly one target: `testId` or `selector`.");
862
+ }
863
+
864
+ const hostElementLocator = selector
865
+ ? page.locator(selector)
866
+ : page.getByTestId(testId);
867
+
868
+ const interactiveElementLocator = hostElementLocator
869
+ .locator('input, textarea, button, select, a')
870
+ .first();
871
+
841
872
  const count = await interactiveElementLocator.count();
842
-
873
+
843
874
  if (count > 0) {
844
875
  return interactiveElementLocator;
845
876
  }
846
-
877
+
847
878
  return hostElementLocator;
848
879
  }
849
880
 
@@ -1,11 +1,11 @@
1
1
  import fs from "fs";
2
2
  import { Liquid } from "liquidjs";
3
+ import { deriveAnchorId } from "../section-page-key.js";
3
4
 
4
5
  const engine = new Liquid();
5
6
 
6
- engine.registerFilter("slug", (value) => {
7
- if (typeof value !== "string") return "";
8
- return value.toLowerCase().replace(/\s+/g, "-");
7
+ engine.registerFilter("slug", (value, fallbackValue) => {
8
+ return deriveAnchorId(value, fallbackValue);
9
9
  });
10
10
 
11
11
  export async function renderHtmlReport({ results, templatePath, outputPath }) {
@@ -5,10 +5,18 @@ function normalizeString(value) {
5
5
  return value.trim();
6
6
  }
7
7
 
8
- export function deriveSectionPageKey(sectionLike) {
9
- return normalizeString(sectionLike?.title)
8
+ export function derivePageKey(value) {
9
+ return normalizeString(value)
10
10
  .toLowerCase()
11
11
  .replace(/[^a-z0-9]+/g, "-")
12
12
  .replace(/-+/g, "-")
13
13
  .replace(/^-+|-+$/g, "");
14
14
  }
15
+
16
+ export function deriveSectionPageKey(sectionLike) {
17
+ return derivePageKey(sectionLike?.title) || derivePageKey(sectionLike?.files);
18
+ }
19
+
20
+ export function deriveAnchorId(value, fallbackValue = "") {
21
+ return derivePageKey(value) || derivePageKey(fallbackValue);
22
+ }
package/src/validation.js CHANGED
@@ -136,7 +136,7 @@ function assertDerivableSectionPageKey(sectionLike, path) {
136
136
  const pageKey = deriveSectionPageKey(sectionLike);
137
137
  assert(
138
138
  pageKey.length > 0,
139
- `"${path}" must contain at least one letter or number.`,
139
+ `"${path}" must produce a page key from title or files.`,
140
140
  );
141
141
  }
142
142
 
@@ -169,14 +169,14 @@ function validateSection(section, index) {
169
169
 
170
170
  assert(typeof item.title === "string" && item.title.trim().length > 0, `"${itemPath}.title" is required.`);
171
171
  assert(typeof item.files === "string" && item.files.trim().length > 0, `"${itemPath}.files" is required.`);
172
- assertDerivableSectionPageKey(item, `${itemPath}.title`);
172
+ assertDerivableSectionPageKey(item, itemPath);
173
173
  });
174
174
  return;
175
175
  }
176
176
 
177
177
  validateOptionalString(section.files, `${sectionPath}.files`);
178
178
  assert(typeof section.files === "string" && section.files.trim().length > 0, `"${sectionPath}.files" is required.`);
179
- assertDerivableSectionPageKey(section, `${sectionPath}.title`);
179
+ assertDerivableSectionPageKey(section, sectionPath);
180
180
  }
181
181
 
182
182
  function collectSectionPageKeys(vtConfig) {
@@ -273,11 +273,18 @@ function validateStructuredActionStep(step, stepPath) {
273
273
  }
274
274
 
275
275
  if (action === "select") {
276
- assertNoUnknownStepKeys(step, stepPath, new Set(["action", "testId", "steps"]));
276
+ assertNoUnknownStepKeys(step, stepPath, new Set(["action", "testId", "selector", "steps"]));
277
277
  validateOptionalString(step.testId, `${stepPath}.testId`);
278
+ validateOptionalString(step.selector, `${stepPath}.selector`);
278
279
  assert(
279
- typeof step.testId === "string" && step.testId.trim().length > 0,
280
- `"${stepPath}.testId" is required for action=select.`,
280
+ (
281
+ typeof step.testId === "string"
282
+ && step.testId.trim().length > 0
283
+ ) !== (
284
+ typeof step.selector === "string"
285
+ && step.selector.trim().length > 0
286
+ ),
287
+ `"${stepPath}" for action=select requires exactly one of "testId" or "selector".`,
281
288
  );
282
289
  assert(Array.isArray(step.steps), `"${stepPath}.steps" must be an array for action=select.`);
283
290
  step.steps.forEach((nestedStep, nestedIndex) => {