@skyramp/mcp 0.4.3 → 0.4.4-rc.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.
Files changed (69) hide show
  1. package/build/playwright/traceRecordingPrompt.js +2 -1
  2. package/build/prompts/orderAssertionRule.d.ts +9 -0
  3. package/build/prompts/orderAssertionRule.js +9 -0
  4. package/build/prompts/test-maintenance/driftAnalysisShared.js +2 -0
  5. package/build/prompts/testbot/testbot-prompts.js +10 -7
  6. package/build/recommendation/answers.d.ts +54 -19
  7. package/build/recommendation/answers.js +102 -31
  8. package/build/recommendation/blockerProbes.d.ts +13 -0
  9. package/build/recommendation/blockerProbes.js +91 -0
  10. package/build/recommendation/blockers.d.ts +65 -0
  11. package/build/recommendation/blockers.js +135 -0
  12. package/build/recommendation/pullRequestText.d.ts +2 -0
  13. package/build/recommendation/pullRequestText.js +16 -0
  14. package/build/recommendation/registerPlan.d.ts +7 -4
  15. package/build/recommendation/registerPlan.js +37 -11
  16. package/build/recommendation/types.d.ts +21 -4
  17. package/build/recommendation/verifierContracts.d.ts +6 -6
  18. package/build/recommendation/verifierContracts.js +8 -7
  19. package/build/recommendation/verifiers/citedPath.d.ts +2 -2
  20. package/build/recommendation/verifiers/citedPath.js +2 -2
  21. package/build/recommendation/verifiers/coverage.js +25 -25
  22. package/build/recommendation/verifiers/deliveredMatchesPlan.js +3 -3
  23. package/build/recommendation/verifiers/issueTraceability.d.ts +1 -5
  24. package/build/recommendation/verifiers/issueTraceability.js +4 -35
  25. package/build/tools/executeSkyrampTestTool.js +8 -0
  26. package/build/tools/generate-tests/generateE2ERestTool.d.ts +1 -0
  27. package/build/tools/generate-tests/generateE2ERestTool.js +9 -1
  28. package/build/tools/generate-tests/generateUIRestTool.d.ts +2 -0
  29. package/build/tools/generate-tests/generateUIRestTool.js +9 -1
  30. package/build/tools/submitReportTool.d.ts +9 -9
  31. package/build/tools/submitReportTool.js +178 -26
  32. package/build/tools/test-management/registerTestPlanTool.d.ts +20 -14
  33. package/build/tools/test-management/registerTestPlanTool.js +30 -14
  34. package/build/types/TestTypes.d.ts +7 -0
  35. package/build/types/TestTypes.js +22 -0
  36. package/build/types/TestbotReport.d.ts +4 -1
  37. package/build/utils/assertion-verify/metrics.js +12 -0
  38. package/build/utils/findUp.d.ts +20 -0
  39. package/build/utils/findUp.js +59 -0
  40. package/build/utils/language-helper.js +1 -1
  41. package/build/utils/repoSdkVersion.d.ts +16 -0
  42. package/build/utils/repoSdkVersion.js +88 -0
  43. package/build/utils/testDependencyPolicy.d.ts +20 -0
  44. package/build/utils/testDependencyPolicy.js +64 -4
  45. package/build/utils/utils-verify/typecheck.js +3 -18
  46. package/node_modules/playwright/lib/mcp/skyramp/assertOrder.js +66 -0
  47. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +12 -3
  48. package/node_modules/playwright/lib/mcp/skyramp/common/liveReadError.js +40 -0
  49. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +21 -0
  50. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +2 -1
  51. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +96 -16
  52. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +10 -0
  53. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  54. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderRunner.js +22 -0
  55. package/node_modules/playwright/node_modules/playwright-core/lib/utils/isomorphic/elementOrder.js +57 -0
  56. package/node_modules/playwright/node_modules/playwright-core/lib/utils.js +2 -0
  57. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.uiPTFncY.js → index.0MJW0uG7.js} +1 -1
  58. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  59. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  60. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  61. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderRunner.ts +26 -1
  62. package/node_modules/playwright/node_modules/playwright-core/src/utils/isomorphic/elementOrder.ts +83 -0
  63. package/node_modules/playwright/node_modules/playwright-core/src/utils.ts +1 -0
  64. package/node_modules/playwright/package.json +1 -1
  65. package/package.json +4 -2
  66. package/plugin/prompts/plan-tests.md +9 -17
  67. package/plugin/skills/enhance-assertions/reference/shared-rules.md +1 -1
  68. package/plugin/skills/enhance-assertions/reference/ui.md +2 -2
  69. package/plugin/skills/fix-test-import-errors/SKILL.md +1 -1
@@ -291,6 +291,28 @@ export const codeRefactoringSchema = z.object({
291
291
  .default(false)
292
292
  .describe("Whether to modularize the code"),
293
293
  });
