@testkase/visual 0.1.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/README.md +53 -0
- package/dist/api.js +114 -0
- package/dist/args.js +120 -0
- package/dist/ci.js +167 -0
- package/dist/github.js +196 -0
- package/dist/glob.js +58 -0
- package/dist/help.js +96 -0
- package/dist/index.js +78 -0
- package/dist/names.js +77 -0
- package/dist/png.js +26 -0
- package/dist/upload.js +207 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @testkase/visual
|
|
2
|
+
|
|
3
|
+
Upload your test suite's screenshots to [TestKase Visual](https://visual.testkase.com)
|
|
4
|
+
and get told what changed.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx @testkase/visual upload ./screenshots --project "Marketing"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Your tests take the pictures. This uploads them, compares each one against the
|
|
11
|
+
last approved version, and exits 1 when something needs a person to look at it.
|
|
12
|
+
It works with Playwright, Cypress, Puppeteer, Selenium or anything else, because
|
|
13
|
+
a folder of PNGs is not a framework.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
1. Make a project in TestKase and note its name.
|
|
18
|
+
2. Create a personal access token and put it in your CI as `TESTKASE_TOKEN`.
|
|
19
|
+
3. Take **full-page** screenshots in your tests:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
await page.screenshot({ path: "shots/home.png", fullPage: true }); // Playwright, Puppeteer
|
|
23
|
+
cy.screenshot("home", { capture: "fullPage" }); // Cypress
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
4. Upload the folder after the suite has run:
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
- run: npx playwright test
|
|
30
|
+
- run: npx @testkase/visual upload ./shots --project "Marketing"
|
|
31
|
+
env:
|
|
32
|
+
TESTKASE_TOKEN: ${{ secrets.TESTKASE_TOKEN }}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## The filename is the screen name
|
|
36
|
+
|
|
37
|
+
`shots/home.png` is the screen "home", and next week's `shots/home.png` is
|
|
38
|
+
compared against it. **Keep filenames stable.** If they change, every picture
|
|
39
|
+
looks new — new baselines, nothing compared, build green — and this tool has
|
|
40
|
+
quietly stopped working. Run `--dry-run` first to see the names it will use, and
|
|
41
|
+
watch for the warning it prints when a build matches nothing.
|
|
42
|
+
|
|
43
|
+
To name screens yourself, use `--name` for one file or `--map` for a folder.
|
|
44
|
+
|
|
45
|
+
## Exit codes
|
|
46
|
+
|
|
47
|
+
| Code | Meaning |
|
|
48
|
+
|---|---|
|
|
49
|
+
| 0 | Nothing to review |
|
|
50
|
+
| 1 | Something changed, or a picture could not be uploaded |
|
|
51
|
+
| 2 | The command was wrong — nothing was created |
|
|
52
|
+
|
|
53
|
+
`npx @testkase/visual --help` is the full reference.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// api.ts — the four calls, over fetch.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ The token goes in a header and NOWHERE else. Not in a URL, not in a log
|
|
5
|
+
// line, not in an error message — a CI log is a file a lot of people can read,
|
|
6
|
+
// and a leaked personal access token is every project in the org.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.Api = exports.ApiError = void 0;
|
|
9
|
+
exports.retryAfterMs = retryAfterMs;
|
|
10
|
+
/**
|
|
11
|
+
* How many times to wait out a "slow down" before giving up on a picture.
|
|
12
|
+
*
|
|
13
|
+
* Four, because the window a rate limit is counted over is a minute: waiting
|
|
14
|
+
* one out is the whole fix, and a fifth attempt means something else is wrong.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_RETRIES = 4;
|
|
17
|
+
const MAX_BACKOFF_MS = 60_000;
|
|
18
|
+
/**
|
|
19
|
+
* How long to wait. The server's own `Retry-After` when it sent one — it knows
|
|
20
|
+
* when its window resets and we are guessing — otherwise a doubling backoff.
|
|
21
|
+
*/
|
|
22
|
+
function retryAfterMs(res, attempt) {
|
|
23
|
+
const header = res.headers.get("retry-after");
|
|
24
|
+
const seconds = header ? Number(header) : NaN;
|
|
25
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
26
|
+
return Math.min(seconds * 1000 + 250, MAX_BACKOFF_MS);
|
|
27
|
+
return Math.min(2 ** attempt * 1000, MAX_BACKOFF_MS);
|
|
28
|
+
}
|
|
29
|
+
/** A refusal from the Hub, already phrased for a person. */
|
|
30
|
+
class ApiError extends Error {
|
|
31
|
+
status;
|
|
32
|
+
code;
|
|
33
|
+
constructor(status, code, message) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.code = code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.ApiError = ApiError;
|
|
40
|
+
class Api {
|
|
41
|
+
opts;
|
|
42
|
+
fetch;
|
|
43
|
+
constructor(opts) {
|
|
44
|
+
this.opts = opts;
|
|
45
|
+
this.fetch = opts.fetchImpl ?? globalThis.fetch;
|
|
46
|
+
}
|
|
47
|
+
async post(path, body) {
|
|
48
|
+
for (let attempt = 0;; attempt++) {
|
|
49
|
+
const res = await this.fetch(`${this.opts.apiUrl}/api/v1/visual/uploads${path}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"content-type": "application/json",
|
|
53
|
+
authorization: `Bearer ${this.opts.token}`,
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
if (res.ok)
|
|
58
|
+
return (await res.json());
|
|
59
|
+
// ⚠️ Being asked to slow down is not a failed screen. Found on QA: a
|
|
60
|
+
// 50-picture run 429'd its last seven and then the finish call, and each
|
|
61
|
+
// one was reported to the customer as a picture that could not be
|
|
62
|
+
// uploaded. Wait the server's own Retry-After and go again.
|
|
63
|
+
if (res.status === 429 && attempt < MAX_RETRIES) {
|
|
64
|
+
await this.wait(retryAfterMs(res, attempt));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const detail = await readError(res);
|
|
68
|
+
throw new ApiError(res.status, detail.code, detail.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
wait(ms) {
|
|
72
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
|
+
}
|
|
74
|
+
startBuild(project, name, from = {}) {
|
|
75
|
+
// A UUID is a project id; anything else is a name the Hub looks up. Both
|
|
76
|
+
// work, because CI has a name in a YAML file and the dashboard has an id.
|
|
77
|
+
const key = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(project)
|
|
78
|
+
? { projectId: project }
|
|
79
|
+
: { project };
|
|
80
|
+
return this.post("/builds", { ...key, name, ...from });
|
|
81
|
+
}
|
|
82
|
+
requestSlot(input) {
|
|
83
|
+
return this.post("", input);
|
|
84
|
+
}
|
|
85
|
+
/** ⚠️ Straight to R2. These bytes never touch our Hub — see the route's header. */
|
|
86
|
+
async putImage(uploadUrl, bytes) {
|
|
87
|
+
const res = await this.fetch(uploadUrl, {
|
|
88
|
+
method: "PUT",
|
|
89
|
+
headers: { "content-type": "image/png" },
|
|
90
|
+
body: new Uint8Array(bytes),
|
|
91
|
+
});
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new ApiError(res.status, "UPLOAD_FAILED", `The upload was refused (${res.status}).`);
|
|
94
|
+
}
|
|
95
|
+
confirm(snapshotId, slot) {
|
|
96
|
+
return this.post(`/${snapshotId}/done`, { slot });
|
|
97
|
+
}
|
|
98
|
+
finishBuild(buildId) {
|
|
99
|
+
return this.post(`/builds/${buildId}/finish`, {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.Api = Api;
|
|
103
|
+
async function readError(res) {
|
|
104
|
+
try {
|
|
105
|
+
const body = (await res.json());
|
|
106
|
+
return {
|
|
107
|
+
code: body.error?.code ?? "REQUEST_FAILED",
|
|
108
|
+
message: body.error?.message ?? `The request failed (${res.status}).`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return { code: "REQUEST_FAILED", message: `The request failed (${res.status}).` };
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// args.ts — the flags, and the refusals that happen before anything is created.
|
|
3
|
+
//
|
|
4
|
+
// Everything here is pure: argv in, a decision out. The point is that a bad
|
|
5
|
+
// command line fails BEFORE a build exists, because a build created and then
|
|
6
|
+
// abandoned is one a reviewer has to look at and close by hand.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.UsageError = exports.DEFAULT_APP_URL = exports.DEFAULT_API_URL = exports.MAX_CONCURRENCY = exports.MIN_CONCURRENCY = exports.DEFAULT_CONCURRENCY = void 0;
|
|
9
|
+
exports.clampConcurrency = clampConcurrency;
|
|
10
|
+
exports.parseArgs = parseArgs;
|
|
11
|
+
exports.exitCodeFor = exitCodeFor;
|
|
12
|
+
exports.DEFAULT_CONCURRENCY = 5;
|
|
13
|
+
exports.MIN_CONCURRENCY = 1;
|
|
14
|
+
exports.MAX_CONCURRENCY = 10;
|
|
15
|
+
exports.DEFAULT_API_URL = "https://hub.testkase.com";
|
|
16
|
+
exports.DEFAULT_APP_URL = "https://visual.testkase.com";
|
|
17
|
+
/** A refusal a person can act on, with the exit code CI should see. */
|
|
18
|
+
class UsageError extends Error {
|
|
19
|
+
exitCode = 2;
|
|
20
|
+
}
|
|
21
|
+
exports.UsageError = UsageError;
|
|
22
|
+
const FLAGS_WITH_VALUES = new Set([
|
|
23
|
+
"--project", "--token", "--build", "--build-name", "--name", "--map",
|
|
24
|
+
"--browser", "--os", "--files", "--ignore", "--concurrency", "--api-url",
|
|
25
|
+
"--branch", "--commit", "--pr", "--app-url",
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* ⚠️ `--concurrency` is clamped, not rejected. Somebody who writes `--concurrency 200`
|
|
29
|
+
* wants "as fast as you can", and refusing the command over it would fail a
|
|
30
|
+
* pipeline for a number that has no wrong answer. Ten is where R2 stops going
|
|
31
|
+
* faster and starts returning 503s.
|
|
32
|
+
*/
|
|
33
|
+
function clampConcurrency(n) {
|
|
34
|
+
if (!Number.isFinite(n))
|
|
35
|
+
return exports.DEFAULT_CONCURRENCY;
|
|
36
|
+
return Math.min(exports.MAX_CONCURRENCY, Math.max(exports.MIN_CONCURRENCY, Math.floor(n)));
|
|
37
|
+
}
|
|
38
|
+
function splitList(value) {
|
|
39
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
function parseArgs(argv, env = {}) {
|
|
42
|
+
const positional = [];
|
|
43
|
+
const flags = {};
|
|
44
|
+
const bools = new Set();
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const arg = argv[i];
|
|
47
|
+
if (!arg.startsWith("--")) {
|
|
48
|
+
positional.push(arg);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const eq = arg.indexOf("=");
|
|
52
|
+
const key = eq < 0 ? arg : arg.slice(0, eq);
|
|
53
|
+
if (FLAGS_WITH_VALUES.has(key)) {
|
|
54
|
+
const value = eq < 0 ? argv[++i] : arg.slice(eq + 1);
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
throw new UsageError(`${key} needs a value.`);
|
|
57
|
+
// Repeatable, so --files a --files b reads the way it looks.
|
|
58
|
+
flags[key] = flags[key] ? `${flags[key]},${value}` : value;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
bools.add(key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const unknown = [...bools].filter((f) => !["--dry-run", "--exit-zero-on-changes", "--no-finish", "--no-github", "--help", "-h", "--version"].includes(f));
|
|
65
|
+
if (unknown.length)
|
|
66
|
+
throw new UsageError(`I don't know the flag ${unknown[0]}.`);
|
|
67
|
+
const folder = positional[0];
|
|
68
|
+
if (!folder)
|
|
69
|
+
throw new UsageError("Tell me which folder to upload: testkase-visual upload ./screenshots --project <name>");
|
|
70
|
+
// ⚠️ Required, never guessed. The New run dialog made this mistake once: it
|
|
71
|
+
// quietly used whichever project was to hand, so a run compared against
|
|
72
|
+
// another project's baselines with nothing on screen saying which. In CI
|
|
73
|
+
// nobody is looking at all.
|
|
74
|
+
const project = flags["--project"] ?? env.TESTKASE_PROJECT;
|
|
75
|
+
if (!project)
|
|
76
|
+
throw new UsageError("--project is required. It's the project in TestKase these screens belong to.");
|
|
77
|
+
const token = flags["--token"] ?? env.TESTKASE_TOKEN;
|
|
78
|
+
if (!token)
|
|
79
|
+
throw new UsageError("No access token. Set TESTKASE_TOKEN, or pass --token.");
|
|
80
|
+
return {
|
|
81
|
+
folder,
|
|
82
|
+
project,
|
|
83
|
+
token,
|
|
84
|
+
build: flags["--build"],
|
|
85
|
+
buildName: flags["--build-name"] ?? env.TESTKASE_BUILD_NAME,
|
|
86
|
+
// Overrides for a setup we do not recognise. Left undefined, ci.ts reads
|
|
87
|
+
// the environment; it never guesses when it cannot tell.
|
|
88
|
+
branch: flags["--branch"],
|
|
89
|
+
commit: flags["--commit"],
|
|
90
|
+
pullRequest: flags["--pr"],
|
|
91
|
+
name: flags["--name"],
|
|
92
|
+
mapFile: flags["--map"],
|
|
93
|
+
browser: flags["--browser"] ?? "upload",
|
|
94
|
+
os: flags["--os"] ?? "ci",
|
|
95
|
+
files: splitList(flags["--files"] ?? "**/*.png"),
|
|
96
|
+
ignore: splitList(flags["--ignore"] ?? ""),
|
|
97
|
+
concurrency: flags["--concurrency"] ? clampConcurrency(Number(flags["--concurrency"])) : exports.DEFAULT_CONCURRENCY,
|
|
98
|
+
dryRun: bools.has("--dry-run"),
|
|
99
|
+
exitZeroOnChanges: bools.has("--exit-zero-on-changes"),
|
|
100
|
+
// A sharded suite runs this on five machines and finishes ONCE, on the last.
|
|
101
|
+
finish: !bools.has("--no-finish"),
|
|
102
|
+
apiUrl: (flags["--api-url"] ?? env.TESTKASE_API_URL ?? exports.DEFAULT_API_URL).replace(/\/+$/, ""),
|
|
103
|
+
// ⚠️ Where the "Details" link points. A constant rather than something the
|
|
104
|
+
// Hub tells us: the Hub does not reliably know its own public address —
|
|
105
|
+
// PUBLIC_APP_URL is set by nothing today — so asking it would be a guess
|
|
106
|
+
// dressed up as an answer.
|
|
107
|
+
appUrl: (flags["--app-url"] ?? env.TESTKASE_APP_URL ?? exports.DEFAULT_APP_URL).replace(/\/+$/, ""),
|
|
108
|
+
github: !bools.has("--no-github"),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* ⚠️ The exit code IS the integration. 1 when anything needs a person to look at
|
|
113
|
+
* it, 0 otherwise — a pipeline that only prints a warning is a pipeline that
|
|
114
|
+
* ships the change.
|
|
115
|
+
*/
|
|
116
|
+
function exitCodeFor(summary, opts) {
|
|
117
|
+
if (opts.exitZeroOnChanges)
|
|
118
|
+
return 0;
|
|
119
|
+
return summary.unreviewed > 0 ? 1 : 0;
|
|
120
|
+
}
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ci.ts — where did this build come from?
|
|
3
|
+
//
|
|
4
|
+
// Every CI system already knows the branch, the commit and the pull request. It
|
|
5
|
+
// puts them in the environment under a different name each time, which is the
|
|
6
|
+
// only reason this file exists.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ Nothing here GUESSES. If we cannot tell which branch a run came from, the
|
|
9
|
+
// answer is nothing at all — never a plausible-looking default. A build labelled
|
|
10
|
+
// with the wrong branch is worse than one labelled with none, because somebody
|
|
11
|
+
// will believe it.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.detectCi = detectCi;
|
|
14
|
+
exports.provenance = provenance;
|
|
15
|
+
exports.describe = describe;
|
|
16
|
+
const first = (env, ...names) => {
|
|
17
|
+
for (const n of names) {
|
|
18
|
+
const v = env[n]?.trim();
|
|
19
|
+
if (v)
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* GitHub Actions names the branch differently depending on the event.
|
|
26
|
+
*
|
|
27
|
+
* On a pull request, GITHUB_REF_NAME is "214/merge" — the synthetic merge ref,
|
|
28
|
+
* not a branch anybody has. GITHUB_HEAD_REF is the actual source branch and is
|
|
29
|
+
* set ONLY on pull_request events, which is exactly the signal we want: prefer
|
|
30
|
+
* it, fall back to GITHUB_REF_NAME for a push.
|
|
31
|
+
*/
|
|
32
|
+
function github(env) {
|
|
33
|
+
const pr = first(env, "GITHUB_PR_NUMBER") ?? prFromRef(env.GITHUB_REF);
|
|
34
|
+
return {
|
|
35
|
+
branch: first(env, "GITHUB_HEAD_REF", "GITHUB_REF_NAME") ?? stripRef(env.GITHUB_REF),
|
|
36
|
+
commit: first(env, "GITHUB_SHA"),
|
|
37
|
+
pullRequest: pr,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** "refs/pull/214/merge" → "214". Nothing else in the environment carries it. */
|
|
41
|
+
function prFromRef(ref) {
|
|
42
|
+
const m = /^refs\/pull\/(\d+)\//.exec(ref ?? "");
|
|
43
|
+
return m ? m[1] : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** "refs/heads/main" → "main". Left alone if it is not a ref. */
|
|
46
|
+
function stripRef(ref) {
|
|
47
|
+
if (!ref)
|
|
48
|
+
return undefined;
|
|
49
|
+
return ref.replace(/^refs\/heads\//, "") || undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Each detector is tried in order and the FIRST whose marker variable is set
|
|
53
|
+
* wins. Order matters where they overlap: several tools set CI=true, and
|
|
54
|
+
* Jenkins-style GIT_BRANCH appears in other places too, so the generic
|
|
55
|
+
* fallbacks come last.
|
|
56
|
+
*/
|
|
57
|
+
const DETECTORS = [
|
|
58
|
+
{ name: "github", when: (e) => Boolean(e.GITHUB_ACTIONS), read: github },
|
|
59
|
+
{
|
|
60
|
+
name: "gitlab",
|
|
61
|
+
when: (e) => Boolean(e.GITLAB_CI),
|
|
62
|
+
read: (e) => ({
|
|
63
|
+
branch: first(e, "CI_COMMIT_REF_NAME", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"),
|
|
64
|
+
commit: first(e, "CI_COMMIT_SHA"),
|
|
65
|
+
pullRequest: first(e, "CI_MERGE_REQUEST_IID"),
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "circleci",
|
|
70
|
+
when: (e) => Boolean(e.CIRCLECI),
|
|
71
|
+
read: (e) => ({
|
|
72
|
+
branch: first(e, "CIRCLE_BRANCH"),
|
|
73
|
+
commit: first(e, "CIRCLE_SHA1"),
|
|
74
|
+
// CircleCI gives the PR's URL, not its number.
|
|
75
|
+
pullRequest: prFromUrl(first(e, "CIRCLE_PULL_REQUEST")),
|
|
76
|
+
}),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "bitbucket",
|
|
80
|
+
when: (e) => Boolean(e.BITBUCKET_BUILD_NUMBER),
|
|
81
|
+
read: (e) => ({
|
|
82
|
+
branch: first(e, "BITBUCKET_BRANCH"),
|
|
83
|
+
commit: first(e, "BITBUCKET_COMMIT"),
|
|
84
|
+
pullRequest: first(e, "BITBUCKET_PR_ID"),
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "buildkite",
|
|
89
|
+
when: (e) => Boolean(e.BUILDKITE),
|
|
90
|
+
read: (e) => ({
|
|
91
|
+
branch: first(e, "BUILDKITE_BRANCH"),
|
|
92
|
+
commit: first(e, "BUILDKITE_COMMIT"),
|
|
93
|
+
// Buildkite sets this to the literal string "false" when there is no PR.
|
|
94
|
+
pullRequest: notFalse(first(e, "BUILDKITE_PULL_REQUEST")),
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "jenkins",
|
|
99
|
+
when: (e) => Boolean(e.JENKINS_URL || e.BUILD_NUMBER),
|
|
100
|
+
read: (e) => ({
|
|
101
|
+
branch: stripOrigin(first(e, "BRANCH_NAME", "GIT_BRANCH")),
|
|
102
|
+
commit: first(e, "GIT_COMMIT"),
|
|
103
|
+
pullRequest: first(e, "CHANGE_ID"),
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
// Anything else that sets CI, plus the variables people wire up by hand.
|
|
108
|
+
name: "generic",
|
|
109
|
+
when: (e) => Boolean(e.CI),
|
|
110
|
+
read: (e) => ({
|
|
111
|
+
branch: stripRef(first(e, "GIT_BRANCH", "BRANCH_NAME", "BRANCH")),
|
|
112
|
+
commit: first(e, "GIT_COMMIT", "COMMIT_SHA", "SHA"),
|
|
113
|
+
pullRequest: first(e, "PULL_REQUEST", "PR_NUMBER"),
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
function prFromUrl(url) {
|
|
118
|
+
const m = /\/(\d+)\/?$/.exec(url ?? "");
|
|
119
|
+
return m ? m[1] : undefined;
|
|
120
|
+
}
|
|
121
|
+
/** Buildkite says "false"; Jenkins-style vars sometimes say "null". */
|
|
122
|
+
function notFalse(v) {
|
|
123
|
+
return v && v !== "false" && v !== "null" ? v : undefined;
|
|
124
|
+
}
|
|
125
|
+
/** Jenkins reports "origin/main" for a checkout of main. */
|
|
126
|
+
function stripOrigin(v) {
|
|
127
|
+
return v?.replace(/^origin\//, "") || undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* What the environment says about this run, and which system said it.
|
|
131
|
+
*
|
|
132
|
+
* Returns `{}` when nothing recognisable is set — running on a laptop is a
|
|
133
|
+
* perfectly ordinary thing to do, and it is not an error.
|
|
134
|
+
*/
|
|
135
|
+
function detectCi(env = process.env) {
|
|
136
|
+
for (const d of DETECTORS) {
|
|
137
|
+
if (!d.when(env))
|
|
138
|
+
continue;
|
|
139
|
+
const p = d.read(env);
|
|
140
|
+
// A detector that matched but found nothing useful should not stop the
|
|
141
|
+
// search — a stray CI=true would otherwise shadow a real one behind it.
|
|
142
|
+
if (p.branch || p.commit || p.pullRequest)
|
|
143
|
+
return { ...p, ci: d.name };
|
|
144
|
+
}
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
/** What the flags say, then what the environment says. A flag always wins. */
|
|
148
|
+
function provenance(flags, env = process.env) {
|
|
149
|
+
const found = detectCi(env);
|
|
150
|
+
return {
|
|
151
|
+
ci: found.ci,
|
|
152
|
+
branch: flags.branch ?? found.branch,
|
|
153
|
+
commit: flags.commit ?? found.commit,
|
|
154
|
+
pullRequest: flags.pullRequest ?? found.pullRequest,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** "feature/blue-button · a1b2c3d · PR #214", or nothing at all. */
|
|
158
|
+
function describe(p) {
|
|
159
|
+
const parts = [];
|
|
160
|
+
if (p.branch)
|
|
161
|
+
parts.push(p.branch);
|
|
162
|
+
if (p.commit)
|
|
163
|
+
parts.push(p.commit.slice(0, 7));
|
|
164
|
+
if (p.pullRequest)
|
|
165
|
+
parts.push(`PR #${p.pullRequest}`);
|
|
166
|
+
return parts.join(" · ");
|
|
167
|
+
}
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// github.ts — telling the pull request what changed.
|
|
3
|
+
//
|
|
4
|
+
// A visual tool nobody looks at is a visual tool that is not working. Today a
|
|
5
|
+
// changed screen shows up as `exit code 1` in a CI log, and the developer has to
|
|
6
|
+
// guess it was us, open the dashboard, find the project, find the build. Most
|
|
7
|
+
// will assume the suite is flaky and press re-run.
|
|
8
|
+
//
|
|
9
|
+
// So we post the result where they are already looking: a check in the pull
|
|
10
|
+
// request's list, and one comment.
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ WE HOLD NO CREDENTIALS. This runs inside the customer's own pipeline, which
|
|
13
|
+
// is already logged in to their repository. The alternative — storing a GitHub
|
|
14
|
+
// token per customer — is a far bigger security surface for the same outcome.
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ AND IT NEVER FAILS THEIR BUILD. Every call here is best-effort. If GitHub
|
|
17
|
+
// refuses, we print one line and carry on with the real result: a visual tool
|
|
18
|
+
// that breaks a pipeline because it could not leave a comment is worse than one
|
|
19
|
+
// that stays quiet.
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.COMMENT_MARKER = void 0;
|
|
22
|
+
exports.headSha = headSha;
|
|
23
|
+
exports.githubTarget = githubTarget;
|
|
24
|
+
exports.checkState = checkState;
|
|
25
|
+
exports.checkDescription = checkDescription;
|
|
26
|
+
exports.commentBody = commentBody;
|
|
27
|
+
exports.postToPullRequest = postToPullRequest;
|
|
28
|
+
const fs_1 = require("fs");
|
|
29
|
+
/** Ours, so we can edit our own comment instead of adding another one. */
|
|
30
|
+
exports.COMMENT_MARKER = "<!-- testkase-visual -->";
|
|
31
|
+
/**
|
|
32
|
+
* The commit a check must be attached to.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ On a pull request, GITHUB_SHA is a MERGE COMMIT GitHub invents for the
|
|
35
|
+
* run — it is not on either branch, and a status posted there appears nowhere.
|
|
36
|
+
* The head commit is in the event payload GitHub writes to disk, which is the
|
|
37
|
+
* only place it is available.
|
|
38
|
+
*
|
|
39
|
+
* Getting this wrong does not error. It posts happily to a commit nobody looks
|
|
40
|
+
* at, and the feature silently does nothing — the worst way for it to fail.
|
|
41
|
+
*/
|
|
42
|
+
function headSha(env, readFile = fs_1.readFileSync) {
|
|
43
|
+
const path = env.GITHUB_EVENT_PATH;
|
|
44
|
+
if (path) {
|
|
45
|
+
try {
|
|
46
|
+
const event = JSON.parse(String(readFile(path, "utf8")));
|
|
47
|
+
const head = event.pull_request?.head?.sha;
|
|
48
|
+
if (head)
|
|
49
|
+
return head;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// A push event has no pull_request, and an unreadable file is not worth
|
|
53
|
+
// failing over. GITHUB_SHA is correct for a push.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return env.GITHUB_SHA;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Everything needed to post, or nothing.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ All four parts or none. A token without a repository, or a repository
|
|
62
|
+
* without a commit, cannot post anything — and half-configured must behave
|
|
63
|
+
* exactly like not configured, silently, because running on a laptop is
|
|
64
|
+
* ordinary.
|
|
65
|
+
*/
|
|
66
|
+
function githubTarget(env, pullRequest, readFile = fs_1.readFileSync) {
|
|
67
|
+
const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
68
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
69
|
+
const sha = headSha(env, readFile);
|
|
70
|
+
if (!token || !repo || !sha)
|
|
71
|
+
return null;
|
|
72
|
+
return { token, repo, sha, pullRequest };
|
|
73
|
+
}
|
|
74
|
+
/** Red when somebody has to look, green when nobody does. */
|
|
75
|
+
function checkState(o) {
|
|
76
|
+
return o.unreviewed > 0 || o.failed > 0 ? "failure" : "success";
|
|
77
|
+
}
|
|
78
|
+
/** The one line that shows in the checks list. It has to say the number. */
|
|
79
|
+
function checkDescription(o) {
|
|
80
|
+
if (o.failed > 0 && o.unreviewed === 0) {
|
|
81
|
+
return `${o.failed} ${o.failed === 1 ? "picture" : "pictures"} could not be compared`;
|
|
82
|
+
}
|
|
83
|
+
if (o.unreviewed > 0) {
|
|
84
|
+
const s = `${o.unreviewed} ${o.unreviewed === 1 ? "screen" : "screens"} to review`;
|
|
85
|
+
return o.failed > 0 ? `${s}, ${o.failed} could not be compared` : s;
|
|
86
|
+
}
|
|
87
|
+
if (o.newBaselines > 0 && o.total === o.newBaselines) {
|
|
88
|
+
return `${o.newBaselines} new ${o.newBaselines === 1 ? "screen" : "screens"}, nothing to compare yet`;
|
|
89
|
+
}
|
|
90
|
+
return `${o.total} ${o.total === 1 ? "screen" : "screens"} checked, no changes`;
|
|
91
|
+
}
|
|
92
|
+
/** The comment body. Markdown, short, and led by the thing that needs doing. */
|
|
93
|
+
function commentBody(o) {
|
|
94
|
+
const lines = [exports.COMMENT_MARKER, "### TestKase Visual", ""];
|
|
95
|
+
if (o.unreviewed > 0) {
|
|
96
|
+
lines.push(`**${o.unreviewed} ${o.unreviewed === 1 ? "screen needs" : "screens need"} a look.** [Review ${o.unreviewed === 1 ? "it" : "them"}](${o.reviewUrl})`);
|
|
97
|
+
}
|
|
98
|
+
else if (o.failed > 0) {
|
|
99
|
+
lines.push(`**${o.failed} ${o.failed === 1 ? "picture" : "pictures"} could not be compared.** The ${o.failed === 1 ? "picture is" : "pictures are"} saved — running the build again is enough.`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
lines.push(`No visual changes. [See the build](${o.reviewUrl})`);
|
|
103
|
+
}
|
|
104
|
+
lines.push("", `${o.total} screens · ${o.changed} changed · ${o.newBaselines} new`);
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
const API = "https://api.github.com";
|
|
108
|
+
async function call(target, path, init, f) {
|
|
109
|
+
return f(`${API}${path}`, {
|
|
110
|
+
method: init.method,
|
|
111
|
+
headers: {
|
|
112
|
+
accept: "application/vnd.github+json",
|
|
113
|
+
authorization: `Bearer ${target.token}`,
|
|
114
|
+
"x-github-api-version": "2022-11-28",
|
|
115
|
+
"content-type": "application/json",
|
|
116
|
+
},
|
|
117
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Post the check and the comment. Never throws.
|
|
122
|
+
*
|
|
123
|
+
* The two are independent on purpose: a repository may allow one and not the
|
|
124
|
+
* other, and getting half of it is better than getting none.
|
|
125
|
+
*/
|
|
126
|
+
async function postToPullRequest(target, outcome, deps = {}) {
|
|
127
|
+
const f = deps.fetchImpl ?? globalThis.fetch;
|
|
128
|
+
const warn = deps.warn ?? (() => { });
|
|
129
|
+
const result = { status: false, comment: false };
|
|
130
|
+
try {
|
|
131
|
+
const res = await call(target, `/repos/${target.repo}/statuses/${target.sha}`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
body: {
|
|
134
|
+
state: checkState(outcome),
|
|
135
|
+
context: "TestKase Visual",
|
|
136
|
+
description: checkDescription(outcome),
|
|
137
|
+
target_url: outcome.reviewUrl,
|
|
138
|
+
},
|
|
139
|
+
}, f);
|
|
140
|
+
result.status = res.ok;
|
|
141
|
+
if (!res.ok)
|
|
142
|
+
warn(explain("post the check", res.status));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
warn("Couldn't reach GitHub to post the check.");
|
|
146
|
+
}
|
|
147
|
+
if (target.pullRequest) {
|
|
148
|
+
try {
|
|
149
|
+
result.comment = await upsertComment(target, outcome, f, warn);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
warn("Couldn't reach GitHub to leave a comment.");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* One comment, edited.
|
|
159
|
+
*
|
|
160
|
+
* ⚠️ A branch with twelve pushes must not collect twelve comments — that is how
|
|
161
|
+
* a useful tool becomes one people mute. We find our own by a hidden marker and
|
|
162
|
+
* edit it; a new one is posted only the first time.
|
|
163
|
+
*/
|
|
164
|
+
async function upsertComment(target, outcome, f, warn) {
|
|
165
|
+
const body = commentBody(outcome);
|
|
166
|
+
const list = await call(target, `/repos/${target.repo}/issues/${target.pullRequest}/comments?per_page=100`, { method: "GET" }, f);
|
|
167
|
+
if (list.ok) {
|
|
168
|
+
const comments = (await list.json());
|
|
169
|
+
const mine = comments.find((c) => c.body?.includes(exports.COMMENT_MARKER));
|
|
170
|
+
if (mine) {
|
|
171
|
+
const patched = await call(target, `/repos/${target.repo}/issues/comments/${mine.id}`, { method: "PATCH", body: { body } }, f);
|
|
172
|
+
if (!patched.ok)
|
|
173
|
+
warn(explain("update the comment", patched.status));
|
|
174
|
+
return patched.ok;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const posted = await call(target, `/repos/${target.repo}/issues/${target.pullRequest}/comments`, { method: "POST", body: { body } }, f);
|
|
178
|
+
if (!posted.ok)
|
|
179
|
+
warn(explain("leave a comment", posted.status));
|
|
180
|
+
return posted.ok;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Why GitHub said no, in words a person can act on.
|
|
184
|
+
*
|
|
185
|
+
* ⚠️ 403 is the one that will actually happen, and "403" tells nobody anything.
|
|
186
|
+
* The workflow needs permissions granted explicitly, and that line is what they
|
|
187
|
+
* are missing.
|
|
188
|
+
*/
|
|
189
|
+
function explain(what, status) {
|
|
190
|
+
if (status === 403 || status === 404) {
|
|
191
|
+
return `Couldn't ${what} on GitHub — the workflow needs "permissions: statuses: write, pull-requests: write".`;
|
|
192
|
+
}
|
|
193
|
+
if (status === 401)
|
|
194
|
+
return `Couldn't ${what} on GitHub — the token was refused.`;
|
|
195
|
+
return `Couldn't ${what} on GitHub (${status}).`;
|
|
196
|
+
}
|
package/dist/glob.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// glob.ts — just enough globbing for --files and --ignore.
|
|
3
|
+
//
|
|
4
|
+
// A dependency for this would be a supply-chain risk in a package customers run
|
|
5
|
+
// inside their CI with a token in the environment. The patterns people actually
|
|
6
|
+
// write here are `**/*.png`, `shots/**`, `**/*-mobile.png`, so that is what this
|
|
7
|
+
// supports: `**` across directories, `*` within one, `?` for a character.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.globToRegExp = globToRegExp;
|
|
10
|
+
exports.matchesAny = matchesAny;
|
|
11
|
+
exports.selectFiles = selectFiles;
|
|
12
|
+
function escapeLiteral(text) {
|
|
13
|
+
return text.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
14
|
+
}
|
|
15
|
+
/** A glob as a regular expression, anchored, matching forward-slash paths. */
|
|
16
|
+
function globToRegExp(pattern) {
|
|
17
|
+
let out = "";
|
|
18
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
19
|
+
const ch = pattern[i];
|
|
20
|
+
if (ch === "*") {
|
|
21
|
+
if (pattern[i + 1] === "*") {
|
|
22
|
+
i++;
|
|
23
|
+
// `a/**/b` should also match `a/b`, so swallow the slash with the stars.
|
|
24
|
+
if (pattern[i + 1] === "/") {
|
|
25
|
+
i++;
|
|
26
|
+
out += "(?:.*/)?";
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
out += ".*";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
out += "[^/]*";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (ch === "?") {
|
|
37
|
+
out += "[^/]";
|
|
38
|
+
}
|
|
39
|
+
else if (ch === "/") {
|
|
40
|
+
out += "/";
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
out += escapeLiteral(ch);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return new RegExp(`^${out}$`, "i");
|
|
47
|
+
}
|
|
48
|
+
function matchesAny(path, patterns) {
|
|
49
|
+
const normalised = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
50
|
+
return patterns.some((p) => globToRegExp(p.replace(/^\.\//, "")).test(normalised));
|
|
51
|
+
}
|
|
52
|
+
/** The files to upload: everything `--files` picks, minus everything `--ignore` does. */
|
|
53
|
+
function selectFiles(paths, include, ignore) {
|
|
54
|
+
return paths
|
|
55
|
+
.filter((p) => matchesAny(p, include))
|
|
56
|
+
.filter((p) => ignore.length === 0 || !matchesAny(p, ignore))
|
|
57
|
+
.sort();
|
|
58
|
+
}
|
package/dist/help.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// help.ts — the first thing anyone runs, so it is written to be read.
|
|
3
|
+
//
|
|
4
|
+
// Two things in here are not decoration. The full-page one-liners, because we
|
|
5
|
+
// do NOT take the picture in this model and a customer who uploads
|
|
6
|
+
// viewport-sized shots gets a tool that only ever checks the top of a page. And
|
|
7
|
+
// the sentence about filenames, because a suite whose filenames drift gets a
|
|
8
|
+
// tool that compares nothing at all and says everything is fine.
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.HELP = void 0;
|
|
11
|
+
exports.HELP = `
|
|
12
|
+
@testkase/visual — upload your test suite's screenshots and get told what changed.
|
|
13
|
+
|
|
14
|
+
npx @testkase/visual upload ./screenshots --project "Marketing"
|
|
15
|
+
|
|
16
|
+
Your tests take the pictures; this uploads them, compares each one against the
|
|
17
|
+
last approved version, and fails the build when something needs a person to look
|
|
18
|
+
at it. It works with any framework, because a folder of PNGs is not a framework.
|
|
19
|
+
|
|
20
|
+
REQUIRED
|
|
21
|
+
--project <name|id> The TestKase project these screens belong to. Never
|
|
22
|
+
guessed: the wrong project compares against the wrong
|
|
23
|
+
baselines and nobody notices for weeks.
|
|
24
|
+
TESTKASE_TOKEN Your access token, in the environment. Or --token.
|
|
25
|
+
|
|
26
|
+
THE FILENAME IS THE SCREEN NAME
|
|
27
|
+
shots/home.png is the screen "home", and next week's shots/home.png is
|
|
28
|
+
compared against it. Keep filenames stable. If they change, every picture
|
|
29
|
+
looks new: new baselines, nothing compared, build green, and this tool has
|
|
30
|
+
quietly stopped working. Run --dry-run first to see the names it will use.
|
|
31
|
+
|
|
32
|
+
TAKE THE WHOLE PAGE, NOT THE FIRST SCREENFUL
|
|
33
|
+
Playwright await page.screenshot({ path: "shots/home.png", fullPage: true })
|
|
34
|
+
Puppeteer await page.screenshot({ path: "shots/home.png", fullPage: true })
|
|
35
|
+
Cypress cy.screenshot("home", { capture: "fullPage" })
|
|
36
|
+
Selenium needs a helper, or CDP captureBeyondViewport
|
|
37
|
+
A page that grows taller still compares against its baseline: we match on the
|
|
38
|
+
picture's WIDTH, not its height, so adding a row to a table is a change to
|
|
39
|
+
review rather than a brand-new screen.
|
|
40
|
+
|
|
41
|
+
OPTIONS
|
|
42
|
+
--dry-run Print the names and upload nothing.
|
|
43
|
+
--name <name> Name a single file yourself.
|
|
44
|
+
--map <file> Name many: one "file = screen name" per line (or JSON).
|
|
45
|
+
Anything not listed keeps its filename.
|
|
46
|
+
--files <glob> Which files to upload. Default **/*.png
|
|
47
|
+
--ignore <glob> Which to skip. Repeatable, or comma-separated.
|
|
48
|
+
--browser <name> Default "upload". Pass it if you run the same suite on
|
|
49
|
+
two browsers — you want two sets of baselines.
|
|
50
|
+
--os <name> Default "ci".
|
|
51
|
+
--concurrency <n> Uploads at once. Default 5, most 10.
|
|
52
|
+
--build <id> Add to an existing build, for a sharded suite.
|
|
53
|
+
--build-name <name> Label the build.
|
|
54
|
+
--branch / --commit / --pr
|
|
55
|
+
Where this run came from. Read automatically from your
|
|
56
|
+
CI (GitHub Actions, GitLab, CircleCI, Bitbucket,
|
|
57
|
+
Buildkite, Jenkins) — pass these only if yours is not
|
|
58
|
+
recognised. Outside CI we say nothing rather than guess.
|
|
59
|
+
--no-finish Leave the build open. Shards 1..4 use this; the last one
|
|
60
|
+
doesn't, and that is the run that reports the result.
|
|
61
|
+
--exit-zero-on-changes
|
|
62
|
+
Always exit 0. For the first pipeline you add this to,
|
|
63
|
+
before anyone has approved anything.
|
|
64
|
+
--no-github Do not post the result to the pull request.
|
|
65
|
+
--api-url <url> Point at another Hub. You will not need this.
|
|
66
|
+
--app-url <url> Where the "Details" link should point.
|
|
67
|
+
|
|
68
|
+
ON A PULL REQUEST
|
|
69
|
+
Running inside GitHub Actions, this posts the result where the developer is
|
|
70
|
+
already looking: a check in the pull request's list, and one comment that is
|
|
71
|
+
edited on each run rather than added to. Give the job permission:
|
|
72
|
+
|
|
73
|
+
permissions:
|
|
74
|
+
statuses: write
|
|
75
|
+
pull-requests: write
|
|
76
|
+
env:
|
|
77
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
78
|
+
|
|
79
|
+
We hold no credentials of yours — this runs inside your own pipeline, which is
|
|
80
|
+
already logged in. If GitHub refuses, you get one line and the real result;
|
|
81
|
+
it will never fail your build on our behalf.
|
|
82
|
+
|
|
83
|
+
EXIT CODES
|
|
84
|
+
0 nothing to review
|
|
85
|
+
1 something changed, or a picture could not be uploaded
|
|
86
|
+
2 the command was wrong — nothing was created
|
|
87
|
+
|
|
88
|
+
A SHARDED SUITE
|
|
89
|
+
The first machine starts the build and leaves it open. It prints the id:
|
|
90
|
+
npx @testkase/visual upload ./shot-1 --project Marketing --no-finish
|
|
91
|
+
The rest join it, and the last one finishes it — that run is the one that
|
|
92
|
+
reports the result and sets the exit code:
|
|
93
|
+
npx @testkase/visual upload ./shot-2 --project Marketing --build <id> --no-finish
|
|
94
|
+
npx @testkase/visual upload ./shot-3 --project Marketing --build <id>
|
|
95
|
+
Without this, five machines make five builds and the reviewer sees five lists.
|
|
96
|
+
`.trim();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// index.ts — the command.
|
|
4
|
+
//
|
|
5
|
+
// Everything real happens in upload.ts; this is the part that talks to the
|
|
6
|
+
// operating system: reads the folder, prints the lines, picks the exit code.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const promises_1 = require("fs/promises");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
const args_1 = require("./args");
|
|
11
|
+
const api_1 = require("./api");
|
|
12
|
+
const upload_1 = require("./upload");
|
|
13
|
+
const help_1 = require("./help");
|
|
14
|
+
async function walk(folder) {
|
|
15
|
+
const out = [];
|
|
16
|
+
async function visit(dir) {
|
|
17
|
+
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const full = (0, path_1.join)(dir, entry.name);
|
|
20
|
+
if (entry.isDirectory())
|
|
21
|
+
await visit(full);
|
|
22
|
+
else if (entry.isFile())
|
|
23
|
+
out.push((0, path_1.relative)(folder, full).split(path_1.sep).join("/"));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
await visit(folder);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
async function main(argv) {
|
|
30
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
31
|
+
process.stdout.write(`${help_1.HELP}\n`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (argv.includes("--version")) {
|
|
35
|
+
process.stdout.write(`${require("../package.json").version}\n`);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
// `upload` is the only verb today, and optional, so both of these work:
|
|
39
|
+
// testkase-visual upload ./shots --project X
|
|
40
|
+
// testkase-visual ./shots --project X
|
|
41
|
+
const args = argv[0] === "upload" ? argv.slice(1) : argv;
|
|
42
|
+
const opts = (0, args_1.parseArgs)(args, process.env);
|
|
43
|
+
const api = new api_1.Api({ apiUrl: opts.apiUrl, token: opts.token });
|
|
44
|
+
const result = await (0, upload_1.run)(opts, {
|
|
45
|
+
api,
|
|
46
|
+
list: walk,
|
|
47
|
+
read: (path) => (0, promises_1.readFile)(path),
|
|
48
|
+
readText: (path) => (0, promises_1.readFile)(path, "utf8"),
|
|
49
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
50
|
+
});
|
|
51
|
+
return result.exitCode;
|
|
52
|
+
}
|
|
53
|
+
main(process.argv.slice(2))
|
|
54
|
+
.then((code) => {
|
|
55
|
+
process.exitCode = code;
|
|
56
|
+
})
|
|
57
|
+
.catch((err) => {
|
|
58
|
+
// ⚠️ The message only. A stack trace in a CI log tells a reader our file
|
|
59
|
+
// layout, and tells the person running it nothing they can act on.
|
|
60
|
+
if (err instanceof args_1.UsageError) {
|
|
61
|
+
process.stderr.write(`${err.message}\n\nRun testkase-visual --help.\n`);
|
|
62
|
+
process.exitCode = err.exitCode;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (err instanceof api_1.ApiError) {
|
|
66
|
+
process.stderr.write(`${err.message}\n`);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const code = err.code;
|
|
71
|
+
if (code === "ENOENT") {
|
|
72
|
+
process.stderr.write("I couldn't find that folder.\n");
|
|
73
|
+
process.exitCode = 2;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
process.stderr.write(`${err instanceof Error ? err.message : "Something went wrong."}\n`);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
});
|
package/dist/names.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// names.ts — which screen a file is.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ In this model the FILENAME is the identity. `shots/home.png` is the screen
|
|
5
|
+
// "home", and next week's `shots/home.png` is compared against it. That makes a
|
|
6
|
+
// renamed file the dangerous failure — forty new baselines, nothing compared,
|
|
7
|
+
// build green, and the tool has quietly stopped working.
|
|
8
|
+
//
|
|
9
|
+
// Which is why the transform below is deliberately boring: strip the directory,
|
|
10
|
+
// strip the extension, and stop. Anything cleverer — lower-casing, replacing
|
|
11
|
+
// dashes with spaces, trimming a numeric suffix — is a name that CHANGES when we
|
|
12
|
+
// improve it, and every baseline in every customer's project is orphaned by the
|
|
13
|
+
// improvement. `cart--empty.png` is the screen `cart--empty`.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.screenNameFor = screenNameFor;
|
|
16
|
+
exports.parseNameMap = parseNameMap;
|
|
17
|
+
exports.resolveName = resolveName;
|
|
18
|
+
const path_1 = require("path");
|
|
19
|
+
/** The screen name a file gets when nobody has said otherwise. */
|
|
20
|
+
function screenNameFor(file) {
|
|
21
|
+
const base = (0, path_1.basename)(file.replace(/\\/g, "/"));
|
|
22
|
+
const ext = (0, path_1.extname)(base);
|
|
23
|
+
return ext ? base.slice(0, -ext.length) : base;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A map file: one `path = screen name` per line, `#` comments, blank lines
|
|
27
|
+
* ignored. Also accepts JSON, because half of CI writes JSON and being strict
|
|
28
|
+
* about which buys nothing.
|
|
29
|
+
*
|
|
30
|
+
* Keys are matched against the path AS GIVEN and against its bare filename, so
|
|
31
|
+
* both `shots/home.png` and `home.png` work in the file.
|
|
32
|
+
*/
|
|
33
|
+
function parseNameMap(text) {
|
|
34
|
+
const trimmed = text.trim();
|
|
35
|
+
if (trimmed.startsWith("{")) {
|
|
36
|
+
const parsed = JSON.parse(trimmed);
|
|
37
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
38
|
+
throw new Error("The map file must be an object of file → screen name.");
|
|
39
|
+
}
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
42
|
+
if (typeof v !== "string")
|
|
43
|
+
throw new Error(`The map file's "${k}" is not a name.`);
|
|
44
|
+
out[normaliseKey(k)] = v.trim();
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
50
|
+
const row = line.trim();
|
|
51
|
+
if (!row || row.startsWith("#"))
|
|
52
|
+
continue;
|
|
53
|
+
const at = row.indexOf("=");
|
|
54
|
+
if (at < 0)
|
|
55
|
+
throw new Error(`The map file's line "${row}" is not "file = name".`);
|
|
56
|
+
const key = row.slice(0, at).trim();
|
|
57
|
+
const value = row.slice(at + 1).trim();
|
|
58
|
+
if (!key || !value)
|
|
59
|
+
throw new Error(`The map file's line "${row}" is not "file = name".`);
|
|
60
|
+
out[normaliseKey(key)] = value;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function normaliseKey(key) {
|
|
65
|
+
return key.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The name for one file, in the order the flags are documented:
|
|
69
|
+
* `--name` (a single file), then the map, then the filename.
|
|
70
|
+
*/
|
|
71
|
+
function resolveName(file, opts) {
|
|
72
|
+
if (opts.name)
|
|
73
|
+
return opts.name;
|
|
74
|
+
const map = opts.map ?? {};
|
|
75
|
+
const key = normaliseKey(file);
|
|
76
|
+
return map[key] ?? map[(0, path_1.basename)(key)] ?? screenNameFor(file);
|
|
77
|
+
}
|
package/dist/png.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// png.ts — how big is this picture?
|
|
3
|
+
//
|
|
4
|
+
// The viewport a baseline is keyed on (migration 113) is, for an uploaded
|
|
5
|
+
// image, the image's own size — so we have to read it before we can ask for a
|
|
6
|
+
// slot. A PNG's width and height are the two 32-bit integers at the start of the
|
|
7
|
+
// IHDR chunk, always the first chunk, always at the same offset. That is eight
|
|
8
|
+
// bytes of a file we already have in memory, which is the entire reason this
|
|
9
|
+
// tool has no image dependency.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.pngSize = pngSize;
|
|
12
|
+
const MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
13
|
+
/** The picture's size, or an explanation of why it isn't a PNG. */
|
|
14
|
+
function pngSize(buf) {
|
|
15
|
+
if (buf.length < 24 || !buf.subarray(0, 8).equals(MAGIC)) {
|
|
16
|
+
throw new Error("that isn't a PNG");
|
|
17
|
+
}
|
|
18
|
+
if (buf.subarray(12, 16).toString("ascii") !== "IHDR") {
|
|
19
|
+
throw new Error("that PNG is damaged");
|
|
20
|
+
}
|
|
21
|
+
const width = buf.readUInt32BE(16);
|
|
22
|
+
const height = buf.readUInt32BE(20);
|
|
23
|
+
if (width < 1 || height < 1)
|
|
24
|
+
throw new Error("that PNG has no size");
|
|
25
|
+
return { width, height };
|
|
26
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// upload.ts — the run itself, with everything it touches injected.
|
|
3
|
+
//
|
|
4
|
+
// The file system, the clock, the API and where output goes all arrive as
|
|
5
|
+
// arguments, so the whole loop is testable without a network, a folder or a
|
|
6
|
+
// Hub — which matters because the interesting cases here are the failures.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.run = run;
|
|
9
|
+
const api_1 = require("./api");
|
|
10
|
+
const png_1 = require("./png");
|
|
11
|
+
const names_1 = require("./names");
|
|
12
|
+
const glob_1 = require("./glob");
|
|
13
|
+
const ci_1 = require("./ci");
|
|
14
|
+
const github_1 = require("./github");
|
|
15
|
+
/** How many pictures are in flight at once. Bounded, so a big folder cannot open
|
|
16
|
+
* a thousand sockets and be throttled into looking broken. */
|
|
17
|
+
async function inParallel(items, limit, work) {
|
|
18
|
+
let next = 0;
|
|
19
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
20
|
+
for (;;) {
|
|
21
|
+
const index = next++;
|
|
22
|
+
if (index >= items.length)
|
|
23
|
+
return;
|
|
24
|
+
await work(items[index]);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
await Promise.all(runners);
|
|
28
|
+
}
|
|
29
|
+
async function run(opts, deps) {
|
|
30
|
+
const all = await deps.list(opts.folder);
|
|
31
|
+
const files = (0, glob_1.selectFiles)(all, opts.files, opts.ignore);
|
|
32
|
+
if (files.length === 0) {
|
|
33
|
+
deps.out(`No pictures matched ${opts.files.join(", ")} in ${opts.folder}.`);
|
|
34
|
+
return { outcomes: [], exitCode: 0 };
|
|
35
|
+
}
|
|
36
|
+
const map = opts.mapFile ? (0, names_1.parseNameMap)(await deps.readText(opts.mapFile)) : undefined;
|
|
37
|
+
// --name is for ONE file. Applying it to forty would give forty pictures the
|
|
38
|
+
// same identity and the last one uploaded would win, silently.
|
|
39
|
+
if (opts.name && files.length > 1) {
|
|
40
|
+
throw new api_1.ApiError(0, "NAME_FOR_ONE", "--name works on a single file. For a folder, use --map.");
|
|
41
|
+
}
|
|
42
|
+
const named = files.map((file) => ({ file, screenName: (0, names_1.resolveName)(file, { name: opts.name, map }) }));
|
|
43
|
+
const duplicates = findDuplicates(named);
|
|
44
|
+
if (duplicates.length) {
|
|
45
|
+
// Two files, one identity: the second overwrites the first's comparison and
|
|
46
|
+
// the build looks complete. Better to stop than to report a lie.
|
|
47
|
+
throw new api_1.ApiError(0, "DUPLICATE_NAME", `Two files would be the same screen: ${duplicates[0]}. Rename one, or use --map.`);
|
|
48
|
+
}
|
|
49
|
+
if (opts.dryRun) {
|
|
50
|
+
deps.out(`${named.length} pictures would be uploaded to project "${opts.project}":`);
|
|
51
|
+
for (const item of named)
|
|
52
|
+
deps.out(` ${item.file} → ${item.screenName}`);
|
|
53
|
+
deps.out("");
|
|
54
|
+
deps.out("Nothing was uploaded. Check the names on the right: they are what a");
|
|
55
|
+
deps.out("screen is matched by, so they must stay the same between runs.");
|
|
56
|
+
return { outcomes: [], exitCode: 0 };
|
|
57
|
+
}
|
|
58
|
+
// Where this run came from. Recorded on the build, and shown so a person
|
|
59
|
+
// can see at a glance which code produced these pictures.
|
|
60
|
+
const from = (0, ci_1.provenance)({ branch: opts.branch, commit: opts.commit, pullRequest: opts.pullRequest }, deps.env ?? process.env);
|
|
61
|
+
const build = opts.build
|
|
62
|
+
? { buildId: opts.build, number: null, projectName: opts.project, remaining: NaN }
|
|
63
|
+
: await deps.api.startBuild(opts.project, opts.buildName, from);
|
|
64
|
+
deps.out(build.number == null
|
|
65
|
+
? `Adding to build ${build.buildId}.`
|
|
66
|
+
: `Build ${build.number} of "${build.projectName}" — ${named.length} pictures.`);
|
|
67
|
+
const where = (0, ci_1.describe)(from);
|
|
68
|
+
if (where)
|
|
69
|
+
deps.out(` ${where}`);
|
|
70
|
+
const outcomes = [];
|
|
71
|
+
await inParallel(named, opts.concurrency, async (item) => {
|
|
72
|
+
try {
|
|
73
|
+
const bytes = await deps.read(joinPath(opts.folder, item.file));
|
|
74
|
+
const size = (0, png_1.pngSize)(bytes);
|
|
75
|
+
const slot = await deps.api.requestSlot({
|
|
76
|
+
buildId: build.buildId,
|
|
77
|
+
screenName: item.screenName,
|
|
78
|
+
width: size.width,
|
|
79
|
+
height: size.height,
|
|
80
|
+
browser: opts.browser,
|
|
81
|
+
os: opts.os,
|
|
82
|
+
});
|
|
83
|
+
await deps.api.putImage(slot.uploadUrl, bytes);
|
|
84
|
+
const result = await deps.api.confirm(slot.snapshotId, slot.slot);
|
|
85
|
+
outcomes.push({
|
|
86
|
+
file: item.file,
|
|
87
|
+
screenName: item.screenName,
|
|
88
|
+
status: result.status,
|
|
89
|
+
diffRatio: result.diffRatio,
|
|
90
|
+
// A "failed" line with nothing after it tells nobody anything. The
|
|
91
|
+
// first QA run printed three of them.
|
|
92
|
+
error: result.status === "failed" ? explain(result.failure) : undefined,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// One bad picture does not abandon thirty-nine good ones. It is reported,
|
|
97
|
+
// and it makes the run fail at the end.
|
|
98
|
+
outcomes.push({
|
|
99
|
+
file: item.file,
|
|
100
|
+
screenName: item.screenName,
|
|
101
|
+
status: "error",
|
|
102
|
+
error: err instanceof Error ? err.message : "something went wrong",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
outcomes.sort((a, b) => a.screenName.localeCompare(b.screenName));
|
|
107
|
+
report(outcomes, deps.out);
|
|
108
|
+
if (!opts.finish) {
|
|
109
|
+
deps.out("");
|
|
110
|
+
deps.out(`Build ${build.buildId} is still open — finish it on the last shard.`);
|
|
111
|
+
return { buildId: build.buildId, outcomes, exitCode: failures(outcomes) > 0 ? 1 : 0 };
|
|
112
|
+
}
|
|
113
|
+
const summary = await deps.api.finishBuild(build.buildId);
|
|
114
|
+
deps.out("");
|
|
115
|
+
deps.out(`${summary.total} screens · ${summary.changed} changed · ${summary.newBaselines} new · ${summary.unreviewed} to review`);
|
|
116
|
+
// ⚠️ The silent failure this tool is most likely to have. Loud on purpose.
|
|
117
|
+
if (summary.renamedFilesSuspected) {
|
|
118
|
+
deps.out("");
|
|
119
|
+
deps.out(`⚠ ${summary.newBaselines} new screens, 0 matched an existing one.`);
|
|
120
|
+
deps.out(" If your test filenames changed, these are new baselines and nothing");
|
|
121
|
+
deps.out(" was compared. The filename is the screen's identity — keep it stable,");
|
|
122
|
+
deps.out(" or pin the names with --map.");
|
|
123
|
+
}
|
|
124
|
+
const failed = failures(outcomes);
|
|
125
|
+
if (failed > 0)
|
|
126
|
+
deps.out(`${failed} could not be uploaded.`);
|
|
127
|
+
// Tell the pull request, if this run is one. Best-effort in every direction:
|
|
128
|
+
// a tool that broke a pipeline because it could not leave a comment would be
|
|
129
|
+
// worse than one that stayed quiet.
|
|
130
|
+
if (opts.github) {
|
|
131
|
+
const target = (0, github_1.githubTarget)(deps.env ?? process.env, from.pullRequest);
|
|
132
|
+
if (target) {
|
|
133
|
+
const posted = await (0, github_1.postToPullRequest)(target, {
|
|
134
|
+
total: summary.total,
|
|
135
|
+
changed: summary.changed,
|
|
136
|
+
newBaselines: summary.newBaselines,
|
|
137
|
+
unreviewed: summary.unreviewed,
|
|
138
|
+
failed,
|
|
139
|
+
reviewUrl: `${opts.appUrl}/visual/builds?buildId=${build.buildId}`,
|
|
140
|
+
}, { fetchImpl: deps.fetchImpl, warn: deps.out });
|
|
141
|
+
if (posted.status || posted.comment)
|
|
142
|
+
deps.out("Posted the result to GitHub.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const needsReview = summary.unreviewed > 0 || failed > 0;
|
|
146
|
+
return {
|
|
147
|
+
buildId: build.buildId,
|
|
148
|
+
outcomes,
|
|
149
|
+
summary,
|
|
150
|
+
exitCode: opts.exitZeroOnChanges ? (failed > 0 ? 1 : 0) : needsReview ? 1 : 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A comparison that did not happen, in words.
|
|
155
|
+
*
|
|
156
|
+
* The picture is safely stored in every one of these cases, which is the part
|
|
157
|
+
* worth saying — the run can simply be repeated.
|
|
158
|
+
*/
|
|
159
|
+
function explain(reason) {
|
|
160
|
+
switch (reason) {
|
|
161
|
+
case "size_mismatch":
|
|
162
|
+
return "different width to its baseline — approve it as a new baseline in Review";
|
|
163
|
+
case "compare_unavailable":
|
|
164
|
+
case "no_node_available":
|
|
165
|
+
return "we couldn't compare it just now; the picture is saved, try the run again";
|
|
166
|
+
default:
|
|
167
|
+
return reason ? `couldn't compare it (${reason})` : "couldn't compare it";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function failures(outcomes) {
|
|
171
|
+
return outcomes.filter((o) => o.status === "error" || o.status === "failed").length;
|
|
172
|
+
}
|
|
173
|
+
function findDuplicates(named) {
|
|
174
|
+
const seen = new Map();
|
|
175
|
+
const clashes = [];
|
|
176
|
+
for (const item of named) {
|
|
177
|
+
const first = seen.get(item.screenName);
|
|
178
|
+
if (first)
|
|
179
|
+
clashes.push(`"${item.screenName}" (${first} and ${item.file})`);
|
|
180
|
+
else
|
|
181
|
+
seen.set(item.screenName, item.file);
|
|
182
|
+
}
|
|
183
|
+
return clashes;
|
|
184
|
+
}
|
|
185
|
+
const LABEL = {
|
|
186
|
+
baseline: "new",
|
|
187
|
+
identical: "same",
|
|
188
|
+
changed: "CHANGED",
|
|
189
|
+
failed: "failed",
|
|
190
|
+
error: "error",
|
|
191
|
+
};
|
|
192
|
+
function report(outcomes, out) {
|
|
193
|
+
const width = Math.min(48, Math.max(...outcomes.map((o) => o.screenName.length), 6));
|
|
194
|
+
for (const o of outcomes) {
|
|
195
|
+
const name = o.screenName.padEnd(width);
|
|
196
|
+
const detail = o.status === "changed" && typeof o.diffRatio === "number"
|
|
197
|
+
? ` ${(o.diffRatio * 100).toFixed(2)}% different`
|
|
198
|
+
: o.error
|
|
199
|
+
? ` ${o.error}`
|
|
200
|
+
: "";
|
|
201
|
+
out(` ${name} ${LABEL[o.status]}${detail}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function joinPath(folder, file) {
|
|
205
|
+
const base = folder.replace(/[\\/]+$/, "");
|
|
206
|
+
return `${base}/${file}`;
|
|
207
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@testkase/visual",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Upload your test suite's screenshots to TestKase Visual and get told what changed.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"testkase-visual": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"prepublishOnly": "npm run build && npm test"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.10.0",
|
|
27
|
+
"typescript": "^5.7.0",
|
|
28
|
+
"vitest": "^3.0.0"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/SolvionIT/testkase-visual.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://testkase.com/docs/visual/ci-uploads",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"testkase",
|
|
37
|
+
"visual-regression",
|
|
38
|
+
"visual-testing",
|
|
39
|
+
"screenshot-testing",
|
|
40
|
+
"playwright",
|
|
41
|
+
"cypress",
|
|
42
|
+
"puppeteer",
|
|
43
|
+
"selenium",
|
|
44
|
+
"ci-cd"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|