@vizzly-testing/cli 0.22.2 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,47 @@
4
4
  * Generic functions to extract git and PR information from any CI provider
5
5
  */
6
6
 
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ // Cache for GitHub Actions event payload to avoid re-reading the file
10
+ let _githubEventCache = null;
11
+
12
+ /**
13
+ * Read and parse the GitHub Actions event payload from GITHUB_EVENT_PATH.
14
+ *
15
+ * GitHub Actions sets GITHUB_EVENT_PATH to a file containing the full webhook
16
+ * payload that triggered the workflow. This is essential for pull_request and
17
+ * pull_request_target events because GITHUB_SHA points to a merge commit,
18
+ * not the actual head commit.
19
+ *
20
+ * @returns {Object} Parsed event payload or empty object on failure
21
+ */
22
+ export function getGitHubEvent() {
23
+ if (_githubEventCache !== null) {
24
+ return _githubEventCache;
25
+ }
26
+ let eventPath = process.env.GITHUB_EVENT_PATH;
27
+ if (!eventPath) {
28
+ _githubEventCache = {};
29
+ return _githubEventCache;
30
+ }
31
+ try {
32
+ let content = readFileSync(eventPath, 'utf8');
33
+ _githubEventCache = JSON.parse(content);
34
+ } catch {
35
+ // File doesn't exist or invalid JSON - fail silently with empty object
36
+ _githubEventCache = {};
37
+ }
38
+ return _githubEventCache;
39
+ }
40
+
41
+ /**
42
+ * Reset the GitHub event cache. Useful for testing.
43
+ */
44
+ export function resetGitHubEventCache() {
45
+ _githubEventCache = null;
46
+ }
47
+
7
48
  /**
8
49
  * Get the branch name from CI environment variables
9
50
  * @returns {string|null} Branch name or null if not available
@@ -45,15 +86,41 @@ export function getBranch() {
45
86
  }
46
87
 
47
88
  /**
48
- * Get the commit SHA from CI environment variables
89
+ * Get the commit SHA from CI environment variables.
90
+ *
91
+ * IMPORTANT: For GitHub Actions pull_request events, GITHUB_SHA points to a
92
+ * temporary merge commit, NOT the actual head commit of the PR. This function
93
+ * reads the event payload from GITHUB_EVENT_PATH to extract the correct SHA.
94
+ *
95
+ * See: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
96
+ * "GITHUB_SHA for this event is the last merge commit of the pull request merge branch.
97
+ * If you want to get the commit ID for the last commit to the head branch of the
98
+ * pull request, use github.event.pull_request.head.sha instead."
99
+ *
49
100
  * @returns {string|null} Commit SHA or null if not available
50
101
  */
