@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.0](https://github.com/Applitools-Dev/sdk/compare/js/eyes-results@0.1.0...js/eyes-results@0.2.0) (2026-08-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* new SDK package to query eyes results | AD-15678 ([#4103](https://github.com/Applitools-Dev/sdk/issues/4103)) ([0f97e26](https://github.com/Applitools-Dev/sdk/commit/0f97e26c1be602a7b1dc9db158077c4cd6382c17))
|
package/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
- -
|
|
2
|
+
SDK LICENSE AGREEMENT
|
|
3
|
+
IMPORTANT - PLEASE READ CAREFULLY THE TERMS OF THIS LICENSE AGREEMENT (“AGREEMENT”). BY INSTALLING, ACCESSING AND/OR USING THE SOFTWARE (AS DEFINED BELOW), YOU EXPRESSLY ACKNOWLEDGE AND AGREE THAT YOU, OR THE COMPANY YOU REPRESENT, (“YOU” OR “LICENSEE”) ARE ENTERING INTO A LEGAL AGREEMENT WITH APPLITOOLS LTD. AND ITS AFFILIATES (“APPLITOOLS”), AND HAVE UNDERSTOOD AND AGREE TO COMPLY WITH, AND BE LEGALLY BOUND BY, THE TERMS AND CONDITIONS OF THIS AGREEMENT. YOU HEREBY WAIVE ANY RIGHTS OR REQUIREMENTS UNDER ANY LAWS OR REGULATIONS IN ANY JURISDICTION WHICH REQUIRE AN ORIGINAL (NON-ELECTRONIC) SIGNATURE OR DELIVERY OR RETENTION OF NON-ELECTRONIC RECORDS, TO THE EXTENT PERMITTED UNDER APPLICABLE LAW.
|
|
4
|
+
THE SOFTWARE MAY BE USED SOLELY FOR YOUR PERSONAL, NON-COMMERCIAL PURPOSES. FOR COMMERCIAL PURPOSES PLEASE CONTACT THE REGIONAL APPLITOOLS BUSINESS REPRESENTATIVE.
|
|
5
|
+
1. Definitions. For purposes of this Agreement, the following capitalized terms shall have the following meaning:
|
|
6
|
+
1. “Documentation” means the user’s guides and technical manuals delivered by Applitools to Licensee.
|
|
7
|
+
2. “Feedback” means suggestions, comments or feedback (whether orally or in writing) with respect to the Software.
|
|
8
|
+
3. “Intellectual Property Rights” means all intangible legal rights, titles and interests evidenced by or embodied in all: (i) inventions (regardless of patentability and whether or not reduced to practice), improvements thereto, patents, patent applications, patent disclosures, together with all reissuances, continuations, continuations in part, revisions, extensions and reexaminations thereof; (ii) trademarks, service marks, trade dress, logos, trade names, corporate names, together with translations, adaptations, derivations and combinations thereof, including goodwill associated therewith, and applications, registrations, and renewals in connection therewith; (iii) any work of authorship, regardless of copyrightable, copyrightable works, copyrights (including moral rights), and applications, registrations and renewals in connection therewith; (iv) mask works and applications, registrations and renewals in connection therewith; (v) trade secrets and Confidential Information; and (vi) other proprietary rights and any other similar rights, in each case on a worldwide basis, and copies and tangible embodiments thereof, in whatever form or medium.
|
|
9
|
+
4. “License” means the right to use the Software pursuant to Section 2.1 to this Agreement.
|
|
10
|
+
5. “Software” means Applitools’ software development kit software in object or source code version, Documentation and any updates and upgrade thereto (to the extent delivered).
|
|
11
|
+
1. License
|
|
12
|
+
1. Grant of License. Subject to the terms and conditions of this Agreement, Applitools grants You, during the Term, a personal, non-exclusive, non-sublicensable, non-transferable, revocable license to: (i) use the Software solely for Your own personal (non-commercial) use and (ii) use and display Applitools’ Marks solely for the purpose of publicizing or advertising that You are using the Software.
|
|
13
|
+
2. Documentation. Applitools may make available Documentation to Licensee for Licensee to use solely in connection with Licensee’s use of the Software during the term of this Agreement. Licensee may print or copy the Documentation as needed for its own purposes provided that all copyright notices are included therein. The Documentation shall be considered the Confidential Information of Applitools.
|
|
14
|
+
1. Reservation of Rights; Use Restrictions. Other than the rights explicitly granted in this Agreement, Licensee shall have no other rights, express or implied, in the Software. Without limiting the generality of the foregoing, Licensee agrees and undertakes not to: (i) allow any third party to use the Software in any manner, including but not limited to, sell, lease, sublicense or distribute the Software, or any part thereof; (ii) modify, revise, or alter the Software or reverse engineer, decompile, disassemble or otherwise reduce to human-perceivable form the Software’s source code; (iii) copy or allow copies of the Software to be made; (iv) remove, alter or obscure any proprietary notice or identification, including copyright, trademark, patent or other notices, contained in or displayed on or via the Software; (v) use the Software to violate any applicable laws, rules or regulations, or for any unlawful, harmful, irresponsible, or inappropriate purpose, or in any manner that breaches this Agreement, and/or (vi) represent that it possesses any proprietary interest in the Software.
|
|
15
|
+
1. Third Party Software. Licensee acknowledges and agrees that any third party software (“Third Party Software”) that provided with the Software is provided under the terms of the license attached/linked thereto or, if no such license is attached, such Third Party Software is provided for free and on “AS IS” basis. Applitools is not liable for any losses or damages which may occur resulting from the use of any Third Party Software. Applitools does not possess any proprietary interest in such Third Party Software.
|
|
16
|
+
1. Open Source Licenses. The Software includes certain open source code software and materials (as shall be listed in the documentation of the Software) (“Open Source Software”) that are subject to their respective open source licenses (“Open Source Licenses”). Such Open Source Licenses contain a list of conditions with respect to warranty, copyright policy and other provisions. By executing this Agreement, Licensee undertakes to strictly comply with the terms and condition of the Open Source Licenses, as may be amended from time to time. In order to comply with the Open Source Licenses, Licensee shall read the respective licenses or notices, such list of Open Source Licenses may be amended from time to time by Applitools, at its sole discretion. In the event of any inconsistencies or conflicting provisions between the provisions of the Open Source Licenses and the provisions of this Agreement, the provisions of the Open Source Licenses shall prevail. Without derogating from the generality of the foregoing, it is clarified that any Open Source Software is provided on an “AS IS” basis, without indemnity or warranty of any kind, whether express or implied. For clarity, the representations and warranties set forth in Section 4 hereunder shall not apply to any Open Source Software.
|
|
17
|
+
1. Title & Ownership. APPLITOOLS DOES NOT SELL OR TRANSFER TITLE IN THE SOFTWARE, OR ANY PART THEREOF, TO LICENSEE. The Documentation, Software (excluding any Open Source Software and Third Party Software therein which are owned by their respective licensors) and/or any copies thereof, including without limitation any derivative works made (regardless of whether such derivative works were made and/or developed pursuant to the request and/or specifications of Licensee, and irrespective of any support and/or assistance Applitools may, will or had received from Licensee, or any third party on its behalf, with respect thereto), as well as any updates or upgrades thereto, if provided to Applitools pursuant to this Agreement, shall remain Applitools’ sole and exclusive property. All Intellectual Property Rights evidenced by or embodied in and/or attached/connected/related to the Software, or part thereof, are and shall be owned solely and exclusively by Applitools. Nothing in this Agreement shall constitute a waiver of Applitools’ Intellectual Property Rights under any law, or be in any way construed or interpreted as such. It is further agreed that to the extent Licensee provides Applitools with Feedback, Licensee acknowledges that any and all rights, including Intellectual Property Rights in such Feedback shall belong exclusively to Applitools and Licensee hereby irrevocably and unconditionally transfers and assigns to Applitools all intellectual property rights in such Feedback and waives any and all moral rights that Licensee may have in respect thereto. It is further understood that use of Feedback, if any, may be made by Applitools at its sole discretion, and that Applitools in no way shall be obliged to make use of any kind of the Feedback or part thereof.
|
|
18
|
+
1. Warranty. Applitools warrants that to its knowledge it has the right to grant the license under this Agreement. Applitools’ sole liability for any breach of this warranty or any other warranty under this Agreement shall be, at Applitools’ sole discretion: (i) to replace or repair the Software or the applicable portion thereof; or (ii) to terminate this Agreement.
|
|
19
|
+
2. Warranty Exclusions. The warranties set forth in Section 4 are contingent upon Licensee’s proper use of the Software, and shall not apply to damage caused by abuse, misuse, alteration, neglect or unauthorized repair or installation, or by the use or attempted use of Software other than that supplied and supported by Applitools. Applitools will use reasonable commercial efforts to repair or replace the Software or the applicable portion thereof, pursuant to the foregoing warranty within thirty (30) days of being so notified.
|
|
20
|
+
1. Warranty Disclaimers. AS BETWEEN LICENSEE AND APPLITOOLS, EXCEPT AS SET IN SECTION 4, THE SOFTWARE IS PROVIDED ON AN “AS IS” AND “AS AVAILABLE” BASIS WITHOUT WARRANTIES OF ANY KIND INCLUDING, WITHOUT LIMITATION, REPRESENTATIONS, WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR INTENDED OR PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, THAT THE SOFTWARE WILL MEET LICENSEE’S REQUIREMENTS OR EXPECTATIONS OR WILL ACHIEVE ANY SPECIFIC RESULTS AND THOSE ARISING BY STATUTE OR FROM A COURSE OF DEALING OR USAGE OF TRADE. Applicable law may not allow the exclusion of certain warranties, so to that extent such exclusions may not apply.
|
|
21
|
+
1. High Risk Activities. You acknowledge that the Software is not fault tolerant and is not designed, manufactured, or intended for use or resale as on-line control equipment in hazardous or high risk environments and activities requiring fail-safe performance (such as in the operation of nuclear facilities, aircraft navigation or communication systems, air traffic control, direct life support machines and/or devices, or weapons systems) in which the failure of the Software could lead directly to death, personal injury, or severe physical or environmental damage, and You agree not to use or allow the use of the Software or any portion thereof for, or in connection with, any such environment or activity.
|
|
22
|
+
1. Indemnify. You agree that Applitools shall have no liability whatsoever for any use made of the Software by You or any third party. You hereby agree to defend, indemnify and hold harmless Applitools and its affiliates and their respective officers, directors, agents and employees from any and all claims, damages, liabilities, costs, and expenses (including attorney’s fees) arising from claims related to Your use of the Software as well as from Your failure to comply with this Agreement.
|
|
23
|
+
1. Limitation of Liability. UNDER NO CIRCUMSTANCES SHALL APPLITOOLS AND/OR ITS AFFILIATES BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL, PUNITIVE OR CONSEQUENTIAL DAMAGES, OR FOR ANY LOSS OF DATA, REVENUE, BUSINESS OR REPUTATION, THAT ARISES UNDER OR IN CONNECTION WITH THIS AGREEMENT, OR THAT RESULTS FROM THE USE OF, OR THE INABILITY TO USE, THE SOFTWARE. APPLITOOLS’S TOTAL AGGREGATE LIABILITY FOR ANY AND ALL DIRECT DAMAGES AND LOSSES THAT ARISE UNDER OR IN CONNECTION WITH THIS AGREEMENT SHALL NOT IN ANY CIRCUMSTANCE EXCEED THE AMOUNT OF 100.00 (ONE HUNDRED) UNITED STATES DOLLARS. THE FOREGOING LIMITATIONS AND EXCLUSIONS IN THIS SECTION 7 SHALL APPLY: (I) EVEN IF APPLITOOLS HAS BEEN ADVISED OF THE POSSIBILITY OF ANY DAMAGES OR LOSSES; (II) EVEN IF ANY REMEDY SET FORTH HEREIN FAILS OF ITS ESSENTIAL PURPOSE; AND (III) REGARDLESS OF THE BASIS OR THEORY OF LIABILITY.
|
|
24
|
+
1. Export Laws. Licensee agrees to comply fully with all U.S., EU, Israeli, and all applicable export laws and regulations to ensure that neither the Software nor any technical data related thereto are exported or re-exported directly or indirectly in violation of, or used for any purposes prohibited by, such laws and regulations. For clarity, and without derogating from Section 11 below, in case of any change of any applicable law, policy or regulation, which might affect Applitools’ business, Applitools will have the right to terminate this Agreement and the license granted hereunder and the Licensee shall have no claims regarding such termination.
|
|
25
|
+
1. Term and Termination. This Agreement shall continue until terminated as set forth in this section (the “Term”). You may terminate this Agreement at any time by removing the Software from Your system and destroying all copies of the Software and Documentation relating to the Software. Unauthorized copying of the Software or otherwise failing to comply with this Agreement will result in automatic immediate termination of this Agreement and will make available to Applitools legal remedies. Applitools reserves the right to terminate this Agreement and the License at any time and without notice. Upon termination of this Agreement, the License will terminate and You: (i) will cease any and all rights to use the Software, and (ii) will remove the Software from all hard drives, networks and other storage media and destroy all copies of the Software in your possession or under your control. The provisions of Sections 2.3, 2.4, 2.5, 6, 7, 8, 9, 10, 11 and 12 shall survive the termination, expiration or other ending of this Agreement.
|
|
26
|
+
1. Miscellaneous. This Agreement represents the complete agreement concerning the Software between You and Applitools and supersedes all prior agreements and representations between You and Applitools. If any provision of this Agreement is held to be unenforceable for any reason, such provision shall be reformed only to the extent necessary to make it enforceable. Any waiver of any provision of this Agreement will be effective only if in writing and signed by Applitools. This Agreement is personal to You and may not be assigned or transferred for any reason whatsoever without the consent of Applitools and any action or conduct in violation of the foregoing shall be void and without effect. Applitools expressly reserves the right to assign this Agreement and to delegate any of its obligations hereunder. This Agreement are governed by and construed under the laws of the State of Israel, excluding its conflicts of law rules. You expressly agree that the exclusive jurisdiction for any claim or action arising out of or relating to this Agreement shall be the courts located in Tel Aviv, Israel, and You further agree and submit to the exercise of personal jurisdiction of such courts for the purpose of litigating any such claim or action. In any action or proceeding to enforce rights under this Agreement, the prevailing party shall be entitled to recover costs and attorneys’ fees.
|
package/dist/api/api.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from './url-parsing.js';
|
|
2
|
+
export * from './polling.js';
|
|
3
|
+
export * from './req-results.js';
|
|
4
|
+
export * from './tasks.js';
|
|
5
|
+
export * from './fs-cache.js';
|
|
6
|
+
export * from './gzip.js';
|
|
7
|
+
export * from './dom-fetch.js';
|
|
8
|
+
export * from './dom-diff.js';
|
|
9
|
+
export * from './image-fetch.js';
|
|
10
|
+
export * from './session-fetch.js';
|
|
11
|
+
export * from './selector-mapping.js';
|
|
12
|
+
export * from './diff-fetch.js';
|
|
13
|
+
export * from './session-updates.js';
|
|
14
|
+
export * from './find-regions.js';
|
|
15
|
+
export * from './session-history-fetch.js';
|
|
16
|
+
export * from './compare-step.js';
|
|
17
|
+
export * from './same-diff-as.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { postJson, getJsonResult, reqTransport } from './polling.js';
|
|
2
|
+
/**
|
|
3
|
+
* Recomputes a step's visual diff against arbitrary match settings/regions — the same
|
|
4
|
+
* comparison the dashboard's own region editor uses for a live preview, confirmed live to
|
|
5
|
+
* accept both a `{domSelector}` region (resolved against both images' own DOM, since
|
|
6
|
+
* `useDom`/`hasDom` are true) and a plain `{left,top,width,height}` region. `image1`/`image2`
|
|
7
|
+
* in the response are a genuine two-way diff — rects in each image's own coordinate space,
|
|
8
|
+
* not a single shared list — mirroring the request's own `image1`/`image2` sides.
|
|
9
|
+
*
|
|
10
|
+
* Confirmed live to be sometimes synchronous (200) and sometimes an async job (202 +
|
|
11
|
+
* `Location`) depending on whether the exact payload was already computed — `getJsonResult`
|
|
12
|
+
* handles both transparently.
|
|
13
|
+
*/
|
|
14
|
+
export async function compareStep(body, options, fetchImpl = reqTransport) {
|
|
15
|
+
const { baseUrl, accountId, keys } = options;
|
|
16
|
+
const apiKey = keys.getReadKey();
|
|
17
|
+
const url = `${baseUrl}/api/images/compare?accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
18
|
+
const response = await postJson(url, body, fetchImpl);
|
|
19
|
+
return getJsonResult(response, apiKey, fetchImpl, undefined, url);
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getJson, reqTransport } from './polling.js';
|
|
2
|
+
/**
|
|
3
|
+
* The cheap, already-computed two-way diff for one step, straight off the dashboard's own
|
|
4
|
+
* `GET .../diff` endpoint — used whenever nothing about the step's effective match settings
|
|
5
|
+
* differs from what the test actually ran with (see `eyes-inspect`'s `fetchDiffRects`, this
|
|
6
|
+
* function's only caller, for when the more expensive `compareStep` path is needed instead).
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchStepDiff(options, fetchImpl = reqTransport) {
|
|
9
|
+
const { baseUrl, batchId, sessionId, stepIndex, accountId, keys } = options;
|
|
10
|
+
const url = `${baseUrl}/api/sessions/batches/${batchId}/${sessionId}/${stepIndex}/diff?accountId=${accountId}&apiKey=${encodeURIComponent(keys.getReadKey())}`;
|
|
11
|
+
const data = await getJson(url, fetchImpl);
|
|
12
|
+
return { image1: data.image1 ?? [], image2: data.image2 ?? [] };
|
|
13
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import { defaultFsCacheDeps, cacheFilePath } from './fs-cache.js';
|
|
3
|
+
import { makeReqResults } from './req-results.js';
|
|
4
|
+
import { getTaskStatus, taskIdFromLocation, TASK_RESULT_PATH } from './tasks.js';
|
|
5
|
+
export const DOM_DIFF_PATH = '/api/images/dom/diff';
|
|
6
|
+
export const defaultFetchDomDiffDeps = {
|
|
7
|
+
...defaultFsCacheDeps,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Fetches (or reads from cache) the alignment of two DOM snapshots, computed server-side. The
|
|
11
|
+
* request carries the two domIds alone — the server resolves them against its own snapshot
|
|
12
|
+
* storage, so no DOM travels either way — and a ready result is the compact `{matches, diff}`
|
|
13
|
+
* alignment: `matches` is what lets a caller extract one node's own diff afterwards
|
|
14
|
+
* (`sessions/dom`'s `extractNodeDiff`) without paying for a second alignment of the same pair.
|
|
15
|
+
* Cropping to an area (`filterDiffToArea`), excluding non-visual entries
|
|
16
|
+
* (`excludeNonVisualNodeDiffs`) and serializing (`serializeDiff`) all stay with the caller.
|
|
17
|
+
*
|
|
18
|
+
* No client-side polling: a 202 submit is probed once via `getTaskStatus`; if the task is still
|
|
19
|
+
* running, the taskId is returned and the caller decides when to check again
|
|
20
|
+
* (`checkDomDiffTask`). A direct 200 with the alignment body also parses — forward tolerance for
|
|
21
|
+
* a server answering an already-computed pair synchronously; the current backend always answers
|
|
22
|
+
* 202 (its async-task framework has no synchronous arm).
|
|
23
|
+
*
|
|
24
|
+
* Content-addressed cache, keyed on the two domIds alone — both are already immutable, unlike an
|
|
25
|
+
* area or a step's match settings, so one ready alignment is reused across every caller of that
|
|
26
|
+
* pair. A `running` outcome is never cached.
|
|
27
|
+
*
|
|
28
|
+
* Every alignment is shape-checked at this boundary before it is returned or cached — see
|
|
29
|
+
* `assertDomAlignment`.
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchDomDiff(baseUrl, baselineDomId, checkpointDomId, keys, deps = defaultFetchDomDiffDeps) {
|
|
32
|
+
const cachePath = domDiffCachePath(baselineDomId, checkpointDomId, deps);
|
|
33
|
+
const cached = readCachedAlignment(cachePath, deps);
|
|
34
|
+
if (cached)
|
|
35
|
+
return { status: 'ready', alignment: cached };
|
|
36
|
+
const req = makeReqResults({ serverUrl: baseUrl, apiKey: keys.getReadKey(), ...deps });
|
|
37
|
+
const response = await req(DOM_DIFF_PATH, {
|
|
38
|
+
name: 'fetchDomDiff',
|
|
39
|
+
query: { baselineDomId, checkpointDomId },
|
|
40
|
+
expected: [200, 202],
|
|
41
|
+
});
|
|
42
|
+
if (response.status === 202) {
|
|
43
|
+
const location = response.headers.get('Location');
|
|
44
|
+
if (!location)
|
|
45
|
+
throw new Error('DOM diff task started (202) but no status Location header was returned');
|
|
46
|
+
const taskId = taskIdFromLocation(location);
|
|
47
|
+
const task = await getTaskStatus(baseUrl, taskId, keys, deps);
|
|
48
|
+
return cacheIfReady(task, taskId, cachePath, deps);
|
|
49
|
+
}
|
|
50
|
+
const alignment = assertDomAlignment(await response.json(), DOM_DIFF_PATH, response.status);
|
|
51
|
+
deps.writeFile(cachePath, JSON.stringify(alignment));
|
|
52
|
+
return { status: 'ready', alignment };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The caller's re-check for a `running` `fetchDomDiff` — one `getTaskStatus` probe, with a ready
|
|
56
|
+
* alignment landing in the same domId-pair cache a direct fetch would have written, so later
|
|
57
|
+
* calls for the pair stay cache hits regardless of which path produced the alignment.
|
|
58
|
+
*
|
|
59
|
+
* Cache-first, like `fetchDomDiff`: the pair may already be ready on disk (an earlier probe, or
|
|
60
|
+
* any other caller of the same pair, landed it), and a cached pair must win even when the taskId
|
|
61
|
+
* in hand has since expired — so a re-check with a stale taskId never fails on a result that's
|
|
62
|
+
* already here.
|
|
63
|
+
*/
|
|
64
|
+
export async function checkDomDiffTask(baseUrl, taskId, baselineDomId, checkpointDomId, keys, deps = defaultFetchDomDiffDeps) {
|
|
65
|
+
const cachePath = domDiffCachePath(baselineDomId, checkpointDomId, deps);
|
|
66
|
+
const cached = readCachedAlignment(cachePath, deps);
|
|
67
|
+
if (cached)
|
|
68
|
+
return { status: 'ready', alignment: cached };
|
|
69
|
+
const task = await getTaskStatus(baseUrl, taskId, keys, deps);
|
|
70
|
+
return cacheIfReady(task, taskId, cachePath, deps);
|
|
71
|
+
}
|
|
72
|
+
function domDiffCachePath(baselineDomId, checkpointDomId, deps) {
|
|
73
|
+
const cacheKey = crypto.createHash('sha256').update(`${baselineDomId}:${checkpointDomId}`).digest('hex').slice(0, 16);
|
|
74
|
+
return cacheFilePath(deps, 'eyes-dom-diff', cacheKey, 'json');
|
|
75
|
+
}
|
|
76
|
+
function cacheIfReady(task, taskId, cachePath, deps) {
|
|
77
|
+
if (task.status === 'running')
|
|
78
|
+
return task;
|
|
79
|
+
const alignment = assertDomAlignment(task.result, TASK_RESULT_PATH(taskId), 200);
|
|
80
|
+
deps.writeFile(cachePath, JSON.stringify(alignment));
|
|
81
|
+
return { status: 'ready', alignment };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reads a cached alignment, treating an unusable entry (unparseable, or not alignment-shaped) as a
|
|
85
|
+
* miss rather than a failure: the cache is content-addressed and reproducible, so re-fetching the
|
|
86
|
+
* pair and overwriting is always safe — whereas the alternative is a truncated or old-shape file
|
|
87
|
+
* (an interrupted write, a pre-`assertDomAlignment` cache) failing every future call for that pair
|
|
88
|
+
* forever, since the entry has no TTL and nothing else would ever replace it.
|
|
89
|
+
*/
|
|
90
|
+
function readCachedAlignment(cachePath, deps) {
|
|
91
|
+
const cached = deps.readFile(cachePath);
|
|
92
|
+
if (cached === null)
|
|
93
|
+
return null;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(cached);
|
|
96
|
+
return isDomAlignment(parsed) ? parsed : null;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function isDomAlignment(value) {
|
|
103
|
+
const candidate = value;
|
|
104
|
+
return (typeof candidate === 'object' &&
|
|
105
|
+
candidate !== null &&
|
|
106
|
+
typeof candidate.matches === 'object' &&
|
|
107
|
+
candidate.matches !== null &&
|
|
108
|
+
// An array is a `typeof 'object'` too, and `matches` is looked up by nodeId — every lookup in an
|
|
109
|
+
// array misses, so `{matches: [], diff: []}` would cache as a permanently valid-looking
|
|
110
|
+
// alignment that answers `found: false` for every node instead of failing here.
|
|
111
|
+
!Array.isArray(candidate.matches) &&
|
|
112
|
+
Array.isArray(candidate.diff));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Guards the one cast that asserts the wire shape. The backend's aligner is a byte-identical port
|
|
116
|
+
* of the client engine this package used to carry, and these types were verified field-for-field
|
|
117
|
+
* against its own fixture suite, so a healthy server can't produce anything else — but the pair
|
|
118
|
+
* cache has no TTL, so a single `null`, `{}` or old-shape 200 that got through would be persisted
|
|
119
|
+
* and then returned as a ready alignment for good, not just for this run. Worse, it surfaces far
|
|
120
|
+
* from here: `extractNodeDiff` opens with `targetNodeId in alignment.matches`, so a bad value
|
|
121
|
+
* becomes `TypeError: Cannot use 'in' operator` inside `sessions/dom.ts` with nothing naming the
|
|
122
|
+
* endpoint that produced it. Checking the two fields every consumer reads keeps such a response
|
|
123
|
+
* out of the cache and names the endpoint and status in the failure instead.
|
|
124
|
+
*/
|
|
125
|
+
function assertDomAlignment(value, endpoint, status) {
|
|
126
|
+
if (!isDomAlignment(value)) {
|
|
127
|
+
throw new Error(`${endpoint} (${status}) did not return a DOM alignment — expected an object with ` +
|
|
128
|
+
`\`matches\` and \`diff\`, got ${preview(value)}`);
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
/** A bounded rendering of an unexpected body — enough to recognize it in a log, never the whole
|
|
133
|
+
* response (an old-shape alignment would be megabytes). */
|
|
134
|
+
function preview(value) {
|
|
135
|
+
if (value === undefined)
|
|
136
|
+
return 'undefined';
|
|
137
|
+
const json = JSON.stringify(value) ?? String(value);
|
|
138
|
+
return json.length > 200 ? `${json.slice(0, 200)}…` : json;
|
|
139
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { defaultFsCacheDeps, cacheFilePath } from './fs-cache.js';
|
|
3
|
+
import { gunzip } from './gzip.js';
|
|
4
|
+
import { makeReqResults } from './req-results.js';
|
|
5
|
+
export const PRUNED_DOM_PATH = (domId) => `/api/images/dom/${domId}/pruned`;
|
|
6
|
+
export const defaultFetchPrunedDomDeps = {
|
|
7
|
+
...defaultFsCacheDeps,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Fetches (or reads from cache) an Applitools DOM snapshot already pruned server-side: URLs
|
|
11
|
+
* normalized, node ids assigned, metadata fields and whitespace-only text nodes gone. Same
|
|
12
|
+
* `eyes-<domId>` cache entry a client-side pruner writes, so entries stay valid either way.
|
|
13
|
+
* Takes no `area` — cropping is a separate, uncached step (`sessions/dom`'s `getDomArea`).
|
|
14
|
+
*
|
|
15
|
+
* The 200 body may be a gzip payload (a gzip *body*, not `Content-Encoding` — which the
|
|
16
|
+
* transport already undoes transparently), so it's gunzipped when it is one and parsed as-is
|
|
17
|
+
* when it isn't. Any non-200 throws via the configured req's expected-status hook, carrying the
|
|
18
|
+
* request name, status, and response body.
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchPrunedDom(options, deps = defaultFetchPrunedDomDeps) {
|
|
21
|
+
const { baseUrl, domId, keys } = options;
|
|
22
|
+
const cachePath = cacheFilePath(deps, 'eyes', domId, 'json');
|
|
23
|
+
const cached = deps.readFile(cachePath);
|
|
24
|
+
if (cached !== null)
|
|
25
|
+
return JSON.parse(cached);
|
|
26
|
+
const req = makeReqResults({ serverUrl: baseUrl, apiKey: keys.getReadKey(), ...deps });
|
|
27
|
+
const response = await req(PRUNED_DOM_PATH(domId), { name: 'fetchPrunedDom', expected: 200 });
|
|
28
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
29
|
+
let text;
|
|
30
|
+
try {
|
|
31
|
+
text = (await gunzip(buffer)).toString('utf8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
text = buffer.toString('utf8'); // not a gzip body — already plain (or transport-decompressed)
|
|
35
|
+
}
|
|
36
|
+
const prunedDom = JSON.parse(text);
|
|
37
|
+
deps.writeFile(cachePath, JSON.stringify(prunedDom));
|
|
38
|
+
return prunedDom;
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { postJson, getJsonResult, reqTransport } from './polling.js';
|
|
2
|
+
/**
|
|
3
|
+
* Searches every one of `targets` for each of `regions` — regions defined against
|
|
4
|
+
* `sourceImage`'s own DOM or, since `domSelector` is optional, its raw rectangle — and returns
|
|
5
|
+
* a map keyed by each target's `ImageRecord.id`. A target with no match at all is still present
|
|
6
|
+
* in the map, with an empty array (its key is never omitted). When several regions are searched
|
|
7
|
+
* at once, a target's array holds only the ones actually found there — there is no positional
|
|
8
|
+
* correspondence to `regions`, so a caller distinguishes which one matched by comparing
|
|
9
|
+
* `domSelector` back against the region it sent (both confirmed live: a single-region miss
|
|
10
|
+
* comes back as `[]`; a two-region call with one hit and one miss comes back with exactly one
|
|
11
|
+
* entry, the hit).
|
|
12
|
+
*
|
|
13
|
+
* Confirmed live to be an async job: the initial POST returns 202 with a `Location` header
|
|
14
|
+
* to a status URL; polling that returns 201, but the result itself lives at a *second*,
|
|
15
|
+
* separate URL given by that 201 response's own `Location` header — `getJsonResult`/
|
|
16
|
+
* `pollUntilResult` (`./polling.js`) handle this double-redirect.
|
|
17
|
+
*/
|
|
18
|
+
export async function findRegionsInImages(sourceImage, regions, targets, options, fetchImpl = reqTransport, sleepFn) {
|
|
19
|
+
const { baseUrl, accountId, keys } = options;
|
|
20
|
+
const apiKey = keys.getReadKey();
|
|
21
|
+
const url = `${baseUrl}/api/images/find?firstOnly=false&accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
22
|
+
const response = await postJson(url, { sourceImage, regions, targets }, fetchImpl);
|
|
23
|
+
return getJsonResult(response, apiKey, fetchImpl, sleepFn, url);
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
export const defaultFsCacheDeps = {
|
|
5
|
+
readFile: p => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null),
|
|
6
|
+
writeFile: (p, contents) => fs.writeFileSync(p, contents),
|
|
7
|
+
cacheDir: os.tmpdir(),
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Builds a content-addressed cache file path, sanitizing `key` the same way the
|
|
11
|
+
* repo's existing cache-file naming already does (only `~`/`,` need escaping —
|
|
12
|
+
* these characters can appear in Applitools image/dom ids and are not filesystem-safe).
|
|
13
|
+
*/
|
|
14
|
+
export function cacheFilePath(deps, prefix, key, ext = 'json') {
|
|
15
|
+
const safeKey = key.replace(/[~,]/g, '_');
|
|
16
|
+
// path.posix.join (not path.join) so the result is deterministic across platforms —
|
|
17
|
+
// Node's fs APIs accept forward slashes on Windows too.
|
|
18
|
+
return path.posix.join(deps.cacheDir, `${prefix}-${safeKey}.${ext}`);
|
|
19
|
+
}
|
package/dist/api/gzip.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as zlib from 'zlib';
|
|
2
|
+
/** Promisified zlib.gunzip. Rejects if the input isn't valid gzip data — callers decide the fallback. */
|
|
3
|
+
export function gunzip(buffer) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
zlib.gunzip(buffer, (err, result) => {
|
|
6
|
+
if (err)
|
|
7
|
+
reject(err);
|
|
8
|
+
else
|
|
9
|
+
resolve(result);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { reqTransport } from './polling.js';
|
|
3
|
+
/**
|
|
4
|
+
* A step's checkpoint screenshot, as a raw PNG buffer — a binary response, so this calls `fetch`
|
|
5
|
+
* directly rather than through `postJson`/`getJson` (which assume JSON), the one documented
|
|
6
|
+
* exception among `api/`'s REST wrappers.
|
|
7
|
+
*/
|
|
8
|
+
export async function fetchCheckpointImage(options, fetchImpl = reqTransport) {
|
|
9
|
+
const { baseUrl, batchId, sessionId, stepIndex, accountId, keys, area } = options;
|
|
10
|
+
const apiStepIndex = stepIndex + 1; // images/checkpoint uses 1-based step index (matches UI URL /steps/N convention)
|
|
11
|
+
const path = area
|
|
12
|
+
? `/api/sessions/batches/${batchId}/${sessionId}/steps/${apiStepIndex}/images/checkpoint.clip(${area.left};${area.top};${area.width};${area.height})`
|
|
13
|
+
: `/api/sessions/batches/${batchId}/${sessionId}/steps/${apiStepIndex}/images/checkpoint`;
|
|
14
|
+
const url = `${baseUrl}${path}?accountId=${accountId}&apiKey=${encodeURIComponent(keys.getReadKey())}`;
|
|
15
|
+
const response = await fetchImpl(url);
|
|
16
|
+
if (!response.ok)
|
|
17
|
+
throw new Error(`Failed to fetch checkpoint image: ${response.status} ${response.statusText}`);
|
|
18
|
+
return Buffer.from(await response.arrayBuffer());
|
|
19
|
+
}
|
|
20
|
+
/** A baseline or checkpoint screenshot by its own image id, as a raw PNG buffer — same binary-response exception as `fetchCheckpointImage`. */
|
|
21
|
+
export async function fetchImageById(options, fetchImpl = reqTransport) {
|
|
22
|
+
const { baseUrl, imageId, keys, area } = options;
|
|
23
|
+
let path = `/api/images/${imageId}`;
|
|
24
|
+
if (area)
|
|
25
|
+
path += `.clip(${area.left};${area.top};${area.width};${area.height})`;
|
|
26
|
+
const url = `${baseUrl}${path}?apiKey=${encodeURIComponent(keys.getReadKey())}`;
|
|
27
|
+
const response = await fetchImpl(url);
|
|
28
|
+
if (!response.ok)
|
|
29
|
+
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
|
30
|
+
return Buffer.from(await response.arrayBuffer());
|
|
31
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { makeReqResults } from './req-results.js';
|
|
2
|
+
/**
|
|
3
|
+
* The older api modules' transport seam — a fresh `makeReqResults` instance per invocation, the
|
|
4
|
+
* DOM-diff flow's conventions (standard retry policy, explicit request/connection timeouts). The
|
|
5
|
+
* full retry budget every request gets is guaranteed by `makeReqResults` itself
|
|
6
|
+
* (`isolateRetryPerRequest`), not by this per-invocation build; building here keeps one shape
|
|
7
|
+
* across the package and scopes the resolved server URL to the request it came from.
|
|
8
|
+
* These modules bake the apiKey into their URLs themselves, so it is read back out of the URL
|
|
9
|
+
* here rather than threaded separately; no `expected` statuses are passed, so the instance's
|
|
10
|
+
* unexpected-status hook stays inert and the modules' own status checks remain in charge. Only
|
|
11
|
+
* how a single request executes lives here: the task flow (202/Location → status → result),
|
|
12
|
+
* JSON parsing, and error wording all stay above it.
|
|
13
|
+
*/
|
|
14
|
+
export const reqTransport = (url, init) => {
|
|
15
|
+
const { origin, searchParams } = new URL(url);
|
|
16
|
+
const req = makeReqResults({ serverUrl: origin, apiKey: searchParams.get('apiKey') ?? '' });
|
|
17
|
+
return req(url, (init ?? {}));
|
|
18
|
+
};
|
|
19
|
+
/** POST a JSON body. */
|
|
20
|
+
export async function postJson(url, body, fetchImpl = reqTransport) {
|
|
21
|
+
return fetchImpl(url, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** GET and parse a JSON response, throwing a descriptive error on a non-ok status. */
|
|
28
|
+
export async function getJson(url, fetchImpl = reqTransport) {
|
|
29
|
+
const response = await fetchImpl(url);
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
const text = await response.text().catch(() => '');
|
|
32
|
+
throw new Error(`Request failed: ${response.status} ${response.statusText}${text ? ` — ${text}` : ''}`);
|
|
33
|
+
}
|
|
34
|
+
return response.json();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A `Location` header may legally be relative (RFC 9110) and Node's fetch rejects relative URLs,
|
|
38
|
+
* so resolve it against the URL of the request that produced it. An absolute location needs no
|
|
39
|
+
* base; a relative one without a base is unusable — fail here, naming the value, rather than as
|
|
40
|
+
* an opaque invalid-URL error from the fetch that would have consumed it.
|
|
41
|
+
*/
|
|
42
|
+
function resolveLocation(location, base) {
|
|
43
|
+
if (base)
|
|
44
|
+
return new URL(location, base).toString();
|
|
45
|
+
if (URL.canParse(location))
|
|
46
|
+
return location;
|
|
47
|
+
throw new Error(`Cannot resolve relative Location header "${location}" — no request URL to resolve it against`);
|
|
48
|
+
}
|
|
49
|
+
/** Parses a response body as JSON, tolerating an empty body (e.g. a 204) by returning `undefined` instead of throwing on `JSON.parse('')`. */
|
|
50
|
+
async function parseJsonBody(response) {
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
return text ? JSON.parse(text) : undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Polls a job-status URL until it reports done, then fetches and returns the job's result.
|
|
56
|
+
* Confirmed live against the real API — for every async endpoint checked so far (`images/find`,
|
|
57
|
+
* `sessions/batches/{batchId}/updates`), not just the ones that produce a real payload — a
|
|
58
|
+
* `201` isn't itself the result: it carries its *own* `Location` header (distinct from the
|
|
59
|
+
* original 202's) pointing at a separate result URL, which resolves with either a real JSON
|
|
60
|
+
* body (`images/find`) or an empty one (`updates`, `save`, etc. — a 204). Both are handled
|
|
61
|
+
* uniformly here; a caller with nothing to do with the result just ignores the return value.
|
|
62
|
+
* 200 = still running (wait and retry), anything else on the status poll = error.
|
|
63
|
+
*/
|
|
64
|
+
export async function pollUntilResult(statusUrl, apiKey, fetchImpl = reqTransport, sleepFn = ms => new Promise(resolve => setTimeout(resolve, ms))) {
|
|
65
|
+
const maxAttempts = 60;
|
|
66
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
67
|
+
const sep = statusUrl.includes('?') ? '&' : '?';
|
|
68
|
+
const response = await fetchImpl(`${statusUrl}${sep}apiKey=${encodeURIComponent(apiKey)}`);
|
|
69
|
+
if (response.status === 200) {
|
|
70
|
+
await sleepFn(1000);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (response.status === 201) {
|
|
74
|
+
const location = response.headers.get('Location');
|
|
75
|
+
if (!location) {
|
|
76
|
+
throw new Error('Job finished (201) but no result Location header was returned');
|
|
77
|
+
}
|
|
78
|
+
// `response.url` is the post-redirect URL when populated, but is '' under req's HTTP/2
|
|
79
|
+
// rewrap — fall back to the URL this poll actually requested.
|
|
80
|
+
const resultUrl = resolveLocation(location, response.url || statusUrl);
|
|
81
|
+
const resultSep = resultUrl.includes('?') ? '&' : '?';
|
|
82
|
+
const resultResponse = await fetchImpl(`${resultUrl}${resultSep}apiKey=${encodeURIComponent(apiKey)}`);
|
|
83
|
+
if (!resultResponse.ok) {
|
|
84
|
+
const text = await resultResponse.text().catch(() => '');
|
|
85
|
+
throw new Error(`Failed to fetch job result: ${resultResponse.status} ${resultResponse.statusText}${text ? ` — ${text}` : ''}`);
|
|
86
|
+
}
|
|
87
|
+
return parseJsonBody(resultResponse);
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Unexpected poll status: ${response.status} ${response.statusText}`);
|
|
90
|
+
}
|
|
91
|
+
throw new Error('Polling timed out after 60 attempts');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Handles the Eyes dashboard's async-job response convention end to end: a `202` with a
|
|
95
|
+
* `Location` header means the job is running and is polled via `pollUntilResult` through to
|
|
96
|
+
* its actual result; a plain ok response is parsed as JSON directly (tolerating an empty
|
|
97
|
+
* body). One function for every endpoint, whether or not its result is ever read — a caller
|
|
98
|
+
* that only cares that the call succeeded (e.g. `accept`/`reject`/`save`) just discards the
|
|
99
|
+
* return value instead of needing a separate "no result expected" variant.
|
|
100
|
+
*
|
|
101
|
+
* `requestUrl` backs up `response.url` as the base for a relative Location: `response.url` is
|
|
102
|
+
* '' under req's HTTP/2 rewrap, so callers pass the URL they requested.
|
|
103
|
+
*/
|
|
104
|
+
export async function getJsonResult(response, apiKey, fetchImpl = reqTransport, sleepFn, requestUrl) {
|
|
105
|
+
if (response.status === 202) {
|
|
106
|
+
const location = response.headers.get('Location');
|
|
107
|
+
if (!location) {
|
|
108
|
+
throw new Error('Job started (202) but no status Location header was returned');
|
|
109
|
+
}
|
|
110
|
+
return pollUntilResult(resolveLocation(location, response.url || requestUrl), apiKey, fetchImpl, sleepFn);
|
|
111
|
+
}
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const text = await response.text().catch(() => '');
|
|
114
|
+
throw new Error(`Request failed: ${response.status} ${response.statusText}${text ? ` — ${text}` : ''}`);
|
|
115
|
+
}
|
|
116
|
+
return parseJsonBody(response);
|
|
117
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { makeReq } from '@applitools/req';
|
|
2
|
+
// Same convention as core-base's reqEyes: `expected` is enforced by an afterResponse hook, so
|
|
3
|
+
// call sites branch only on the statuses that carry meaning for them.
|
|
4
|
+
function handleUnexpectedResponse() {
|
|
5
|
+
return {
|
|
6
|
+
async afterResponse({ request, response, options }) {
|
|
7
|
+
const expected = options?.expected;
|
|
8
|
+
if (expected === undefined)
|
|
9
|
+
return;
|
|
10
|
+
if (Array.isArray(expected) ? expected.includes(response.status) : expected === response.status)
|
|
11
|
+
return;
|
|
12
|
+
const body = await response
|
|
13
|
+
.clone()
|
|
14
|
+
.text()
|
|
15
|
+
.catch(() => '');
|
|
16
|
+
const message = `Request "${options?.name ?? request.url}" failed due to unexpected status ` +
|
|
17
|
+
`${response.statusText}(${response.status})${body ? ` and body "${body}"` : ''}`;
|
|
18
|
+
options?.logger?.error(message);
|
|
19
|
+
throw new Error(message);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// req counts retries by mutating `attempt` on the very `Retry` object it was handed, and never
|
|
24
|
+
// resets it: `mergeOptions` rebuilds the retry *array* per request, but `[].concat(obj)` copies the
|
|
25
|
+
// element reference, so one literal shared across requests is one budget shared across requests —
|
|
26
|
+
// after `limit` cumulative retries every later request is silently fire-once, and before that a
|
|
27
|
+
// fresh request's first backoff is read from the climbed index (3200 ms instead of 200 ms).
|
|
28
|
+
// `afterOptionsMerged` runs once per request on that request's own merged options, so replacing the
|
|
29
|
+
// retry entries with fresh copies there makes the budget per-request no matter how long the
|
|
30
|
+
// instance that carries the policy lives.
|
|
31
|
+
function isolateRetryPerRequest() {
|
|
32
|
+
return {
|
|
33
|
+
afterOptionsMerged({ options }) {
|
|
34
|
+
if (!options.retry)
|
|
35
|
+
return;
|
|
36
|
+
const retries = Array.isArray(options.retry) ? options.retry : [options.retry];
|
|
37
|
+
return { ...options, retry: retries.map(retry => ({ ...retry, attempt: undefined })) };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Builds a configured `req` — core-base's `makeReqEyes` concept adapted to this package: the
|
|
43
|
+
* instance owns the server URL, the `apiKey` query parameter, the transient-retry policy, and the
|
|
44
|
+
* `expected`-status convention above, so api functions take the configured req plus business
|
|
45
|
+
* parameters and never thread baseUrl/apiKey/transport themselves. Flat options on purpose: an api
|
|
46
|
+
* function's own deps spread straight in — `makeReqResults({serverUrl, apiKey, ...deps})`.
|
|
47
|
+
*
|
|
48
|
+
* **Build one per call, not one per module.** Every call site here does (`api/dom-diff.ts`,
|
|
49
|
+
* `api/dom-fetch.ts`, `api/tasks.ts`, `api/polling.ts`'s `reqTransport`), because req's retry
|
|
50
|
+
* counter lives on the retry object the instance carries — see `isolateRetryPerRequest` above for
|
|
51
|
+
* what that costs a long-lived instance. That hook is what actually guarantees the budget is
|
|
52
|
+
* per-request, so hoisting an instance is safe rather than silently degrading; per-call
|
|
53
|
+
* construction stays the convention because it also keeps the apiKey and server URL scoped to the
|
|
54
|
+
* flow that resolved them. `test/api/req-results.test.ts`'s "retry isolation" suite makes several
|
|
55
|
+
* calls through a single instance and pins both properties.
|
|
56
|
+
*/
|
|
57
|
+
export function makeReqResults({ serverUrl, apiKey, fetch, logger }) {
|
|
58
|
+
return makeReq({
|
|
59
|
+
baseUrl: serverUrl,
|
|
60
|
+
query: { apiKey },
|
|
61
|
+
retry: {
|
|
62
|
+
limit: 5,
|
|
63
|
+
timeout: [200, 400, 800, 1600, 3200],
|
|
64
|
+
// 500/503 deliberately excluded: on a task-status probe they are terminal contract signals
|
|
65
|
+
// (503 = task rejected), not transient blips — they must surface immediately.
|
|
66
|
+
statuses: [429, 502, 504],
|
|
67
|
+
codes: ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND'],
|
|
68
|
+
},
|
|
69
|
+
// core-base reqEyes's defaults. Callers cap attempts, not wall-clock, so a stalled request
|
|
70
|
+
// needs its own timeout to not hang forever.
|
|
71
|
+
requestTimeout: 30000,
|
|
72
|
+
connectionTimeout: 300000,
|
|
73
|
+
hooks: [isolateRetryPerRequest(), handleUnexpectedResponse()],
|
|
74
|
+
fetch,
|
|
75
|
+
logger,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { postJson, getJsonResult, reqTransport } from './polling.js';
|
|
2
|
+
/**
|
|
3
|
+
* Given a reference step and a candidate list of other steps, returns which of `steps` actually
|
|
4
|
+
* have the same diff as `reference` — the real target steps an accept/reject decision should
|
|
5
|
+
* propagate to, as opposed to every step a scenario-name match happened to include. `image1`/
|
|
6
|
+
* `image2` here are plain baseline/checkpoint image id strings, not full `ImageRecord` objects
|
|
7
|
+
* (unlike `compareStep`'s own request shape) — each entry (including `reference`) carries its
|
|
8
|
+
* own step's `matchSettings`, never another step's.
|
|
9
|
+
*
|
|
10
|
+
* Confirmed live: an async job (202 + Location), same as every other endpoint in this file.
|
|
11
|
+
* **The result is `number[]` — zero-based indices into the request's own `steps` array, e.g.
|
|
12
|
+
* `[0, 1]` meaning `steps[0]` and `steps[1]` both matched — never step objects, and never
|
|
13
|
+
* `{sessionId, stepIndex}` identifiers.** A caller must resolve each index back against its own
|
|
14
|
+
* original `steps`/candidate array; the response carries nothing else to identify a match by.
|
|
15
|
+
* When nothing in `steps` matches `reference`, the job's own result is `204 No Content` — an
|
|
16
|
+
* empty body, which `getJsonResult` parses as `undefined` — normalized here to `[]` so callers
|
|
17
|
+
* can always assume an array.
|
|
18
|
+
*/
|
|
19
|
+
export async function findSameDiffSteps(body, options, fetchImpl = reqTransport) {
|
|
20
|
+
const { baseUrl, accountId, keys } = options;
|
|
21
|
+
const apiKey = keys.getReadKey();
|
|
22
|
+
const url = `${baseUrl}/api/sessions/steps/samediffas?accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
23
|
+
const response = await postJson(url, body, fetchImpl);
|
|
24
|
+
return (await getJsonResult(response, apiKey, fetchImpl, undefined, url)) ?? [];
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { postJson, getJsonResult, reqTransport } from './polling.js';
|
|
2
|
+
/**
|
|
3
|
+
* Maps each of `selectors` from `image1`'s DOM to its structurally-equivalent selector on
|
|
4
|
+
* `image2`'s DOM — e.g. translating a region's domSelector from a baseline to a checkpoint,
|
|
5
|
+
* or the reverse. Returns one entry per input selector, in the same order; an entry is
|
|
6
|
+
* `null` when no compatible element exists on `image2`.
|
|
7
|
+
*/
|
|
8
|
+
export async function getCompatibleSelectors(options, image1, image2, selectors, fetchImpl = reqTransport) {
|
|
9
|
+
const { baseUrl, accountId, keys } = options;
|
|
10
|
+
const apiKey = keys.getReadKey();
|
|
11
|
+
const url = `${baseUrl}/api/images/get-compatible-selectors?accountId=${accountId}&apiKey=${encodeURIComponent(apiKey)}`;
|
|
12
|
+
const response = await postJson(url, { image1, image2, selectors }, fetchImpl);
|
|
13
|
+
const data = await getJsonResult(response, apiKey, fetchImpl, undefined, url);
|
|
14
|
+
return data.selectors;
|
|
15
|
+
}
|