294
+ /** The `testName` field for a recording-driven generation tool.
295
+ *
296
+ * Single-sourced because the description is prompt text: it is the only thing
297
+ * steering the agent's choice of title, it gets re-tuned against eval results,
298
+ * and two copies would let the UI and E2E tools drift apart silently. Each
299
+ * tool passes the title its own generator falls back to (SKYR-4468). */
300
+ export const testNameField = (fallbackTitle) => z
301
+ .string()
302
+ // .trim() normalises incidental whitespace and, combined with .min(1),
303
+ // rejects a whitespace-only title, which would otherwise produce a
304
+ // meaningless test title and identifier (SKYR-4468).
305
+ .trim()
306
+ .min(1)
307
+ .optional()
308
+ .describe("Title for the generated test. Name the BEHAVIOUR this recording verifies, " +
309
+ "taken from the recommendation the recording came from — not the page it happens on. " +
310
+ "Keep it to one short phrase, under about 60 characters. " +
311
+ 'Example: "Add to cart from search results", not "Search page". ' +
312
+ "Skyramp emits it verbatim as the Playwright test title for TypeScript/JavaScript. " +
313
+ "Python and Java get it as a comment above the test, and a function name built from " +
314
+ "its ASCII words and cut at 80 characters. " +
315
+ `Omit it and the test stays titled \`${fallbackTitle}\`, which claims nothing.`);
294
316
  /** Test types skyramp_enhance_assertions supports — single-sourced so the
295
317
  * tool's schema enum and generation-time baseline seeding cannot drift
296
318
  * (contract additionally requires provider mode at the seeding site). */
