snapdrift 0.8.2 → 0.9.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/lib/provider.mjs CHANGED
@@ -64,7 +64,7 @@ export class LocalProvider {
64
64
  * Build a PR comment body from a diff summary.
65
65
  * LocalProvider omits the dashboard link (no dashboard exists).
66
66
  *
67
- * @param {Record<string, unknown>} summary
67
+ * @param {import('@snapdrift/manifest').VisualReportSummary} summary
68
68
  * @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
69
69
  * @returns {string}
70
70
  */
@@ -73,7 +73,7 @@ export class LocalProvider {
73
73
  }
74
74
  }
75
75
 
76
- /** @type {Record<string, (config?: VisualRegressionConfig) => VisualProvider>} */
76
+ /** @type {{ local: (config?: VisualRegressionConfig) => LocalProvider, snap: (config: VisualRegressionConfig) => SnapProvider }} */
77
77
  const PROVIDER_FACTORIES = {
78
78
  local: () => new LocalProvider(),
79
79
  snap: (config) => {
@@ -84,6 +84,24 @@ const PROVIDER_FACTORIES = {
84
84
  }
85
85
  };
86
86
 
87
+ /**
88
+ * @overload
89
+ * @param {'snap'} providerName
90
+ * @param {VisualRegressionConfig} config
91
+ * @returns {SnapProvider}
92
+ */
93
+ /**
94
+ * @overload
95
+ * @param {'local'} providerName
96
+ * @param {VisualRegressionConfig} [config]
97
+ * @returns {LocalProvider}
98
+ */
99
+ /**
100
+ * @overload
101
+ * @param {string} providerName
102
+ * @param {VisualRegressionConfig} [config]
103
+ * @returns {VisualProvider}
104
+ */
87
105
  /**
88
106
  * Create a VisualProvider instance by name.
89
107
  *
@@ -0,0 +1,147 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {{
5
+ * found: boolean,
6
+ * resolutionStatus: 'found' | 'missing',
7
+ * artifactName: string,
8
+ * headSha?: string,
9
+ * runId?: string,
10
+ * message: string
11
+ * }} BaselineResolution
12
+ */
13
+
14
+ /**
15
+ * Resolve the most recent successful, non-expired SnapDrift baseline artifact.
16
+ *
17
+ * The GitHub API calls are kept here so the standalone resolver and the PR
18
+ * wrapper share the same response validation and pagination behavior.
19
+ *
20
+ * @param {{
21
+ * github: {
22
+ * paginate: Function,
23
+ * rest: { actions: {
24
+ * listWorkflowRuns: Function,
25
+ * listWorkflowRunArtifacts: Function
26
+ * }}
27
+ * },
28
+ * repository: string,
29
+ * workflowId: string,
30
+ * branch: string,
31
+ * artifactName: string
32
+ * }} options
33
+ * @returns {Promise<BaselineResolution>}
34
+ */
35
+ export async function resolveBaselineArtifact({
36
+ github,
37
+ repository,
38
+ workflowId,
39
+ branch,
40
+ artifactName
41
+ }) {
42
+ const { owner, repo } = parseBaselineRepository(repository);
43
+ if (typeof artifactName !== 'string' || artifactName.trim().length === 0) {
44
+ throw new Error('Invalid baseline artifact name. Expected a non-empty name.');
45
+ }
46
+
47
+ // Search the complete successful-run history so an older, still-valid
48
+ // baseline remains discoverable; 100 keeps that intentional search to the
49
+ // fewest API pages GitHub allows.
50
+ const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
51
+ owner,
52
+ repo,
53
+ workflow_id: workflowId,
54
+ branch,
55
+ event: 'push',
56
+ per_page: 100
57
+ });
58
+
59
+ if (!Array.isArray(runs)) {
60
+ throw new Error('GitHub returned an invalid workflow run list.');
61
+ }
62
+
63
+ for (const run of runs) {
64
+ validateWorkflowRun(run);
65
+
66
+ // GitHub returns a null conclusion for queued and in-progress runs. They
67
+ // are valid records and must be ignored before inspecting terminal fields.
68
+ if (run.status !== 'completed') {
69
+ continue;
70
+ }
71
+ if (typeof run.conclusion !== 'string') {
72
+ throw new Error(`GitHub returned a malformed completed workflow run record for run ${run.id}.`);
73
+ }
74
+ if (run.conclusion !== 'success') {
75
+ continue;
76
+ }
77
+
78
+ const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
79
+ owner,
80
+ repo,
81
+ run_id: run.id,
82
+ per_page: 100
83
+ });
84
+ if (!Array.isArray(artifacts)) {
85
+ throw new Error(`GitHub returned a malformed artifact list for workflow run ${run.id}.`);
86
+ }
87
+
88
+ // Only the artifact SnapDrift depends on needs strict field validation.
89
+ // Other artifacts in the same run belong to the consumer workflow and may
90
+ // legitimately have a different shape.
91
+ const namedArtifacts = artifacts.filter((artifact) => (
92
+ artifact && typeof artifact === 'object' && artifact.name === artifactName
93
+ ));
94
+ for (const artifact of namedArtifacts) {
95
+ if (typeof artifact.expired !== 'boolean') {
96
+ throw new Error(`GitHub returned a malformed baseline artifact record for workflow run ${run.id}.`);
97
+ }
98
+ }
99
+
100
+ const match = namedArtifacts.find((artifact) => !artifact.expired);
101
+ if (match) {
102
+ return {
103
+ found: true,
104
+ resolutionStatus: 'found',
105
+ artifactName: match.name,
106
+ headSha: run.head_sha,
107
+ runId: String(run.id),
108
+ message: ''
109
+ };
110
+ }
111
+ }
112
+
113
+ return {
114
+ found: false,
115
+ resolutionStatus: 'missing',
116
+ artifactName,
117
+ message: `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`
118
+ };
119
+ }
120
+
121
+ /**
122
+ * @param {string} repository
123
+ * @returns {{owner: string, repo: string}}
124
+ */
125
+ export function parseBaselineRepository(repository) {
126
+ const value = String(repository || '').trim();
127
+ const parts = value.split('/');
128
+ if (parts.length !== 2 || parts.some((part) => part.trim().length === 0)) {
129
+ throw new Error(`Invalid baseline repository "${value}". Expected owner/name.`);
130
+ }
131
+ return { owner: parts[0].trim(), repo: parts[1].trim() };
132
+ }
133
+
134
+ /**
135
+ * @param {unknown} run
136
+ * @returns {asserts run is {id: number, status: string, head_sha: string, conclusion?: unknown}}
137
+ */
138
+ function validateWorkflowRun(run) {
139
+ if (!run || typeof run !== 'object') {
140
+ throw new Error('GitHub returned a malformed workflow run record.');
141
+ }
142
+ const record = /** @type {Record<string, unknown>} */ (run);
143
+ if (typeof record.id !== 'number' || !Number.isInteger(record.id) || record.id <= 0 ||
144
+ typeof record.status !== 'string' || typeof record.head_sha !== 'string' || record.head_sha.length === 0) {
145
+ throw new Error('GitHub returned a malformed workflow run record.');
146
+ }
147
+ }
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * @typedef {{
7
+ * runDir: string,
8
+ * resultsFile: string,
9
+ * manifestFile: string,
10
+ * screenshotsDir: string,
11
+ * }} BaselinePaths
12
+ */
13
+
14
+ /**
15
+ * @typedef {{
16
+ * workspace: string,
17
+ * downloadPath?: string,
18
+ * }} ResolveBaselinePathsParams
19
+ */
20
+
21
+ const DEFAULT_DOWNLOAD_PATH = 'snapdrift-baseline-download';
22
+
23
+ /**
24
+ * Resolve the baseline download paths for the resolve-baseline composite action.
25
+ *
26
+ * `downloadPath` may be absolute (used directly) or relative to the Actions
27
+ * workspace (prefixed with `workspace`). Returns the run dir plus the results,
28
+ * manifest, and screenshots paths derived from it.
29
+ *
30
+ * @param {ResolveBaselinePathsParams} params
31
+ * @returns {BaselinePaths}
32
+ */
33
+ export function resolveBaselinePaths({ workspace, downloadPath }) {
34
+ const rel = downloadPath || DEFAULT_DOWNLOAD_PATH;
35
+ const runDir = path.isAbsolute(rel) ? rel : path.join(workspace, rel);
36
+ const resultsFile = path.join(runDir, 'results.json');
37
+ const manifestFile = path.join(runDir, 'manifest.json');
38
+ const screenshotsDir = path.join(path.dirname(manifestFile), 'screenshots');
39
+ return { runDir, resultsFile, manifestFile, screenshotsDir };
40
+ }