51
102
  export function getCommit() {
52
- return process.env.VIZZLY_COMMIT_SHA ||
53
- // Vizzly override
54
- process.env.GITHUB_SHA ||
55
- // GitHub Actions
56
- process.env.CI_COMMIT_SHA ||
103
+ // Vizzly override always takes priority
104
+ if (process.env.VIZZLY_COMMIT_SHA) {
105
+ return process.env.VIZZLY_COMMIT_SHA;
106
+ }
107
+
108
+ // GitHub Actions: extract the correct SHA based on event type
109
+ if (process.env.GITHUB_ACTIONS) {
110
+ let event = getGitHubEvent();
111
+
112
+ // For pull_request events, use the actual head commit SHA (not the merge commit)
113
+ // The event payload contains pull_request.head.sha which is what we want
114
+ if (event.pull_request?.head?.sha) {
115
+ return event.pull_request.head.sha;
116
+ }
117
+
118
+ // For push events or if event parsing failed, GITHUB_SHA is correct
119
+ return process.env.GITHUB_SHA || null;
120
+ }
121
+
122
+ // Other CI providers
123
+ return process.env.CI_COMMIT_SHA ||
57
124
  // GitLab CI
58
125
  process.env.CIRCLE_SHA1 ||
59
126
  // CircleCI
@@ -170,15 +237,30 @@ export function getPullRequestNumber() {
170
237
  }
171
238
 
172
239
  /**
173
- * Get the PR head SHA from CI environment variables
240
+ * Get the PR head SHA from CI environment variables.
241
+ *
242
+ * For GitHub Actions, this reads from the event payload to get the actual
243
+ * head commit SHA, not the merge commit that GITHUB_SHA points to.
244
+ *
174
245
  * @returns {string|null} PR head SHA or null if not available
175
246
  */
176
247
  export function getPullRequestHeadSha() {
177
- return process.env.VIZZLY_PR_HEAD_SHA ||
178
- // Vizzly override
179
- process.env.GITHUB_SHA ||
180
- // GitHub Actions
181
- process.env.CI_COMMIT_SHA ||
248
+ // Vizzly override always takes priority
249
+ if (process.env.VIZZLY_PR_HEAD_SHA) {
250
+ return process.env.VIZZLY_PR_HEAD_SHA;
251
+ }
252
+
253
+ // GitHub Actions: extract from event payload for PRs
254
+ if (process.env.GITHUB_ACTIONS) {
255
+ let event = getGitHubEvent();
256
+ if (event.pull_request?.head?.sha) {
257
+ return event.pull_request.head.sha;
258
+ }
259
+ return process.env.GITHUB_SHA || null;
260
+ }
261
+
262
+ // Other CI providers
263
+ return process.env.CI_COMMIT_SHA ||
182
264
  // GitLab CI
183
265
  process.env.CIRCLE_SHA1 ||
184
266
  // CircleCI
@@ -200,13 +282,29 @@ export function getPullRequestHeadSha() {
200
282
  }
201
283
 
202
284
  /**
203
- * Get the PR base SHA from CI environment variables
285
+ * Get the PR base SHA from CI environment variables.
286
+ *
287
+ * For GitHub Actions, this reads from the event payload to get the base
288
+ * branch SHA that the PR is targeting.
289
+ *
204
290
  * @returns {string|null} PR base SHA or null if not available
205
291
  */
206
292
  export function getPullRequestBaseSha() {
207
- return process.env.VIZZLY_PR_BASE_SHA ||
208
- // Vizzly override
209
- process.env.CI_MERGE_REQUEST_TARGET_BRANCH_SHA ||
293
+ // Vizzly override always takes priority
294
+ if (process.env.VIZZLY_PR_BASE_SHA) {
295
+ return process.env.VIZZLY_PR_BASE_SHA;
296
+ }
297
+
298
+ // GitHub Actions: extract from event payload
299
+ if (process.env.GITHUB_ACTIONS) {
300
+ let event = getGitHubEvent();
301
+ if (event.pull_request?.base?.sha) {
302
+ return event.pull_request.base.sha;
303
+ }
304
+ }
305
+
306
+ // Other CI providers
307
+ return process.env.CI_MERGE_REQUEST_TARGET_BRANCH_SHA ||
210
308
  // GitLab CI
211
309
  null // Most CIs don't provide this
212
310
  ;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Session management for build context
3
+ *
4
+ * Tracks the current build ID so subsequent commands (like `vizzly preview`)
5
+ * can automatically attach to the right build without passing IDs around.
6
+ *
7
+ * Two mechanisms:
8
+ * 1. Session file (.vizzly/session.json) - for local dev and same CI job
9
+ * 2. GitHub Actions env ($GITHUB_ENV) - for cross-step persistence in GHA
10
+ */
11
+
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ let SESSION_DIR = '.vizzly';
15
+ let SESSION_FILE = 'session.json';
16
+ let SESSION_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
17
+
18
+ /**
19
+ * Get the session file path
20
+ * @param {string} [cwd] - Working directory (defaults to process.cwd())
21
+ * @returns {string} Path to session file
22
+ */
23
+ export function getSessionPath(cwd = process.cwd()) {
24
+ return join(cwd, SESSION_DIR, SESSION_FILE);
25
+ }
26
+
27
+ /**
28
+ * Write build context to session file and GitHub Actions env
29
+ *
30
+ * @param {Object} context - Build context
31
+ * @param {string} context.buildId - The build ID
32
+ * @param {string} [context.branch] - Git branch
33
+ * @param {string} [context.commit] - Git commit SHA
34
+ * @param {string} [context.parallelId] - Parallel build ID
35
+ * @param {Object} [options] - Options
36
+ * @param {string} [options.cwd] - Working directory
37
+ * @param {Object} [options.env] - Environment variables (defaults to process.env)
38
+ */
39
+ export function writeSession(context, options = {}) {
40
+ let {
41
+ cwd = process.cwd(),
42
+ env = process.env
43
+ } = options;
44
+ let session = {
45
+ buildId: context.buildId,
46
+ branch: context.branch || null,
47
+ commit: context.commit || null,
48
+ parallelId: context.parallelId || null,
49
+ createdAt: new Date().toISOString()
50
+ };
51
+
52
+ // Write session file
53
+ let sessionPath = getSessionPath(cwd);
54
+ let sessionDir = dirname(sessionPath);
55
+ if (!existsSync(sessionDir)) {
56
+ mkdirSync(sessionDir, {
57
+ recursive: true
58
+ });
59
+ }
60
+ writeFileSync(sessionPath, `${JSON.stringify(session, null, 2)}\n`, {
61
+ mode: 0o600
62
+ });
63
+
64
+ // Write to GitHub Actions environment
65
+ if (env.GITHUB_ENV) {
66
+ appendFileSync(env.GITHUB_ENV, `VIZZLY_BUILD_ID=${context.buildId}\n`);
67
+ }
68
+
69
+ // Also write to GitHub Actions output if we're in a step
70
+ if (env.GITHUB_OUTPUT) {
71
+ appendFileSync(env.GITHUB_OUTPUT, `build-id=${context.buildId}\n`);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Read build context from session file or environment
77
+ *
78
+ * Priority:
79
+ * 1. VIZZLY_BUILD_ID environment variable
80
+ * 2. Session file (if recent and optionally matching branch)
81
+ *
82
+ * @param {Object} [options] - Options
83
+ * @param {string} [options.cwd] - Working directory
84
+ * @param {string} [options.currentBranch] - Current git branch (for validation)
85
+ * @param {Object} [options.env] - Environment variables
86
+ * @param {number} [options.maxAgeMs] - Max session age in ms
87
+ * @returns {Object|null} Session context or null if not found/invalid
88
+ */
89
+ export function readSession(options = {}) {
90
+ let {
91
+ cwd = process.cwd(),
92
+ currentBranch = null,
93
+ env = process.env,
94
+ maxAgeMs = SESSION_MAX_AGE_MS
95
+ } = options;
96
+
97
+ // Check environment variable first
98
+ if (env.VIZZLY_BUILD_ID) {
99
+ return {
100
+ buildId: env.VIZZLY_BUILD_ID,
101
+ source: 'environment'
102
+ };
103
+ }
104
+
105
+ // Try session file
106
+ let sessionPath = getSessionPath(cwd);
107
+ if (!existsSync(sessionPath)) {
108
+ return null;
109
+ }
110
+ try {
111
+ let content = readFileSync(sessionPath, 'utf-8');
112
+ let session = JSON.parse(content);
113
+
114
+ // Validate required field
115
+ if (!session.buildId) {
116
+ return null;
117
+ }
118
+
119
+ // Check age
120
+ let createdAt = new Date(session.createdAt);
121
+ let age = Date.now() - createdAt.getTime();
122
+ if (age > maxAgeMs) {
123
+ return {
124
+ ...session,
125
+ source: 'session_file',
126
+ expired: true,
127
+ age
128
+ };
129
+ }
130
+
131
+ // Check branch match (if current branch provided)
132
+ let branchMismatch = false;
133
+ if (currentBranch && session.branch && session.branch !== currentBranch) {
134
+ branchMismatch = true;
135
+ }
136
+ return {
137
+ ...session,
138
+ source: 'session_file',
139
+ expired: false,
140
+ branchMismatch,
141
+ age
142
+ };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Clear the session file
150
+ *
151
+ * @param {Object} [options] - Options
152
+ * @param {string} [options.cwd] - Working directory
153
+ */
154
+ export function clearSession(options = {}) {
155
+ let {
156
+ cwd = process.cwd()
157
+ } = options;
158
+ let sessionPath = getSessionPath(cwd);
159
+ try {
160
+ if (existsSync(sessionPath)) {
161
+ writeFileSync(sessionPath, '');
162
+ }
163
+ } catch {
164
+ // Ignore errors
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Format session age for display
170
+ *
171
+ * @param {number} ageMs - Age in milliseconds
172
+ * @returns {string} Human-readable age
173
+ */
174
+ export function formatSessionAge(ageMs) {
175
+ let seconds = Math.floor(ageMs / 1000);
176
+ let minutes = Math.floor(seconds / 60);
177
+ let hours = Math.floor(minutes / 60);
178
+ if (hours > 0) {
179
+ return `${hours}h ${minutes % 60}m ago`;
180
+ }
181
+ if (minutes > 0) {
182
+ return `${minutes}m ago`;
183
+ }
184
+ return `${seconds}s ago`;
185
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizzly-testing/cli",
3
- "version": "0.22.2",
3
+ "version": "0.23.1",
4
4
  "description": "Visual review platform for UI developers and designers",
5
5
  "keywords": [
6
6
  "visual-testing",