@@ -227,7 +227,10 @@ export interface TestbotReport {
227
227
  /** Files an UPDATE or REGENERATE verdict edited, minus any already counted as
228
228
  * generated — one file owes one run however it was written. */
229
229
  updated: number;
230
- /** How many of those `generated + updated` files were passed to
230
+ /** Files a VERIFY verdict names in the primary repository, minus any already
231
+ * counted above. Never edited, but each owes one run. */
232
+ verified: number;
233
+ /** How many of those `generated + updated + verified` files were passed to
231
234
  * skyramp_execute_test. The file reached the runner; that is not proof a test
232
235
  * ran, because a runner can exit 0 having executed nothing. */
233
236
  executed: number;
@@ -197,6 +197,18 @@ function expectSites(raw, stripped) {
197
197
  matcher = link[1];
198
198
  break;
199
199
  }
200
+ // `expect(async () => { ... }).toPass()` is a retry wrapper, not an
201
+ // assertion: the assertions are the expect() calls inside it. Record none
202
+ // for the wrapper and resume scanning inside its argument, so the inner
203
+ // ones are scored for what they check (the generated order assertion
204
+ // compares boundingBox() geometry inside toPass()).
205
+ // `.not.toPass()` inverts the block (it must keep failing), so its inner
206
+ // assertions are not positive checks: it falls through to ordinary wrapper
207
+ // scoring (weight 1). Python's Playwright has no to_pass equivalent.
208
+ if (matcher === "toPass" && !links.includes("not")) {
209
+ re.lastIndex = openIdx + 1;
210
+ continue;
211
+ }
200
212
  let matcherArgs;
201
213
  if (matcher !== undefined) {
202
214
  const matcherOpen = /^\s*\(/.exec(stripped.slice(cursor));
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The first `<dir>/<candidate>` that exists, checking `from` and then each
3
+ * ancestor, or undefined.
4
+ *
5
+ * With `stopAtRepoRoot`, only the directories up to the nearest one holding
6
+ * `.git` (a directory or a file, so worktrees count) are searched, and a `from`
7
+ * outside any checkout searches nothing: a parent directory is not the
8
+ * repository's to answer for. The walk never passes the filesystem root or
9
+ * MAX_LEVELS.
10
+ */
11
+ export declare function findUp(from: string, candidates: readonly string[], { stopAtRepoRoot }?: {
12
+ stopAtRepoRoot?: boolean;
13
+ }): string | undefined;
14
+ /** The package's manifest as Node would find it from `from`: the nearest
15
+ * `node_modules/<pkg>/package.json` walking UP from there. Node's own lookup
16
+ * never reads NODE_PATH for this, and neither does this one. A manifest that
17
+ * does not parse is logged and treated as absent. */
18
+ export declare function installedManifest(pkg: string, from: string, options?: {
19
+ stopAtRepoRoot?: boolean;
20
+ }): Record<string, unknown> | undefined;
@@ -0,0 +1,59 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { logger } from "./logger.js";
4
+ /** Deeper than any real checkout; a bound so a walk can never run away. */
5
+ const MAX_LEVELS = 64;
6
+ /**
7
+ * The first `<dir>/<candidate>` that exists, checking `from` and then each
8
+ * ancestor, or undefined.
9
+ *
10
+ * With `stopAtRepoRoot`, only the directories up to the nearest one holding
11
+ * `.git` (a directory or a file, so worktrees count) are searched, and a `from`
12
+ * outside any checkout searches nothing: a parent directory is not the
13
+ * repository's to answer for. The walk never passes the filesystem root or
14
+ * MAX_LEVELS.
15
+ */
16
+ export function findUp(from, candidates, { stopAtRepoRoot = false } = {}) {
17
+ const dirs = ancestors(from);
18
+ if (stopAtRepoRoot) {
19
+ const root = dirs.findIndex((dir) => fs.existsSync(path.join(dir, ".git")));
20
+ if (root === -1)
21
+ return undefined;
22
+ dirs.length = root + 1;
23
+ }
24
+ for (const dir of dirs) {
25
+ for (const candidate of candidates) {
26
+ const found = path.join(dir, candidate);
27
+ if (fs.existsSync(found))
28
+ return found;
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ /** `from` and each ancestor, nearest first, up to the filesystem root. */
34
+ function ancestors(from) {
35
+ const dirs = [path.resolve(from)];
36
+ while (dirs.length < MAX_LEVELS) {
37
+ const parent = path.dirname(dirs[dirs.length - 1]);
38
+ if (parent === dirs[dirs.length - 1])
39
+ break;
40
+ dirs.push(parent);
41
+ }
42
+ return dirs;
43
+ }
44
+ /** The package's manifest as Node would find it from `from`: the nearest
45
+ * `node_modules/<pkg>/package.json` walking UP from there. Node's own lookup
46
+ * never reads NODE_PATH for this, and neither does this one. A manifest that
47
+ * does not parse is logged and treated as absent. */
48
+ export function installedManifest(pkg, from, options) {
49
+ const manifest = findUp(from, [path.join("node_modules", pkg, "package.json")], options);
50
+ if (!manifest)
51
+ return undefined;
52
+ try {
53
+ return JSON.parse(fs.readFileSync(manifest, "utf8"));
54
+ }
55
+ catch (error) {
56
+ logger.warning(`Could not read ${manifest}`, { error: String(error) });
57
+ return undefined;
58
+ }
59
+ }
@@ -36,7 +36,7 @@ npx playwright test <test-file>.spec.${extension} --reporter=list
36
36
  * a mock is deployed rather than run as a test — it needs no SDK. */
37
37
  const SDK_DECLARATION = {
38
38
  python: "Declare `skyramp` in the same development manifest.",
39
- javascript: `If @skyramp/skyramp is not already declared, add ${npmSdkSpec} as a devDependency. If it is declared, leave its version alone and report the mismatch if the verifier refuses it.`,
39
+ javascript: `If @skyramp/skyramp is not already declared, add ${npmSdkSpec} as a devDependency. If it is declared, keep its version unless skyramp_execute_test refuses the run for an older SDK.`,
40
40
  java: "Declare `dev.skyramp:skyramp-library` with test scope. An existing manifest resolves the version through its own dependency management; a manifest you created has none, so give a concrete version there.",
41
41
  };
42
42
  /** The SDK line for a language, or nothing when the language has none. */
@@ -0,0 +1,16 @@
1
+ import { SdkEcosystem } from "./testDependencyPolicy.js";
2
+ /**
3
+ * The Skyramp SDK version the repository that owns `testFile` runs, as far as it
4
+ * can be read without its toolchain: the installed npm package, else the exact
5
+ * pin in the nearest manifest. Undefined when neither says. Both lookups stop at
6
+ * the repository root.
7
+ */
8
+ export declare function repoSkyrampSdkVersion(testFile: string, ecosystem: SdkEcosystem): string | undefined;
9
+ /**
10
+ * SKYR-4548: the refusal for a host run whose repository uses an older Skyramp
11
+ * SDK than the one that generated the test, or undefined when the run may go
12
+ * ahead. Such a run fails at import, which reads as a broken test. No readable
13
+ * version means no refusal: a missing SDK is an import error that
14
+ * fix-test-import-errors owns.
15
+ */
16
+ export declare function staleSdkRefusal(testFile: string, language: string, serverVersion?: string): string | undefined;
@@ -0,0 +1,88 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import semver from "semver";
4
+ import { ProgrammingLanguage } from "../types/TestTypes.js";
5
+ import { findUp, installedManifest } from "./findUp.js";
6
+ import { logger } from "./logger.js";
7
+ import { SKYRAMP_SDK_VERSION } from "./skyrampSdkVersion.js";
8
+ import { manifestNames, pinnedSkyrampSdkVersion, SKYRAMP_SDK_PACKAGE, } from "./testDependencyPolicy.js";
9
+ const ECOSYSTEM = {
10
+ [ProgrammingLanguage.TYPESCRIPT]: "javascript",
11
+ [ProgrammingLanguage.JAVASCRIPT]: "javascript",
12
+ [ProgrammingLanguage.PYTHON]: "python",
13
+ [ProgrammingLanguage.JAVA]: "java",
14
+ };
15
+ /** The one edit that moves the repository onto the SDK that generated the
16
+ * test, and that skyramp_verify_test_dependencies accepts. */
17
+ const PIN_EDIT = {
18
+ javascript: (v) => `Declare ${SKYRAMP_SDK_PACKAGE.javascript} under devDependencies only (move it out of dependencies if it is there), at exactly "${SKYRAMP_SDK_PACKAGE.javascript}": "${v}" with no range operator`,
19
+ python: (v) => `Change only the requirement to ${SKYRAMP_SDK_PACKAGE.python}==${v}`,
20
+ java: (v) => `Change only the ${SKYRAMP_SDK_PACKAGE.java} version to ${v}`,
21
+ };
22
+ /**
23
+ * The Skyramp SDK version the repository that owns `testFile` runs, as far as it
24
+ * can be read without its toolchain: the installed npm package, else the exact
25
+ * pin in the nearest manifest. Undefined when neither says. Both lookups stop at
26
+ * the repository root.
27
+ */
28
+ export function repoSkyrampSdkVersion(testFile, ecosystem) {
29
+ const from = path.dirname(path.resolve(testFile));
30
+ if (ecosystem === "javascript") {
31
+ const version = installedManifest(SKYRAMP_SDK_PACKAGE.javascript, from, {
32
+ stopAtRepoRoot: true,
33
+ })?.version;
34
+ if (typeof version === "string")
35
+ return version;
36
+ }
37
+ const names = manifestNames(ecosystem);
38
+ const nearest = findUp(from, names, { stopAtRepoRoot: true });
39
+ if (!nearest)
40
+ return undefined;
41
+ for (const name of names) {
42
+ const manifest = path.join(path.dirname(nearest), name);
43
+ if (!fs.existsSync(manifest))
44
+ continue;
45
+ try {
46
+ const pinned = pinnedSkyrampSdkVersion(name, fs.readFileSync(manifest, "utf8"));
47
+ if (pinned)
48
+ return pinned;
49
+ }
50
+ catch (error) {
51
+ logger.warning(`Could not read the Skyramp SDK version in ${manifest}`, {
52
+ error: String(error),
53
+ });
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ /**
59
+ * SKYR-4548: the refusal for a host run whose repository uses an older Skyramp
60
+ * SDK than the one that generated the test, or undefined when the run may go
61
+ * ahead. Such a run fails at import, which reads as a broken test. No readable
62
+ * version means no refusal: a missing SDK is an import error that
63
+ * fix-test-import-errors owns.
64
+ */
65
+ export function staleSdkRefusal(testFile, language, serverVersion = SKYRAMP_SDK_VERSION) {
66
+ const ecosystem = ECOSYSTEM[language];
67
+ if (!ecosystem)
68
+ return undefined;
69
+ const repoVersion = repoSkyrampSdkVersion(testFile, ecosystem);
70
+ if (!isOlderRelease(repoVersion, serverVersion))
71
+ return undefined;
72
+ return (`Not executed: this repository uses Skyramp SDK ${repoVersion}, but the test was generated by ${serverVersion}. ` +
73
+ `${PIN_EDIT[ecosystem](serverVersion)}, update the lockfile if the repository commits one, reinstall, ` +
74
+ `run skyramp_verify_test_dependencies, and call this tool again. Report the SDK change as an issuesFound entry with category config.`);
75
+ }
76
+ /** True when `repo` is an older release than `server`. Pre-release tags are
77
+ * ignored (1.3.47-rc.1 is 1.3.47), and a version semver cannot read is never
78
+ * older: the gate must not refuse a run on a guess. */
79
+ function isOlderRelease(repo, server) {
80
+ const a = repo ? semver.parse(repo) : null;
81
+ const b = semver.parse(server);
82
+ if (!a || !b) {
83
+ if (repo)
84
+ logger.warning(`Cannot compare Skyramp SDK ${repo} to ${server}`);
85
+ return false;
86
+ }
87
+ return a.compareMain(b) < 0;
88
+ }
@@ -7,3 +7,23 @@ export interface TestDependencyVerification {
7
7
  violations: string[];
8
8
  }
9
9
  export declare function verifyTestDependencyChanges(repositoryPath: string): Promise<TestDependencyVerification>;
10
+ /** The Skyramp SDK's name in each ecosystem. All three are released in lockstep
11
+ * with the npm SDK this server imports, so SKYRAMP_SDK_VERSION is the version
12
+ * that generated a test in any of them. */
13
+ export declare const SKYRAMP_SDK_PACKAGE: {
14
+ readonly javascript: "@skyramp/skyramp";
15
+ readonly python: "skyramp";
16
+ readonly java: "dev.skyramp:skyramp-library";
17
+ };
18
+ export type SdkEcosystem = keyof typeof SKYRAMP_SDK_PACKAGE;
19
+ /** The manifest file names an ecosystem declares its dependencies in. */
20
+ export declare function manifestNames(ecosystem: SdkEcosystem): readonly string[];
21
+ /**
22
+ * The exact Skyramp SDK version a manifest pins where the change verifier lets
23
+ * the agent raise it, or undefined: the SDK is absent, declared as a range,
24
+ * resolved elsewhere (a Maven property or BOM, a Gradle catalog), or pinned in
25
+ * a place the verifier refuses to change (`dependencies`, requirements.txt, a
26
+ * Maven block without test scope, a Gradle `implementation` line). A refusal
27
+ * over such a pin would have no fix the agent may make.
28
+ */
29
+ export declare function pinnedSkyrampSdkVersion(fileName: string, raw: string): string | undefined;
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import { parse as parseToml } from "smol-toml";
5
5
  import { promisify } from "util";
6
6
  import fg from "fast-glob";
7
+ import semver from "semver";
7
8
  import { canonicalJson as canonical, isPlainObject as isObject, } from "./canonicalJson.js";
8
9
  import { listChangedFiles } from "./reportVerification.js";
9
10
  import { contentAtHead } from "./utils-verify/head.js";
@@ -278,18 +279,77 @@ function verifyPackageJson(file, beforeRaw, afterRaw, violations) {
278
279
  canonical(beforeDev) !== canonical(afterDev));
279
280
  }
280
281
  /** The SDK the tests were generated by is the one this server imports, so the
281
- * repository must declare exactly that version. Only the npm spelling carries
282
- * a version: the Python and Java manifests declare the SDK without one, so
283
- * neither is checked here.
282
+ * repository must declare exactly that version. Only a changed npm entry is
283
+ * checked here: the guidance declares the Python and Java SDK without a
284
+ * version. A pin a repository already has is read by pinnedSkyrampSdkVersion.
284
285
  *
285
286
  * The specifier must be the bare version. A range is refused, including
286
287
  * `^1.2.3`: it permits 1.3.0, so a clean install elsewhere can resolve an SDK
287
288
  * that did not write these tests, which is the whole thing this prevents. */
288
289
  function declaresServerSdkVersion(name, specifier) {
289
- if (name !== "@skyramp/skyramp")
290
+ if (name !== SKYRAMP_SDK_PACKAGE.javascript)
290
291
  return true;
291
292
  return specifier.trim() === SKYRAMP_SDK_VERSION;
292
293
  }
294
+ /** The Skyramp SDK's name in each ecosystem. All three are released in lockstep
295
+ * with the npm SDK this server imports, so SKYRAMP_SDK_VERSION is the version
296
+ * that generated a test in any of them. */
297
+ export const SKYRAMP_SDK_PACKAGE = {
298
+ javascript: "@skyramp/skyramp",
299
+ python: "skyramp",
300
+ java: "dev.skyramp:skyramp-library",
301
+ };
302
+ /** The manifest file names an ecosystem declares its dependencies in. */
303
+ export function manifestNames(ecosystem) {
304
+ return DEPENDENCY_FILES[ecosystem].manifests;
305
+ }
306
+ /**
307
+ * The exact Skyramp SDK version a manifest pins where the change verifier lets
308
+ * the agent raise it, or undefined: the SDK is absent, declared as a range,
309
+ * resolved elsewhere (a Maven property or BOM, a Gradle catalog), or pinned in
310
+ * a place the verifier refuses to change (`dependencies`, requirements.txt, a
311
+ * Maven block without test scope, a Gradle `implementation` line). A refusal
312
+ * over such a pin would have no fix the agent may make.
313
+ */
314
+ export function pinnedSkyrampSdkVersion(fileName, raw) {
315
+ switch (fileName) {
316
+ case "package.json":
317
+ return exactVersion(stringMap(JSON.parse(raw).devDependencies)[SKYRAMP_SDK_PACKAGE.javascript]);
318
+ case "requirements-dev.txt":
319
+ return pep440Pin(requirementsMap(raw).dependencies[SKYRAMP_SDK_PACKAGE.python]);
320
+ case "pyproject.toml": {
321
+ // Poetry reads a bare version as an exact pin; PEP 735 carries `==`.
322
+ const spec = pythonDevView(raw).dependencies[SKYRAMP_SDK_PACKAGE.python];
323
+ return pep440Pin(spec) ?? exactVersion(spec);
324
+ }
325
+ case "pom.xml": {
326
+ const blocks = mavenView(raw.replace(/<!--[\s\S]*?-->/g, ""))
327
+ .dependencies[SKYRAMP_SDK_PACKAGE.java];
328
+ return blocks?.length === 1 && !mavenDependency(blocks[0]).error
329
+ ? exactVersion(singleXmlTag(blocks[0], "version"))
330
+ : undefined;
331
+ }
332
+ case "build.gradle":
333
+ case "build.gradle.kts": {
334
+ const lines = gradleView(raw).dependencies[SKYRAMP_SDK_PACKAGE.java];
335
+ if (lines?.length !== 1)
336
+ return undefined;
337
+ const [configuration, value] = lines[0].split("\0");
338
+ return configuration === "testImplementation" ||
339
+ configuration === "testRuntimeOnly"
340
+ ? exactVersion(value.split(":")[2])
341
+ : undefined;
342
+ }
343
+ default:
344
+ return undefined;
345
+ }
346
+ }
347
+ function exactVersion(spec) {
348
+ return (spec && semver.valid(spec.trim())) || undefined;
349
+ }
350
+ function pep440Pin(requirement) {
351
+ return exactVersion(/==\s*([^,;\s]+)/.exec(requirement ?? "")?.[1]);
352
+ }
293
353
  function verifyPyproject(file, beforeRaw, afterRaw, violations) {
294
354
  const before = pythonDevView(beforeRaw);
295
355
  const after = pythonDevView(afterRaw);
@@ -1,5 +1,5 @@
1
1
  import * as path from "path";
2
- import { readFileSync } from "fs";
2
+ import { installedManifest } from "../findUp.js";
3
3
  import { fileURLToPath } from "url";
4
4
  let tsModule;
5
5
  /** The compiler, loaded on first use and once per process. Loaded statically it
@@ -64,6 +64,8 @@ function executorPeerPackages(mcpRoot) {
64
64
  return peers;
65
65
  peers = [];
66
66
  for (const pkg of EXECUTOR_PROVIDED_PACKAGES) {
67
+ // Nearest `node_modules/<pkg>` walking UP: `npm install @skyramp/mcp`,
68
+ // testbot's default, hoists the MCP's dependencies BESIDE it, not under it.
67
69
  const manifest = installedManifest(pkg, mcpRoot);
68
70
  if (manifest)
69
71
  peers.push(...Object.keys(manifest.peerDependencies ?? {}));
@@ -74,23 +76,6 @@ function executorPeerPackages(mcpRoot) {
74
76
  peersByRoot.set(mcpRoot, peers);
75
77
  return peers;
76
78
  }
77
- /** The package's manifest as Node would find it from `from`: the nearest
78
- * `node_modules/<pkg>` walking UP from there. The MCP's dependencies are not
79
- * always nested under its own root — `npm install @skyramp/mcp`, testbot's
80
- * default, hoists them BESIDE `@skyramp/mcp` — and a read at one fixed path
81
- * found nothing on that layout, left the peer list empty, and brought the two
82
- * Playwright identities back exactly where customers run. */
83
- function installedManifest(pkg, from) {
84
- for (let dir = from;; dir = path.dirname(dir)) {
85
- try {
86
- return JSON.parse(readFileSync(path.join(dir, "node_modules", pkg, "package.json"), "utf8"));
87
- }
88
- catch {
89
- if (path.dirname(dir) === dir)
90
- return undefined;
91
- }
92
- }
93
- }
94
79
  const isPackageOrSubpath = (specifier, packages) => packages.some((p) => specifier === p || specifier.startsWith(`${p}/`));
95
80
  function executorProvides(specifier) {
96
81
  return isPackageOrSubpath(specifier, EXECUTOR_PROVIDED_PACKAGES);
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var assertOrder_exports = {};
20
+ __export(assertOrder_exports, {
21
+ checkOrderArgs: () => checkOrderArgs,
22
+ containmentError: () => containmentError,
23
+ contains: () => contains,
24
+ describeOrderResult: () => describeOrderResult
25
+ });
26
+ module.exports = __toCommonJS(assertOrder_exports);
27
+ var import_utils = require("playwright-core/lib/utils");
28
+ function checkOrderArgs(params) {
29
+ if (!(0, import_utils.isOrderRelation)(params.expected))
30
+ return { error: `For "order" assertions, "expected" must be one of ${import_utils.ORDER_RELATIONS.map((r) => `"${r}"`).join(", ")}.` };
31
+ if (!params.otherRef?.trim())
32
+ return { error: 'For "order" assertions, "otherRef" (the element this one is compared with) is required.' };
33
+ if (params.otherRef === params.ref)
34
+ return { error: '"ref" and "otherRef" are the same element; an order assertion compares two different elements.' };
35
+ return { relation: params.expected, otherElement: params.otherElement?.trim() || `ref ${params.otherRef}` };
36
+ }
37
+ function contains(outer, inner) {
38
+ return outer.x <= inner.x && outer.y <= inner.y && outer.x + outer.width >= inner.x + inner.width && outer.y + outer.height >= inner.y + inner.height;
39
+ }
40
+ function containmentError(a, b, boxA, boxB) {
41
+ if (!contains(boxA, boxB) && !contains(boxB, boxA))
42
+ return void 0;
43
+ const [outer, inner] = contains(boxA, boxB) ? [a, b] : [b, a];
44
+ return `"${outer.element}" contains "${inner.element}", so they have no order. Assert the order of two sibling blocks (e.g. the two columns), not of a block and something inside it.`;
45
+ }
46
+ function describeOrderResult(relation, a, b, boxA, boxB) {
47
+ const result = (0, import_utils.orderHolds)(relation, boxA, boxB);
48
+ const { axis, edgeA, edgeB } = result;
49
+ const round = (v) => Math.round(v * 10) / 10;
50
+ const words = (r) => r.replace("-", " ");
51
+ if (result.ok)
52
+ return { result, details: `Order assertion passed: "${a.element}" (${axis}=${round(edgeA)}) is ${words(relation)} "${b.element}" (${axis}=${round(edgeB)}).` };
53
+ const actual = edgeA === edgeB ? `at the same ${axis} as` : axis === "x" ? edgeA < edgeB ? "left of" : "right of" : edgeA < edgeB ? "above" : "below";
54
+ const lowFirst = relation === "left-of" || relation === "above";
55
+ return {
56
+ result,
57
+ details: `Order assertion FAILED: "${a.element}" (${axis}=${round(edgeA)}) is ${actual} "${b.element}" (${axis}=${round(edgeB)}), expected ${words(relation)}. Nothing was recorded. If a requirement demands ${relation}, add the comparison to the generated test with these locators (JavaScript syntax; write them in the test's language): ${(0, import_utils.asLocator)("javascript", a.selector)} and ${(0, import_utils.asLocator)("javascript", b.selector)} (compare boundingBox().${axis}; the first must be ${lowFirst ? "smaller" : "greater"}).`
58
+ };
59
+ }
60
+ // Annotate the CommonJS export names for ESM import in node:
61
+ 0 && (module.exports = {
62
+ checkOrderArgs,
63
+ containmentError,
64
+ contains,
65
+ describeOrderResult
66
+ });
@@ -18,11 +18,13 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var assertTool_exports = {};
20
20
  __export(assertTool_exports, {
21
+ ORDER_RELATIONS: () => import_utils.ORDER_RELATIONS,
21
22
  assertMcpTool: () => assertMcpTool,
22
23
  assertToolSchema: () => assertToolSchema
23
24
  });
24
25
  module.exports = __toCommonJS(assertTool_exports);
25
26
  var import_mcpBundle = require("playwright-core/lib/mcpBundle");
27
+ var import_utils = require("playwright-core/lib/utils");
26
28
  var import_tool = require("../sdk/tool");
27
29
  const assertToolSchema = {
28
30
  name: "browser_assert",
@@ -38,17 +40,23 @@ const assertToolSchema = {
38
40
  "across the page, e.g. ref one option of an open dropdown to assert the number of options.",
39
41
  '"css" checks a computed style property (property "background-color", expected "rgb(37, 99, 235)"; a 3- or 6-digit #hex colour is accepted and recorded as the computed rgb),',
40
42
  '"class" checks the element carries a class ("expected" = one class name),',
41
- '"attribute" checks an attribute value ("property" = the attribute name, "expected" = its value).',
43
+ '"attribute" checks an attribute value ("property" = the attribute name, "expected" = its value),',
44
+ '"order" checks where the element sits relative to another one; "otherRef" and "expected" are REQUIRED for it ("otherRef" = the other element, "expected" = "left-of", "right-of", "above" or "below").',
45
+ 'It compares where the two elements START: the left edges (x) for left-of/right-of, the top edges (y) for above/below, strictly \u2014 "left-of" means "starts further left", not "wholly to the left", and equal edges fail (side-by-side columns are not "above" each other; full-width stacked blocks are not "left-of" each other).',
46
+ "Pick the axis the PR actually changes. Both elements must be visible, and one containing the other is refused: assert two sibling blocks.",
47
+ 'Use "order" when the PR swaps, moves or reorders blocks (a left/right column swap, a moved sidebar): it fails when the swap is undone.',
42
48
  "Use css/class/attribute for a colour, style or class change the PR makes: a screenshot baseline created on this run cannot fail on it.",
43
49
  'css/class/attribute are read with the pointer moved OFF the element, so a value that exists only on hover is never recorded; unlike "count", they cannot be asserted on an option of a menu that closes when the pointer leaves it.',
44
50
  "To assert an element is absent or not visible, use `browser_assert_hidden`."
45
51
  ].join(" "),
46
52
  inputSchema: import_mcpBundle.z.object({
47
- type: import_mcpBundle.z.enum(["text", "value", "checked", "disabled", "visible", "count", "css", "class", "attribute"]).describe(`Type of assertion: "text" for text content, "value" for input field value, "checked" for checkbox/radio state, "disabled" for disabled/enabled state ("false" asserts the element is enabled), "visible" for element visibility, "count" for the number of elements with the referenced element's role, "css" for a computed style property, "class" for a class the element carries, "attribute" for an attribute value`),
53
+ type: import_mcpBundle.z.enum(["text", "value", "checked", "disabled", "visible", "count", "css", "class", "attribute", "order"]).describe(`Type of assertion: "text" for text content, "value" for input field value, "checked" for checkbox/radio state, "disabled" for disabled/enabled state ("false" asserts the element is enabled), "visible" for element visibility, "count" for the number of elements with the referenced element's role, "css" for a computed style property, "class" for a class the element carries, "attribute" for an attribute value, "order" for the element's position relative to another element`),
48
54
  ref: import_mcpBundle.z.string().describe('Element reference from the latest snapshot. For "count", reference one exemplar element (e.g. one option in an open dropdown).'),
49
55
  element: import_mcpBundle.z.string().describe("Human-readable description of the element being asserted"),
56
+ otherRef: import_mcpBundle.z.string().optional().describe(`Required for "order": the other element's reference from the latest snapshot. Not used by other types.`),
57
+ otherElement: import_mcpBundle.z.string().optional().describe('For "order": human-readable description of the other element. Not used by other types.'),
50
58
  property: import_mcpBundle.z.string().optional().describe('For "css": the CSS property (e.g. "background-color", "flex-direction"). For "attribute": the attribute name (e.g. "aria-expanded", "data-state"). Not used by other types.'),
51
- expected: import_mcpBundle.z.string().optional().describe('Expected text or value ("text"/"value"), "true"/"false" ("checked"/"disabled", default "true"; "disabled" with "false" asserts enabled), the expected element count ("count"), the computed style value ("css"; a 3- or 6-digit #hex colour is converted to rgb), one class name ("class"), the attribute value ("attribute"). Not used for "visible".'),
59
+ expected: import_mcpBundle.z.string().optional().describe('Expected text or value ("text"/"value"), "true"/"false" ("checked"/"disabled", default "true"; "disabled" with "false" asserts enabled), the expected element count ("count"), the computed style value ("css"; a 3- or 6-digit #hex colour is converted to rgb), one class name ("class"), the attribute value ("attribute"), "left-of" / "right-of" / "above" / "below" ("order"). Not used for "visible".'),
52
60
  substring: import_mcpBundle.z.boolean().optional().default(true).describe("For text assertions: match as substring (true) or exact match (false)")
53
61
  }),
54
62
  type: "readOnly"
@@ -58,6 +66,7 @@ function assertMcpTool() {
58
66
  }
59
67
  // Annotate the CommonJS export names for ESM import in node:
60
68
  0 && (module.exports = {
69
+ ORDER_RELATIONS,
61
70
  assertMcpTool,
62
71
  assertToolSchema
63
72
  });
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var liveReadError_exports = {};
20
+ __export(liveReadError_exports, {
21
+ liveReadError: () => liveReadError
22
+ });
23
+ module.exports = __toCommonJS(liveReadError_exports);
24
+ function liveReadError(e, o) {
25
+ const err = (text) => ({ content: [{ type: "text", text: `### Error
26
+ ${text}` }], isError: true });
27
+ const msg = String(e?.message ?? e);
28
+ if (/strict mode violation/i.test(msg))
29
+ return err(`Target ${o.selector} matches multiple elements, so ${o.ambiguity}${o.pinFailure ? `, and ${o.pinFailure}` : ""}. Use a more specific target (a data-testid, or role + exact name). ${msg.split("\n")[0]}`);
30
+ if (e?.name === "TimeoutError") {
31
+ if (o.timeoutMs === void 0)
32
+ return void 0;
33
+ return err(`"${o.element}" (${o.selector}) did not resolve within ${o.timeoutMs} ms. Re-snapshot and retry with a fresh ref.`);
34
+ }
35
+ return err(`Could not ${o.failedTo} "${o.element}": ${msg}`);
36
+ }
37
+ // Annotate the CommonJS export names for ESM import in node:
38
+ 0 && (module.exports = {
39
+ liveReadError
40
+ });
@@ -113,6 +113,8 @@ function describeStep(action, index) {
113
113
  detail += ` .${a.className}`;
114
114
  else if (a.name === "assertAttribute")
115
115
  detail += ` [${a.attrName}=${JSON.stringify(a.attrValue)}]`;
116
+ else if (a.name === "assertOrder")
117
+ detail += ` ${a.relation} ${a.otherSelector}`;
116
118
  else if (a.name === "press" && a.key !== void 0)
117
119
  detail += ` ${a.key}`;
118
120
  else if (a.name === "visualSnapshot")
@@ -389,6 +391,25 @@ async function performClientAction(page, actionInContext) {
389
391
  throw new Error(`assertAttribute failed: expected ${action.attrName}=${JSON.stringify(action.attrValue)}, got ${JSON.stringify(actual)}`);
390
392
  return;
391
393
  }
394
+ // SKYR-4544: the comparison the recorder verified and the generated test
395
+ // runs — each box's origin, the left edge (x) for left-of/right-of and the
396
+ // top edge (y) for above/below, compared strictly — after waiting for both
397
+ // elements to be visible, as the generated test does.
398
+ case "assertOrder": {
399
+ const fields = (0, import_utils.parseOrderFields)(action);
400
+ if (!fields)
401
+ throw new Error(`assertOrder failed: trace line needs "otherSelector" and a relation of ${import_utils.ORDER_RELATIONS.join("/")}, got ${JSON.stringify(action.relation)}`);
402
+ const other = page.locator(buildFullSelector(actionInContext.frame.framePath, fields.otherSelector));
403
+ await locator.waitFor({ state: "visible", ...opts });
404
+ await other.waitFor({ state: "visible", ...opts });
405
+ const [boxA, boxB] = [await locator.boundingBox(opts), await other.boundingBox(opts)];
406
+ if (!boxA || !boxB)
407
+ throw new Error(`assertOrder failed: ${!boxA ? fields.selector : fields.otherSelector} has no box on the page`);
408
+ const { ok, axis, edgeA, edgeB } = (0, import_utils.orderHolds)(fields.relation, boxA, boxB);
409
+ if (!ok)
410
+ throw new Error(`assertOrder failed: expected ${fields.selector} ${fields.relation} ${fields.otherSelector}, got ${axis} ${edgeA} vs ${edgeB}`);
411
+ return;
412
+ }
392
413
  case "assertChecked": {
393
414
  const checked = await locator.isChecked(opts);
394
415
  if (checked !== !!action.checked)
@@ -70,6 +70,7 @@ const ACTION_NAMES = /* @__PURE__ */ new Set([
70
70
  "assertCSS",
71
71
  "assertClass",
72
72
  "assertAttribute",
73
+ "assertOrder",
73
74
  "dragTo",
74
75
  "dragAndDrop",
75
76
  "diagramNodeAdd",
@@ -143,7 +144,7 @@ function parseLine(line) {
143
144
  if (!entry.name || !isActionName(entry.name))
144
145
  return null;
145
146
  const { pageGuid, pageAlias, framePath, locator, ...actionFields } = entry;
146
- if (entry.name === "assertHidden" && locator && typeof locator === "object")
147
+ if ((entry.name === "assertHidden" || entry.name === "assertOrder") && locator && typeof locator === "object")
147
148
  actionFields.locator = locator;
148
149
  const frame = {
149
150
  pageGuid: pageGuid ?? "",