@applitools/eyes-results 0.2.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 +8 -0
- package/LICENSE +26 -0
- package/dist/api/api.js +17 -0
- package/dist/api/compare-step.js +20 -0
- package/dist/api/diff-fetch.js +13 -0
- package/dist/api/dom-diff.js +139 -0
- package/dist/api/dom-fetch.js +39 -0
- package/dist/api/find-regions.js +24 -0
- package/dist/api/fs-cache.js +19 -0
- package/dist/api/gzip.js +12 -0
- package/dist/api/image-fetch.js +31 -0
- package/dist/api/polling.js +117 -0
- package/dist/api/req-results.js +77 -0
- package/dist/api/same-diff-as.js +25 -0
- package/dist/api/selector-mapping.js +15 -0
- package/dist/api/session-fetch.js +35 -0
- package/dist/api/session-history-fetch.js +73 -0
- package/dist/api/session-updates.js +37 -0
- package/dist/api/tasks.js +48 -0
- package/dist/api/url-parsing.js +33 -0
- package/dist/common/common.js +2 -0
- package/dist/common/diff-rects.js +64 -0
- package/dist/common/region-mapping.js +50 -0
- package/dist/geometry/geometry.js +72 -0
- package/dist/index.js +14 -0
- package/dist/lib/api-key-provider.js +26 -0
- package/dist/lib/env.js +26 -0
- package/dist/lib/phase-tracker.js +223 -0
- package/dist/sessions/batch.js +98 -0
- package/dist/sessions/diffs.js +210 -0
- package/dist/sessions/dom-history.js +107 -0
- package/dist/sessions/dom-search.js +1150 -0
- package/dist/sessions/dom.js +513 -0
- package/dist/sessions/match.js +56 -0
- package/dist/sessions/regions.js +280 -0
- package/dist/sessions/session.js +115 -0
- package/llm-docs/dom-search.md +164 -0
- package/package.json +69 -0
- package/types/api/api.d.ts +17 -0
- package/types/api/compare-step.d.ts +52 -0
- package/types/api/diff-fetch.d.ts +21 -0
- package/types/api/dom-diff.d.ts +53 -0
- package/types/api/dom-fetch.d.ts +29 -0
- package/types/api/find-regions.d.ts +26 -0
- package/types/api/fs-cache.d.ts +12 -0
- package/types/api/gzip.d.ts +5 -0
- package/types/api/image-fetch.d.ts +29 -0
- package/types/api/polling.d.ts +41 -0
- package/types/api/req-results.d.ts +37 -0
- package/types/api/same-diff-as.d.ts +38 -0
- package/types/api/selector-mapping.d.ts +28 -0
- package/types/api/session-fetch.d.ts +34 -0
- package/types/api/session-history-fetch.d.ts +56 -0
- package/types/api/session-updates.d.ts +26 -0
- package/types/api/tasks.d.ts +33 -0
- package/types/api/url-parsing.d.ts +20 -0
- package/types/common/common.d.ts +2 -0
- package/types/common/diff-rects.d.ts +39 -0
- package/types/common/region-mapping.d.ts +26 -0
- package/types/geometry/geometry.d.ts +24 -0
- package/types/index.d.ts +14 -0
- package/types/lib/api-key-provider.d.ts +31 -0
- package/types/lib/env.d.ts +14 -0
- package/types/lib/phase-tracker.d.ts +146 -0
- package/types/sessions/batch.d.ts +67 -0
- package/types/sessions/diffs.d.ts +26 -0
- package/types/sessions/dom-history.d.ts +119 -0
- package/types/sessions/dom-search.d.ts +113 -0
- package/types/sessions/dom.d.ts +263 -0
- package/types/sessions/match.d.ts +72 -0
- package/types/sessions/regions.d.ts +171 -0
- package/types/sessions/session.d.ts +97 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getJson, reqTransport } from './polling.js';
|
|
2
|
+
import { simplifySession } from '../sessions/session.js';
|
|
3
|
+
/** Fetches the raw Eyes session API response — no shaping. */
|
|
4
|
+
export async function fetchRawSession(options, fetchImpl = reqTransport) {
|
|
5
|
+
const { baseUrl, batchId, sessionId, accountId, keys } = options;
|
|
6
|
+
const url = `${baseUrl}/api/sessions/batches/${batchId}/${sessionId}?accountId=${accountId}&apiKey=${encodeURIComponent(keys.getReadKey())}`;
|
|
7
|
+
return getJson(url, fetchImpl);
|
|
8
|
+
}
|
|
9
|
+
/** Fetches a session and shapes it via `sessions/session`'s `simplifySession`. */
|
|
10
|
+
export async function fetchSession(options, fetchImpl = reqTransport) {
|
|
11
|
+
return simplifySession(await fetchRawSession(options, fetchImpl));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fetches the raw list of sessions in a batch — no shaping.
|
|
15
|
+
* Normalizes the response shape: the Eyes API returns either a bare array or `{ sessions: [...] }`.
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchRawBatchSessions(options, fetchImpl = reqTransport) {
|
|
18
|
+
const { baseUrl, batchId, accountId, keys } = options;
|
|
19
|
+
const url = `${baseUrl}/api/sessions/batches/${batchId}?accountId=${accountId}&apiKey=${encodeURIComponent(keys.getReadKey())}`;
|
|
20
|
+
const data = await getJson(url, fetchImpl);
|
|
21
|
+
return Array.isArray(data) ? data : data.sessions ?? [];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fetches the raw batch entity itself (name, timing, and — the reason this exists —
|
|
25
|
+
* its server-maintained aggregate counts, shaped by `sessions/batch`'s `toBatchStats` —
|
|
26
|
+
* see `BatchInfo` there) — a different endpoint from `fetchRawBatchSessions`, which lists
|
|
27
|
+
* the batch's individual sessions. This aggregate is eventually consistent: it can lag
|
|
28
|
+
* behind a resolve/save call by several seconds, unlike a session list (or anything derived
|
|
29
|
+
* from one), which is always current.
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchBatchInfo(options, fetchImpl = reqTransport) {
|
|
32
|
+
const { baseUrl, batchId, accountId, keys } = options;
|
|
33
|
+
const url = `${baseUrl}/api/sessions/batches/${batchId}/batch?accountId=${accountId}&apiKey=${encodeURIComponent(keys.getReadKey())}`;
|
|
34
|
+
return getJson(url, fetchImpl);
|
|
35
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import * as crypto from 'crypto';
|
|
3
|
+
import { getJsonResult, reqTransport } from './polling.js';
|
|
4
|
+
import { defaultFsCacheDeps, cacheFilePath } from './fs-cache.js';
|
|
5
|
+
/**
|
|
6
|
+
* Builds the `where=` filter clauses for a session-history query. Each clause's payload is a
|
|
7
|
+
* plain UTF-8 string, base64-encoded — not `encodeURIComponent(JSON.stringify(...))` — and
|
|
8
|
+
* `IsViewport` in particular is a custom `{width:N,height:N}` literal (unquoted keys, not
|
|
9
|
+
* valid JSON), confirmed by decoding a real sample URL. Returns only the clauses for fields
|
|
10
|
+
* actually present in `env`, `&&`-joined; a caller further `encodeURIComponent`s the whole
|
|
11
|
+
* result when building the request URL — this returns the raw (unencoded) filter text.
|
|
12
|
+
*/
|
|
13
|
+
export function buildSessionHistoryEnvFilter(env) {
|
|
14
|
+
const clauses = [];
|
|
15
|
+
const b64 = (value) => Buffer.from(value, 'utf8').toString('base64');
|
|
16
|
+
if (env.hostingApp)
|
|
17
|
+
clauses.push(`(IsHostApp:${b64(env.hostingApp)})`);
|
|
18
|
+
if (env.os)
|
|
19
|
+
clauses.push(`(IsHostOS:${b64(env.os)})`);
|
|
20
|
+
if (env.displaySize) {
|
|
21
|
+
const { width, height } = env.displaySize;
|
|
22
|
+
clauses.push(`(IsViewport:${b64(`{width:${width},height:${height}}`)})`);
|
|
23
|
+
}
|
|
24
|
+
return clauses.join('&&');
|
|
25
|
+
}
|
|
26
|
+
export const defaultFetchSessionHistoryDeps = {
|
|
27
|
+
...defaultFsCacheDeps,
|
|
28
|
+
fetchImpl: reqTransport,
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Fetches (or reads from cache) a page of a scenario's historic sessions matching `env`, at or
|
|
32
|
+
* before `ranAtOrBefore`. Confirmed live as an async-job endpoint (a `202` with a `Location`
|
|
33
|
+
* status-poll header, same convention as `images/get-compatible-selectors` in
|
|
34
|
+
* `selector-mapping.ts` — there a POST initiates it, here a plain GET does), so this goes
|
|
35
|
+
* through `getJsonResult`, not `getJson` (which assumes a synchronous body).
|
|
36
|
+
*
|
|
37
|
+
* Named for what it returns — a session history — not for `scenarioId`, which is only one of
|
|
38
|
+
* several inputs (alongside `env`) narrowing which sessions come back; the endpoint itself is
|
|
39
|
+
* scoped by scenario, but the result is a plain list of full raw sessions, not scenario-level
|
|
40
|
+
* aggregate data.
|
|
41
|
+
*
|
|
42
|
+
* Cached indefinitely, keyed by a hash of every input that affects the result — unlike a
|
|
43
|
+
* session/batch fetch (deliberately never cached elsewhere in `api/`, since those can change),
|
|
44
|
+
* a `ranAtOrBefore`-pinned query against the past is immutable once made.
|
|
45
|
+
*
|
|
46
|
+
* Confirmed live: the response is `{ "sessions": [...] }`, each entry a full raw session
|
|
47
|
+
* object (the same shape `fetchRawSession` returns) — but a bare-array response is still
|
|
48
|
+
* normalized defensively, mirroring `fetchRawBatchSessions`, in case an edge case omits the
|
|
49
|
+
* wrapper.
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchSessionHistory(options, deps = defaultFetchSessionHistoryDeps) {
|
|
52
|
+
const { baseUrl, scenarioId, accountId, keys, env, ranAtOrBefore, count } = options;
|
|
53
|
+
const apiKey = keys.getReadKey();
|
|
54
|
+
const where = buildSessionHistoryEnvFilter(env);
|
|
55
|
+
const cacheKey = crypto
|
|
56
|
+
.createHash('sha256')
|
|
57
|
+
.update(JSON.stringify({ scenarioId, where, ranAtOrBefore, count }))
|
|
58
|
+
.digest('hex');
|
|
59
|
+
const cachePath = cacheFilePath(deps, 'eyes-session-history', cacheKey, 'json');
|
|
60
|
+
const cached = deps.readFile(cachePath);
|
|
61
|
+
if (cached !== null)
|
|
62
|
+
return JSON.parse(cached);
|
|
63
|
+
const url = `${baseUrl}/api/sessions/scenarios/${scenarioId}/query` +
|
|
64
|
+
`?ranAtOrBefore=${encodeURIComponent(ranAtOrBefore)}` +
|
|
65
|
+
`&where=${encodeURIComponent(where)}` +
|
|
66
|
+
`&count=${count}` +
|
|
67
|
+
`&accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
68
|
+
const response = await deps.fetchImpl(url);
|
|
69
|
+
const data = await getJsonResult(response, apiKey, deps.fetchImpl, deps.sleepFn, url);
|
|
70
|
+
const sessions = Array.isArray(data) ? data : data?.sessions ?? [];
|
|
71
|
+
deps.writeFile(cachePath, JSON.stringify(sessions));
|
|
72
|
+
return sessions;
|
|
73
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { postJson, getJsonResult, reqTransport } from './polling.js';
|
|
2
|
+
/**
|
|
3
|
+
* POSTs a batch of per-session step updates (accept/reject decisions, region changes) in one
|
|
4
|
+
* call — confirmed live to accept multiple sessions at once, one entry per session id.
|
|
5
|
+
*/
|
|
6
|
+
export async function postStepUpdates(options, stepUpdatesBySession, fetchImpl = reqTransport) {
|
|
7
|
+
const { baseUrl, batchId, accountId, keys } = options;
|
|
8
|
+
const apiKey = keys.getWriteKey();
|
|
9
|
+
const apiUrl = `${baseUrl}/api/sessions/batches/${batchId}/updates?accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
10
|
+
const updates = [...stepUpdatesBySession.entries()].map(([id, stepUpdates]) => ({ id, batchId, stepUpdates }));
|
|
11
|
+
const response = await postJson(apiUrl, { updates }, fetchImpl);
|
|
12
|
+
await getJsonResult(response, apiKey, fetchImpl, undefined, apiUrl);
|
|
13
|
+
}
|
|
14
|
+
/** Commits every pending accept/reject/region change in the batch to the baseline. The endpoint returns nothing useful to the caller — a resolved promise signals success. */
|
|
15
|
+
export async function saveBaselines(options, fetchImpl = reqTransport) {
|
|
16
|
+
const { baseUrl, batchId, accountId, keys } = options;
|
|
17
|
+
const apiKey = keys.getWriteKey();
|
|
18
|
+
const apiUrl = `${baseUrl}/api/sessions/batches/${batchId}/baselines?accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
19
|
+
const response = await postJson(apiUrl, undefined, fetchImpl);
|
|
20
|
+
await getJsonResult(response, apiKey, fetchImpl, undefined, apiUrl);
|
|
21
|
+
}
|
|
22
|
+
/** Reverts a session's shared baseline object to the revision it originally ran against. */
|
|
23
|
+
export async function restoreBaseline(options, fetchImpl = reqTransport) {
|
|
24
|
+
const { baseUrl, accountId, baselineId, baselineRev, keys } = options;
|
|
25
|
+
const apiKey = keys.getWriteKey();
|
|
26
|
+
const url = `${baseUrl}/api/baselines/${baselineId}/${baselineRev}/restore?accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
27
|
+
const response = await postJson(url, undefined, fetchImpl);
|
|
28
|
+
await getJsonResult(response, apiKey, fetchImpl, undefined, url);
|
|
29
|
+
}
|
|
30
|
+
/** Clears every given session's pending (unsaved) accept/reject/region resolution back to unresolved — a no-op, not an error, for a session with nothing pending. */
|
|
31
|
+
export async function resetBatchSessions(options, sessionIds, fetchImpl = reqTransport) {
|
|
32
|
+
const { baseUrl, batchId, accountId, keys } = options;
|
|
33
|
+
const apiKey = keys.getWriteKey();
|
|
34
|
+
const url = `${baseUrl}/api/sessions/batches/${batchId}/reset?accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
35
|
+
const response = await postJson(url, { ids: sessionIds }, fetchImpl);
|
|
36
|
+
await getJsonResult(response, apiKey, fetchImpl, undefined, url);
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { makeReqResults } from './req-results.js';
|
|
2
|
+
export const TASK_STATUS_PATH = (taskId) => `/api/tasks/${taskId}/status`;
|
|
3
|
+
export const TASK_RESULT_PATH = (taskId) => `/api/tasks/${taskId}/result`;
|
|
4
|
+
/** Extracts the taskId out of a submit response's `Location` (`/api/tasks/{taskId}/status`). */
|
|
5
|
+
export function taskIdFromLocation(location) {
|
|
6
|
+
const match = location.match(/\/api\/tasks\/([^/?]+)\/status/);
|
|
7
|
+
if (!match)
|
|
8
|
+
throw new Error(`Location does not name a task-status URL: ${location}`);
|
|
9
|
+
return match[1];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One probe of a task's status — no client-side polling loop: `running` hands the taskId back so
|
|
13
|
+
* the caller decides when (and whether) to check again; `201` means done, and the result the
|
|
14
|
+
* response's `Location` points at is fetched and returned in the same call, so a ready task never
|
|
15
|
+
* costs the caller a second round trip. `404` (unknown or expired task) is terminal and throws
|
|
16
|
+
* with the contract's own wording. A *rejected* task also finishes as `201` here — its rejection
|
|
17
|
+
* surfaces as `503` on the result fetch (TasksController puts it there, not on status); transient
|
|
18
|
+
* transport failures are the configured req's standard retry policy, not handled here.
|
|
19
|
+
*/
|
|
20
|
+
export async function getTaskStatus(baseUrl, taskId, keys, deps = {}) {
|
|
21
|
+
const req = makeReqResults({ serverUrl: baseUrl, apiKey: keys.getReadKey(), ...deps });
|
|
22
|
+
const response = await req(TASK_STATUS_PATH(taskId), {
|
|
23
|
+
name: 'getTaskStatus',
|
|
24
|
+
expected: [200, 201, 404],
|
|
25
|
+
});
|
|
26
|
+
if (response.status === 200)
|
|
27
|
+
return { status: 'running', taskId };
|
|
28
|
+
if (response.status === 201) {
|
|
29
|
+
const resultUrl = response.headers.get('Location') ?? TASK_RESULT_PATH(taskId);
|
|
30
|
+
return { status: 'ready', result: await fetchTaskResult(baseUrl, resultUrl, keys, deps) };
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Unknown or expired task: ${taskId}`);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Fetches a finished task's result and parses it as JSON. The response is gzipped on the wire
|
|
36
|
+
* when the request advertises `Accept-Encoding` — the transport negotiates and decompresses that
|
|
37
|
+
* transparently, so no explicit gunzip step exists here (unlike endpoints that return a gzip
|
|
38
|
+
* *body*, e.g. `fetchPrunedDom`). `503` is the result endpoint's "task rejected" — terminal by
|
|
39
|
+
* the task contract (and excluded from the retry policy for exactly that reason), thrown with
|
|
40
|
+
* the contract's own wording.
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchTaskResult(baseUrl, resultUrl, keys, deps = {}) {
|
|
43
|
+
const req = makeReqResults({ serverUrl: baseUrl, apiKey: keys.getReadKey(), ...deps });
|
|
44
|
+
const response = await req(resultUrl, { name: 'fetchTaskResult', expected: [200, 503] });
|
|
45
|
+
if (response.status === 503)
|
|
46
|
+
throw new Error(`Task rejected by the server: ${resultUrl}`);
|
|
47
|
+
return response.json();
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses an Applitools dashboard URL (session or batch) into its components.
|
|
3
|
+
* Does not require a sessionId to be present — use parseSessionUrl/parseBatchUrl
|
|
4
|
+
* for callers that need to enforce one shape or the other.
|
|
5
|
+
*/
|
|
6
|
+
export function parseApplitoolsUrl(url) {
|
|
7
|
+
const urlMatch = url.match(/^(https?:\/\/[^/]+)/);
|
|
8
|
+
if (!urlMatch)
|
|
9
|
+
throw new Error(`Invalid URL format: "${url}"`);
|
|
10
|
+
const batchMatch = url.match(/\/app\/test-results\/([^/?]+)/);
|
|
11
|
+
if (!batchMatch)
|
|
12
|
+
throw new Error(`Invalid URL format: "${url}"`);
|
|
13
|
+
const accountIdMatch = url.match(/accountId=([^&]+)/);
|
|
14
|
+
if (!accountIdMatch)
|
|
15
|
+
throw new Error(`Missing accountId in URL: "${url}"`);
|
|
16
|
+
const sessionMatch = url.match(/\/app\/test-results\/[^/?]+\/([^/?]+)/);
|
|
17
|
+
const sessionId = sessionMatch ? sessionMatch[1] : null;
|
|
18
|
+
return { baseUrl: urlMatch[1], batchId: batchMatch[1], sessionId, accountId: accountIdMatch[1] };
|
|
19
|
+
}
|
|
20
|
+
/** Throws if the URL doesn't include a sessionId segment (i.e. it's a batch-only URL). */
|
|
21
|
+
export function parseSessionUrl(url) {
|
|
22
|
+
const parsed = parseApplitoolsUrl(url);
|
|
23
|
+
if (!parsed.sessionId) {
|
|
24
|
+
throw new Error('The input URL is a batch URL while a session URL is expected. ' +
|
|
25
|
+
'Use the eyes_fetch_visual_results MCP tool to obtain URLs of sessions in this batch.');
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
/** Batch-scoped: parses the batchId/accountId and always forces sessionId to null, ignoring any session segment. */
|
|
30
|
+
export function parseBatchUrl(url) {
|
|
31
|
+
const parsed = parseApplitoolsUrl(url);
|
|
32
|
+
return { ...parsed, sessionId: null };
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { cacheFilePath, defaultFsCacheDeps, compareStep, fetchStepDiff } from '../api/api.js';
|
|
3
|
+
import { getEffectiveMatchSettings } from '../sessions/match.js';
|
|
4
|
+
import { imageForSide } from '../sessions/regions.js';
|
|
5
|
+
export const defaultFetchRawDiffsDeps = {
|
|
6
|
+
fetchStepDiff,
|
|
7
|
+
compareStep,
|
|
8
|
+
readFile: defaultFsCacheDeps.readFile,
|
|
9
|
+
writeFile: defaultFsCacheDeps.writeFile,
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The two-way visual diff for one step — `image1` in the baseline image's own coordinate
|
|
13
|
+
* space, `image2` in the checkpoint's, exactly mirroring both the plain `GET .../diff`
|
|
14
|
+
* endpoint's and `compareStep`'s own response shape. When the step's resolution state is
|
|
15
|
+
* unchanged from the original test run (`appOutputResolution[stepIndex].isChanged` is not
|
|
16
|
+
* `true`), the cheap `GET .../diff` endpoint is used as-is — nothing about the step's
|
|
17
|
+
* effective match settings differs from what the test already ran with. Otherwise, the step's
|
|
18
|
+
* current effective regions are folded into a fresh `compareStep` request so the returned diff
|
|
19
|
+
* reflects any region added since the test ran — that (more expensive) result is cached on
|
|
20
|
+
* disk, keyed by a hash of the full request body, so repeated calls against the same resolved
|
|
21
|
+
* state don't repeatedly hit the endpoint.
|
|
22
|
+
*
|
|
23
|
+
* Takes an already-fetched `rawSession` rather than fetching its own — `batchId`/`sessionId`
|
|
24
|
+
* are read straight off it (`rawSession.batchId`/`rawSession.id`), so a caller that already has
|
|
25
|
+
* the raw session in hand (e.g. for its own domId lookups) never pays for a second fetch just
|
|
26
|
+
* to hand this function a `url` it would otherwise re-resolve into the same session itself.
|
|
27
|
+
*
|
|
28
|
+
* `matchSettings` comes straight from `getEffectiveMatchSettings` — the raw session's own
|
|
29
|
+
* scalars/regions, shaped directly into the request format, with no baseline/checkpoint
|
|
30
|
+
* pairing and no healing: that shaping only exists for diagnostic region output (which needs
|
|
31
|
+
* both sides shown, healing in whichever one the raw session doesn't encode), never for a
|
|
32
|
+
* compare request. Re-deriving "accepted ⇒ checkpoint, else baseline" here would duplicate
|
|
33
|
+
* logic the raw session's own `annotations` field already resolves — and healing in the other
|
|
34
|
+
* side would cost an extra network round trip per region for nothing a compare request needs.
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchRawDiffs(rawSession, stepIndex, baseUrl, accountId, keys, deps = defaultFetchRawDiffsDeps) {
|
|
37
|
+
const batchId = rawSession?.batchId;
|
|
38
|
+
const sessionId = rawSession?.id;
|
|
39
|
+
if (rawSession?.appOutputResolution?.[stepIndex]?.isChanged !== true) {
|
|
40
|
+
return deps.fetchStepDiff({ baseUrl, batchId, sessionId, stepIndex, accountId, keys });
|
|
41
|
+
}
|
|
42
|
+
const requestBody = {
|
|
43
|
+
matchSettings: getEffectiveMatchSettings(rawSession, stepIndex),
|
|
44
|
+
image1: imageForSide(rawSession, stepIndex, 'baseline'),
|
|
45
|
+
image2: imageForSide(rawSession, stepIndex, 'checkpoint'),
|
|
46
|
+
sessionId: rawSession.id,
|
|
47
|
+
batchId: rawSession.batchId,
|
|
48
|
+
runningSessionId: rawSession.runningSessionId,
|
|
49
|
+
stepIndex,
|
|
50
|
+
};
|
|
51
|
+
// SHA-256 (not SHA-1): this is only a content-addressed cache key, but the hashed body carries
|
|
52
|
+
// session identifiers, so a collision-prone digest is neither needed nor acceptable here.
|
|
53
|
+
const requestHash = crypto.createHash('sha256').update(JSON.stringify(requestBody)).digest('hex').slice(0, 16);
|
|
54
|
+
const cachePath = cacheFilePath(defaultFsCacheDeps, 'eyes-compare', requestHash);
|
|
55
|
+
const cached = deps.readFile(cachePath);
|
|
56
|
+
if (cached !== null) {
|
|
57
|
+
const parsed = JSON.parse(cached);
|
|
58
|
+
return { image1: parsed.image1 ?? [], image2: parsed.image2 ?? [] };
|
|
59
|
+
}
|
|
60
|
+
const result = await deps.compareStep(requestBody, { baseUrl, accountId, keys });
|
|
61
|
+
const rects = { image1: result.image1 ?? [], image2: result.image2 ?? [] };
|
|
62
|
+
deps.writeFile(cachePath, JSON.stringify(rects));
|
|
63
|
+
return rects;
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { findNodeBySelector } from '../sessions/dom.js';
|
|
2
|
+
import { resolveDomIdSide, imageForSide } from '../sessions/regions.js';
|
|
3
|
+
// findNodeBySelector/findNodeByRect return a nested {domSelector, rect} shape (DomNode);
|
|
4
|
+
// the Region shape needed elsewhere is flat (domSelector alongside left/top/width/height).
|
|
5
|
+
function toRegion(resolved) {
|
|
6
|
+
return { domSelector: resolved.domSelector, ...resolved.rect };
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Maps every `anchored` pair's `domSelector` to the other side via one batched
|
|
10
|
+
* get-compatible-selectors call, resolving each mapped selector's accurate rect locally
|
|
11
|
+
* against that side's DOM (the API only returns the mapped selector, never a rect). Falls
|
|
12
|
+
* back to treating every selector as unmapped if the batch call itself fails for some reason
|
|
13
|
+
* other than a bad input selector (`sessions`'s own `checkRegions` already filters those out).
|
|
14
|
+
* Mutates each pair's missing side in place. Returns whether anything was attempted (i.e.
|
|
15
|
+
* `anchored` was non-empty and both sides have an image to map between).
|
|
16
|
+
*
|
|
17
|
+
* Lives here, not in `atoms/sessions` (where the rest of the region-healing pipeline this composes
|
|
18
|
+
* into — `checkRegions`/`clearStaleSelectors`/`healNonAnchoredRegionsBySourceRect` — lives):
|
|
19
|
+
* this is the one step of that pipeline that actually calls the network (`getCompatibleSelectors`),
|
|
20
|
+
* and `atoms` must never depend on `api`/`common` (so it can't reference `ApiKeyProvider` or make
|
|
21
|
+
* network calls) — the orchestration belongs in `common`, which depends on both instead.
|
|
22
|
+
*/
|
|
23
|
+
export async function mapAnchoredRegionsBySelector(rawSession, stepIndex, anchored, deps) {
|
|
24
|
+
if (anchored.length === 0)
|
|
25
|
+
return false;
|
|
26
|
+
const sourceSide = resolveDomIdSide('unchanged', rawSession, stepIndex);
|
|
27
|
+
const missingSide = sourceSide === 'baseline' ? 'checkpoint' : 'baseline';
|
|
28
|
+
const imageSource = imageForSide(rawSession, stepIndex, sourceSide);
|
|
29
|
+
const imageMissing = imageForSide(rawSession, stepIndex, missingSide);
|
|
30
|
+
if (!imageSource || !imageMissing)
|
|
31
|
+
return false;
|
|
32
|
+
const domMissing = await deps.fetchDomForSide(rawSession, stepIndex, missingSide, deps.baseUrl).catch(() => null);
|
|
33
|
+
// `anchored` pairs are guaranteed a truthy domSelector by checkRegions's own definition of "anchored".
|
|
34
|
+
const selectors = anchored.map(p => (p.baseline ?? p.checkpoint).domSelector);
|
|
35
|
+
let mapped;
|
|
36
|
+
try {
|
|
37
|
+
mapped = await deps.getCompatibleSelectors({ baseUrl: deps.baseUrl, accountId: deps.accountId, keys: deps.keys }, imageSource, imageMissing, selectors);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
mapped = selectors.map(() => null); // the batch itself failed — treat every one as unmapped rather than throwing
|
|
41
|
+
}
|
|
42
|
+
anchored.forEach((pair, i) => {
|
|
43
|
+
const newSelector = mapped[i];
|
|
44
|
+
const resolved = newSelector && domMissing ? findNodeBySelector(domMissing, newSelector) : null;
|
|
45
|
+
pair[missingSide] = resolved
|
|
46
|
+
? toRegion(resolved)
|
|
47
|
+
: { ...(pair.baseline ?? pair.checkpoint), domSelector: newSelector ?? '' };
|
|
48
|
+
});
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export function parseRect(s) {
|
|
2
|
+
const parts = s.split(',').map(Number);
|
|
3
|
+
if (parts.length !== 4 || parts.some(isNaN)) {
|
|
4
|
+
throw new Error(`Invalid rect format: "${s}". Expected "left,top,width,height"`);
|
|
5
|
+
}
|
|
6
|
+
return { left: parts[0], top: parts[1], width: parts[2], height: parts[3] };
|
|
7
|
+
}
|
|
8
|
+
/** Inverse of `parseRect` — formats a rect back into `"left,top,width,height"`. */
|
|
9
|
+
export function formatRect(rect) {
|
|
10
|
+
return `${rect.left},${rect.top},${rect.width},${rect.height}`;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if outer completely contains inner (all edges of inner are within outer).
|
|
14
|
+
* Equal rectangles are considered to contain each other.
|
|
15
|
+
*/
|
|
16
|
+
export function rectContains(outer, inner) {
|
|
17
|
+
return (outer.left <= inner.left &&
|
|
18
|
+
outer.top <= inner.top &&
|
|
19
|
+
outer.left + outer.width >= inner.left + inner.width &&
|
|
20
|
+
outer.top + outer.height >= inner.top + inner.height);
|
|
21
|
+
}
|
|
22
|
+
export function rectsIntersect(a, b) {
|
|
23
|
+
return a.left < b.left + b.width && a.left + a.width > b.left && a.top < b.top + b.height && a.top + a.height > b.top;
|
|
24
|
+
}
|
|
25
|
+
export function intersectionArea(a, b) {
|
|
26
|
+
const w = Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left);
|
|
27
|
+
const h = Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top);
|
|
28
|
+
return w > 0 && h > 0 ? w * h : 0;
|
|
29
|
+
}
|
|
30
|
+
export function boundingRect(a, b) {
|
|
31
|
+
const left = Math.min(a.left, b.left);
|
|
32
|
+
const top = Math.min(a.top, b.top);
|
|
33
|
+
const right = Math.max(a.left + a.width, b.left + b.width);
|
|
34
|
+
const bottom = Math.max(a.top + a.height, b.top + b.height);
|
|
35
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Expands a rect by `padding` pixels on every side.
|
|
39
|
+
* When `imageWidth`/`imageHeight` are provided the result is clipped to the image bounds.
|
|
40
|
+
*/
|
|
41
|
+
export function padRect(rect, padding, imageWidth = Infinity, imageHeight = Infinity) {
|
|
42
|
+
const left = Math.max(0, rect.left - padding);
|
|
43
|
+
const top = Math.max(0, rect.top - padding);
|
|
44
|
+
const right = Math.min(imageWidth, rect.left + rect.width + padding);
|
|
45
|
+
const bottom = Math.min(imageHeight, rect.top + rect.height + padding);
|
|
46
|
+
return { left, top, width: right - left, height: bottom - top };
|
|
47
|
+
}
|
|
48
|
+
/** Merges pairs of rects that overlap by more than 10% of the smaller rect's area. */
|
|
49
|
+
export function unifyRects(rects) {
|
|
50
|
+
let result = [...rects];
|
|
51
|
+
let merged = true;
|
|
52
|
+
while (merged) {
|
|
53
|
+
merged = false;
|
|
54
|
+
outer: for (let i = 0; i < result.length; i++) {
|
|
55
|
+
for (let j = i + 1; j < result.length; j++) {
|
|
56
|
+
const overlap = intersectionArea(result[i], result[j]);
|
|
57
|
+
const smallerArea = Math.min(result[i].width * result[i].height, result[j].width * result[j].height);
|
|
58
|
+
if (overlap / smallerArea > 0.1) {
|
|
59
|
+
result = [
|
|
60
|
+
...result.slice(0, i),
|
|
61
|
+
...result.slice(i + 1, j),
|
|
62
|
+
...result.slice(j + 1),
|
|
63
|
+
boundingRect(result[i], result[j]),
|
|
64
|
+
];
|
|
65
|
+
merged = true;
|
|
66
|
+
break outer;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './lib/api-key-provider.js';
|
|
2
|
+
export * from './lib/env.js';
|
|
3
|
+
export * from './lib/phase-tracker.js';
|
|
4
|
+
export * from './geometry/geometry.js';
|
|
5
|
+
export * from './sessions/batch.js';
|
|
6
|
+
export * from './sessions/diffs.js';
|
|
7
|
+
export * from './sessions/dom-history.js';
|
|
8
|
+
export * from './sessions/dom-search.js';
|
|
9
|
+
export * from './sessions/dom.js';
|
|
10
|
+
export * from './sessions/match.js';
|
|
11
|
+
export * from './sessions/regions.js';
|
|
12
|
+
export * from './sessions/session.js';
|
|
13
|
+
export * from './api/api.js';
|
|
14
|
+
export * from './common/common.js';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps already-resolved key strings. A getter throws a clear error if its key wasn't supplied —
|
|
3
|
+
* e.g. a write key deliberately withheld when building a provider for a read-only call — rather
|
|
4
|
+
* than returning `undefined` and letting a missing credential fail confusingly further down.
|
|
5
|
+
*/
|
|
6
|
+
export class StaticApiKeyProvider {
|
|
7
|
+
keys;
|
|
8
|
+
constructor(keys) {
|
|
9
|
+
this.keys = keys;
|
|
10
|
+
}
|
|
11
|
+
getReadKey() {
|
|
12
|
+
return this.require('readKey', 'read');
|
|
13
|
+
}
|
|
14
|
+
getWriteKey() {
|
|
15
|
+
return this.require('writeKey', 'write');
|
|
16
|
+
}
|
|
17
|
+
getRunKey() {
|
|
18
|
+
return this.require('runKey', 'run');
|
|
19
|
+
}
|
|
20
|
+
require(field, label) {
|
|
21
|
+
const key = this.keys[field];
|
|
22
|
+
if (key === undefined)
|
|
23
|
+
throw new Error(`No ${label} API key is available for this call.`);
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as dotenv from 'dotenv';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
export const defaultLoadEnvDeps = {
|
|
5
|
+
existsSync: fs.existsSync,
|
|
6
|
+
configDotenv: dotenv.config,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Walks up from `startDir` (default: the current working directory) looking for a `.env`
|
|
10
|
+
* file, loading the first one found via `dotenv`. Mirrors the behavior every skill's own
|
|
11
|
+
* `main.ts` used to inline directly.
|
|
12
|
+
*/
|
|
13
|
+
export function loadEnvFromCwd(startDir = process.cwd(), deps = defaultLoadEnvDeps) {
|
|
14
|
+
let dir = startDir;
|
|
15
|
+
while (true) {
|
|
16
|
+
const envPath = path.join(dir, '.env');
|
|
17
|
+
if (deps.existsSync(envPath)) {
|
|
18
|
+
deps.configDotenv({ path: envPath });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const parent = path.dirname(dir);
|
|
22
|
+
if (parent === dir)
|
|
23
|
+
return;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|