@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 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
+ }