@gfxlabs/third-eye-cli 3.23.2 → 3.24.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/dist/bin.mjs +290 -21
- package/dist/bin.mjs.map +1 -1
- package/package.json +1 -1
package/dist/bin.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import path, { join, normalize } from "node:path";
|
|
3
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -25,6 +26,9 @@ import { createORPCClient } from "@orpc/client";
|
|
|
25
26
|
import { RPCLink } from "@orpc/client/fetch";
|
|
26
27
|
import { execa } from "execa";
|
|
27
28
|
import { XMLParser } from "fast-xml-parser";
|
|
29
|
+
//#region \0rolldown/runtime.js
|
|
30
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
31
|
+
//#endregion
|
|
28
32
|
//#region src/log.ts
|
|
29
33
|
const logMemory = [];
|
|
30
34
|
const serializeError = (error) => ({
|
|
@@ -860,7 +864,7 @@ const collectLadleStories = async (ladleUrl) => {
|
|
|
860
864
|
};
|
|
861
865
|
//#endregion
|
|
862
866
|
//#region src/crawler/storybook.ts
|
|
863
|
-
const kebabCase = (str) => (str ?? "").replace(/([a-z\d])([A-Z])/g, "$1-$2").replace(/[\s_/]+/g, "-").toLowerCase();
|
|
867
|
+
const kebabCase$1 = (str) => (str ?? "").replace(/([a-z\d])([A-Z])/g, "$1-$2").replace(/[\s_/]+/g, "-").toLowerCase();
|
|
864
868
|
const getStoryBookUrl = (url) => {
|
|
865
869
|
if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) return url;
|
|
866
870
|
if (url.startsWith("/")) return `file://${url}`;
|
|
@@ -996,9 +1000,9 @@ const generateStoryUrl = (iframeUrl, storyId, args, breakpoint) => {
|
|
|
996
1000
|
const generateFilename = (kind, story, prefix, suffix) => {
|
|
997
1001
|
return [
|
|
998
1002
|
prefix,
|
|
999
|
-
kebabCase(kind),
|
|
1000
|
-
kebabCase(story),
|
|
1001
|
-
kebabCase(suffix)
|
|
1003
|
+
kebabCase$1(kind),
|
|
1004
|
+
kebabCase$1(story),
|
|
1005
|
+
kebabCase$1(suffix)
|
|
1002
1006
|
].filter(Boolean).join("--");
|
|
1003
1007
|
};
|
|
1004
1008
|
const generateStorybookShotItems = (baseUrl, stories, mask, modeBreakpoints, browser) => {
|
|
@@ -1496,7 +1500,12 @@ const collectHistoireStories = async (histoireUrl) => {
|
|
|
1496
1500
|
};
|
|
1497
1501
|
//#endregion
|
|
1498
1502
|
//#region src/createShots.ts
|
|
1499
|
-
|
|
1503
|
+
/**
|
|
1504
|
+
* @param turboSnapFilter - When provided, only stories whose shotName is in this set
|
|
1505
|
+
* will be screenshotted. Stories not in the set are skipped entirely (no Playwright
|
|
1506
|
+
* navigation), which is the main performance win of TurboSnap.
|
|
1507
|
+
*/
|
|
1508
|
+
const createShots = async (turboSnapFilter) => {
|
|
1500
1509
|
const { ladleShots, histoireShots, storybookShots, pageShots, customShots, imagePathCurrent } = config;
|
|
1501
1510
|
let storybookShotItems = [];
|
|
1502
1511
|
let ladleShotItems = [];
|
|
@@ -1580,10 +1589,12 @@ const createShots = async () => {
|
|
|
1580
1589
|
await mapLimit(browsers, 1, async (browser) => {
|
|
1581
1590
|
const shotItems = generateStorybookShotItems(storybookWebUrl, collection.stories, mask, storybookShots.breakpoints, browsers.length > 1 ? browser : void 0);
|
|
1582
1591
|
const filterItemsToCheck = "filterItemsToCheck" in config ? config.filterItemsToCheck : void 0;
|
|
1583
|
-
|
|
1592
|
+
let filteredShotItems = filterItemsToCheck ? shotItems.filter((item) => filterItemsToCheck(item)) : shotItems;
|
|
1593
|
+
if (turboSnapFilter) filteredShotItems = filteredShotItems.filter((item) => turboSnapFilter.has(item.shotName) || turboSnapFilter.has(`${item.shotMode}/${item.shotName}`));
|
|
1584
1594
|
storybookShotItems = shotItems;
|
|
1585
|
-
|
|
1586
|
-
|
|
1595
|
+
const capturedItems = filteredShotItems;
|
|
1596
|
+
log.process("info", "general", turboSnapFilter ? `Prepared ${capturedItems.length}/${shotItems.length} stories for screenshots on ${browser.name()} (TurboSnap: ${shotItems.length - capturedItems.length} skipped)` : `Prepared ${capturedItems.length} stories for screenshots on ${browser.name()}`);
|
|
1597
|
+
await takeScreenShots(capturedItems, browser);
|
|
1587
1598
|
});
|
|
1588
1599
|
localServer?.close();
|
|
1589
1600
|
} catch (error) {
|
|
@@ -1626,6 +1637,76 @@ const createShots = async () => {
|
|
|
1626
1637
|
];
|
|
1627
1638
|
};
|
|
1628
1639
|
//#endregion
|
|
1640
|
+
//#region src/git.ts
|
|
1641
|
+
const INITIAL_BATCH_SIZE = 20;
|
|
1642
|
+
/**
|
|
1643
|
+
* Execute a git command and return the trimmed output.
|
|
1644
|
+
*/
|
|
1645
|
+
const execGit = (command) => {
|
|
1646
|
+
try {
|
|
1647
|
+
return execSync(command, {
|
|
1648
|
+
encoding: "utf-8",
|
|
1649
|
+
timeout: 1e4
|
|
1650
|
+
}).trim();
|
|
1651
|
+
} catch {
|
|
1652
|
+
return "";
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
/**
|
|
1656
|
+
* Find the "covering" set of ancestor commits that have builds on the server.
|
|
1657
|
+
*
|
|
1658
|
+
* This mirrors Chromatic's approach:
|
|
1659
|
+
* 1. Walk git history with `git rev-list`
|
|
1660
|
+
* 2. Ask the server which commits have builds
|
|
1661
|
+
* 3. Use `--not` to exclude ancestors of commits with builds
|
|
1662
|
+
* 4. Repeat with exponentially larger batches until no more uncovered commits
|
|
1663
|
+
*
|
|
1664
|
+
* The result is the minimal set of ancestor commits with builds such that
|
|
1665
|
+
* every ancestor of HEAD either has a build or is an ancestor of a commit
|
|
1666
|
+
* with a build.
|
|
1667
|
+
*/
|
|
1668
|
+
const getParentCommits = async (hasBuildsWithCommits) => {
|
|
1669
|
+
if (!execGit("git rev-parse HEAD")) {
|
|
1670
|
+
log.process("info", "general", "Not a git repository, skipping ancestor detection");
|
|
1671
|
+
return [];
|
|
1672
|
+
}
|
|
1673
|
+
if (!execGit("git --no-pager log -n 1 --skip=1 --format=\"%H\"")) {
|
|
1674
|
+
log.process("info", "general", "Initial commit, no ancestors");
|
|
1675
|
+
return [];
|
|
1676
|
+
}
|
|
1677
|
+
let commitsWithBuilds = [];
|
|
1678
|
+
let commitsWithoutBuilds = [];
|
|
1679
|
+
let limit = INITIAL_BATCH_SIZE;
|
|
1680
|
+
for (;;) {
|
|
1681
|
+
const notArgs = commitsWithBuilds.map((c) => c.trim()).join(" ");
|
|
1682
|
+
const output = execGit(`git rev-list HEAD -n ${limit + commitsWithoutBuilds.length}${notArgs ? ` --not ${notArgs}` : ""}`);
|
|
1683
|
+
const candidates = (output ? output.split("\n").filter(Boolean) : []).filter((c) => !commitsWithBuilds.includes(c)).filter((c) => !commitsWithoutBuilds.includes(c)).slice(0, limit);
|
|
1684
|
+
if (candidates.length === 0) break;
|
|
1685
|
+
log.process("info", "general", `🔍 Checking ${candidates.length} commits for builds (batch size ${limit})`);
|
|
1686
|
+
const newCommitsWithBuilds = await hasBuildsWithCommits(candidates);
|
|
1687
|
+
const newCommitsWithoutBuilds = candidates.filter((c) => !newCommitsWithBuilds.includes(c));
|
|
1688
|
+
commitsWithBuilds = [...commitsWithBuilds, ...newCommitsWithBuilds];
|
|
1689
|
+
commitsWithoutBuilds = [...commitsWithoutBuilds, ...newCommitsWithoutBuilds];
|
|
1690
|
+
limit *= 2;
|
|
1691
|
+
if (commitsWithoutBuilds.length > 1e4) {
|
|
1692
|
+
log.process("info", "general", "Reached max history depth (10000 commits)");
|
|
1693
|
+
break;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
if (commitsWithBuilds.length === 0) {
|
|
1697
|
+
log.process("info", "general", "No ancestor builds found — this may be the first build");
|
|
1698
|
+
return [];
|
|
1699
|
+
}
|
|
1700
|
+
if (commitsWithBuilds.length > 1) {
|
|
1701
|
+
const parentArgs = commitsWithBuilds.map((c) => `"${c}^@"`).join(" ");
|
|
1702
|
+
const maxOutput = execGit(`git rev-list ${commitsWithBuilds.join(" ")} --not ${parentArgs}`);
|
|
1703
|
+
const maxCommits = maxOutput ? maxOutput.split("\n").filter(Boolean) : [];
|
|
1704
|
+
if (maxCommits.length > 0) commitsWithBuilds = maxCommits;
|
|
1705
|
+
}
|
|
1706
|
+
log.process("info", "general", `📌 Found ${commitsWithBuilds.length} ancestor build(s): ${commitsWithBuilds.map((c) => c.slice(0, 7)).join(", ")}`);
|
|
1707
|
+
return commitsWithBuilds;
|
|
1708
|
+
};
|
|
1709
|
+
//#endregion
|
|
1629
1710
|
//#region ../shared/dist/client.js
|
|
1630
1711
|
/**
|
|
1631
1712
|
* Typed oRPC client factory.
|
|
@@ -1694,7 +1775,7 @@ const getApiToken = async (config) => {
|
|
|
1694
1775
|
process.exit(1);
|
|
1695
1776
|
}
|
|
1696
1777
|
};
|
|
1697
|
-
const sendInitToAPI = async (config, apiToken) => {
|
|
1778
|
+
const sendInitToAPI = async (config, apiToken, parentCommits) => {
|
|
1698
1779
|
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1699
1780
|
return withRetry("init", () => client.orgs.projects.builds.init({
|
|
1700
1781
|
orgId: config.thirdEyeOrgId,
|
|
@@ -1703,9 +1784,18 @@ const sendInitToAPI = async (config, apiToken) => {
|
|
|
1703
1784
|
branchName: config.commitRefName,
|
|
1704
1785
|
buildNumber: config.ciBuildNumber,
|
|
1705
1786
|
baseBranch: config.baseBranch || void 0,
|
|
1706
|
-
prNumber: config.prNumber
|
|
1787
|
+
prNumber: config.prNumber,
|
|
1788
|
+
parentCommits
|
|
1707
1789
|
}));
|
|
1708
1790
|
};
|
|
1791
|
+
const sendHasBuildsWithCommitsToAPI = async (config, apiToken, commits) => {
|
|
1792
|
+
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1793
|
+
return (await withRetry("hasBuildsWithCommits", () => client.orgs.projects.builds.hasBuildsWithCommits({
|
|
1794
|
+
orgId: config.thirdEyeOrgId,
|
|
1795
|
+
projectId: config.thirdEyeProjectId,
|
|
1796
|
+
commits
|
|
1797
|
+
}))).commits;
|
|
1798
|
+
};
|
|
1709
1799
|
const sendFinalizeToAPI = async (config, apiToken) => {
|
|
1710
1800
|
const client = createClient(config.thirdEyePlatform, void 0, apiToken);
|
|
1711
1801
|
return withRetry("finalize", () => client.orgs.projects.builds.finalize({
|
|
@@ -1795,7 +1885,7 @@ const uploadStorybookArchive = async (config, apiToken, projectId, buildId, arch
|
|
|
1795
1885
|
};
|
|
1796
1886
|
//#endregion
|
|
1797
1887
|
//#region src/upload.ts
|
|
1798
|
-
const uploadRequiredShots = async ({ config, apiToken, uploadToken, requiredFileHashes, extendedShotItems }) => {
|
|
1888
|
+
const uploadRequiredShots = async ({ config, apiToken, uploadToken, requiredFileHashes, extendedShotItems, dependencyMap }) => {
|
|
1799
1889
|
if (requiredFileHashes.length > 0) {
|
|
1800
1890
|
log.process("info", "api", "Uploading shots");
|
|
1801
1891
|
const uploadStart = process.hrtime();
|
|
@@ -1825,7 +1915,8 @@ const uploadRequiredShots = async ({ config, apiToken, uploadToken, requiredFile
|
|
|
1825
1915
|
...shotItem.tags ? { tags: shotItem.tags } : {},
|
|
1826
1916
|
...shotItem.viewport ? { viewport: shotItem.viewport } : {},
|
|
1827
1917
|
...shotItem.breakpoint !== void 0 ? { breakpoint: shotItem.breakpoint } : {},
|
|
1828
|
-
...domHtml ? { dom_html: domHtml } : {}
|
|
1918
|
+
...domHtml ? { dom_html: domHtml } : {},
|
|
1919
|
+
...dependencyMap && shotItem.importPath && dependencyMap.has(shotItem.importPath) ? { dependencies: dependencyMap.get(shotItem.importPath) } : {}
|
|
1829
1920
|
},
|
|
1830
1921
|
logger
|
|
1831
1922
|
});
|
|
@@ -1836,7 +1927,168 @@ const uploadRequiredShots = async ({ config, apiToken, uploadToken, requiredFile
|
|
|
1836
1927
|
return true;
|
|
1837
1928
|
};
|
|
1838
1929
|
//#endregion
|
|
1930
|
+
//#region src/turbosnap.ts
|
|
1931
|
+
/**
|
|
1932
|
+
* TurboSnap: static import tracer for determining which stories are affected by code changes.
|
|
1933
|
+
*
|
|
1934
|
+
* Given a list of changed files (from git diff) and the storybook index (importPath per story),
|
|
1935
|
+
* this module traces each story file's transitive imports to determine which stories need
|
|
1936
|
+
* re-screenshotting. Stories whose dependency tree does not overlap with any changed file are
|
|
1937
|
+
* skipped entirely — they never get launched in Playwright, saving the majority of run time.
|
|
1938
|
+
*
|
|
1939
|
+
* The import tracer is intentionally simple: it parses `import`/`require` statements with a
|
|
1940
|
+
* regex, resolves relative and aliased paths, and follows them recursively with cycle detection.
|
|
1941
|
+
* It does not evaluate dynamic imports or re-exports — those are rare in component code and the
|
|
1942
|
+
* fallback is always "capture the story" (safe default).
|
|
1943
|
+
*/
|
|
1944
|
+
const RESOLVE_EXTENSIONS = [
|
|
1945
|
+
".ts",
|
|
1946
|
+
".tsx",
|
|
1947
|
+
".js",
|
|
1948
|
+
".jsx",
|
|
1949
|
+
".mjs",
|
|
1950
|
+
".cjs"
|
|
1951
|
+
];
|
|
1952
|
+
const INDEX_FILES = RESOLVE_EXTENSIONS.map((ext) => `index${ext}`);
|
|
1953
|
+
/**
|
|
1954
|
+
* Regex that captures the string literal from import/require statements.
|
|
1955
|
+
* Handles:
|
|
1956
|
+
* import ... from 'foo'
|
|
1957
|
+
* import ... from "foo"
|
|
1958
|
+
* import 'foo'
|
|
1959
|
+
* export ... from 'foo'
|
|
1960
|
+
* require('foo')
|
|
1961
|
+
*/
|
|
1962
|
+
const IMPORT_RE = /(?:import\s+(?:[\s\S]*?\s+from\s+)?|export\s+(?:[\s\S]*?\s+from\s+)?|require\s*\()["']([^"']+)["']/g;
|
|
1963
|
+
/**
|
|
1964
|
+
* Parse tsconfig-style path aliases into a simple prefix → directory map.
|
|
1965
|
+
* E.g. `{ "#/*": ["./src/app/*"] }` → `{ "#/": "/abs/path/src/app/" }`
|
|
1966
|
+
*/
|
|
1967
|
+
const parsePathAliases = (tsconfigPath, projectRoot) => {
|
|
1968
|
+
const aliases = {};
|
|
1969
|
+
try {
|
|
1970
|
+
const stripped = readFileSync(tsconfigPath, "utf-8").replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/,(\s*[}\]])/g, "$1");
|
|
1971
|
+
const paths = JSON.parse(stripped).compilerOptions?.paths;
|
|
1972
|
+
if (paths) {
|
|
1973
|
+
for (const [pattern, targets] of Object.entries(paths)) if (targets.length > 0) {
|
|
1974
|
+
const prefix = pattern.replace(/\*$/, "");
|
|
1975
|
+
const target = targets[0].replace(/\*$/, "");
|
|
1976
|
+
aliases[prefix] = path.resolve(projectRoot, target);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
} catch {}
|
|
1980
|
+
return aliases;
|
|
1981
|
+
};
|
|
1982
|
+
/**
|
|
1983
|
+
* Resolve a single import specifier to an absolute file path.
|
|
1984
|
+
* Returns undefined if the import is external (node_modules) or unresolvable.
|
|
1985
|
+
*/
|
|
1986
|
+
const resolveImport = (specifier, fromFile, aliases) => {
|
|
1987
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("/") && !Object.keys(aliases).some((prefix) => specifier.startsWith(prefix))) return;
|
|
1988
|
+
let resolved;
|
|
1989
|
+
const matchingAlias = Object.entries(aliases).find(([prefix]) => specifier.startsWith(prefix));
|
|
1990
|
+
if (matchingAlias) {
|
|
1991
|
+
const [prefix, target] = matchingAlias;
|
|
1992
|
+
resolved = path.join(target, specifier.slice(prefix.length));
|
|
1993
|
+
} else resolved = path.resolve(path.dirname(fromFile), specifier);
|
|
1994
|
+
if (existsSync(resolved) && !isDirectory(resolved)) return resolved;
|
|
1995
|
+
for (const ext of RESOLVE_EXTENSIONS) {
|
|
1996
|
+
const withExt = resolved + ext;
|
|
1997
|
+
if (existsSync(withExt)) return withExt;
|
|
1998
|
+
}
|
|
1999
|
+
for (const indexFile of INDEX_FILES) {
|
|
2000
|
+
const withIndex = path.join(resolved, indexFile);
|
|
2001
|
+
if (existsSync(withIndex)) return withIndex;
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
const isDirectory = (p) => {
|
|
2005
|
+
try {
|
|
2006
|
+
const { statSync } = __require("node:fs");
|
|
2007
|
+
return statSync(p).isDirectory();
|
|
2008
|
+
} catch {
|
|
2009
|
+
return false;
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
/**
|
|
2013
|
+
* Extract all import specifiers from a source file.
|
|
2014
|
+
*/
|
|
2015
|
+
const extractImports = (filePath) => {
|
|
2016
|
+
try {
|
|
2017
|
+
const content = readFileSync(filePath, "utf-8");
|
|
2018
|
+
const imports = [];
|
|
2019
|
+
let match;
|
|
2020
|
+
IMPORT_RE.lastIndex = 0;
|
|
2021
|
+
while ((match = IMPORT_RE.exec(content)) !== null) if (match[1]) imports.push(match[1]);
|
|
2022
|
+
return imports;
|
|
2023
|
+
} catch {
|
|
2024
|
+
return [];
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
/**
|
|
2028
|
+
* Trace all transitive dependencies of a file recursively.
|
|
2029
|
+
* Returns a Set of absolute file paths that the file depends on (including itself).
|
|
2030
|
+
*/
|
|
2031
|
+
const traceDependencies = (entryFile, projectRoot, aliases, _cache) => {
|
|
2032
|
+
const cache = _cache ?? /* @__PURE__ */ new Map();
|
|
2033
|
+
const absEntry = path.isAbsolute(entryFile) ? entryFile : path.resolve(projectRoot, entryFile);
|
|
2034
|
+
if (cache.has(absEntry)) return cache.get(absEntry);
|
|
2035
|
+
const deps = new Set([absEntry]);
|
|
2036
|
+
cache.set(absEntry, deps);
|
|
2037
|
+
if (!existsSync(absEntry)) return deps;
|
|
2038
|
+
const importSpecifiers = extractImports(absEntry);
|
|
2039
|
+
for (const spec of importSpecifiers) {
|
|
2040
|
+
const resolved = resolveImport(spec, absEntry, aliases);
|
|
2041
|
+
if (resolved && !deps.has(resolved)) {
|
|
2042
|
+
deps.add(resolved);
|
|
2043
|
+
const transitiveDeps = traceDependencies(resolved, projectRoot, aliases, cache);
|
|
2044
|
+
for (const d of transitiveDeps) deps.add(d);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
return deps;
|
|
2048
|
+
};
|
|
2049
|
+
/**
|
|
2050
|
+
* Determine which stories are affected by a set of changed files.
|
|
2051
|
+
*
|
|
2052
|
+
* @param stories - Array of { shotName, importPath } from the storybook index
|
|
2053
|
+
* @param changedFiles - Relative file paths from `git diff --name-only`
|
|
2054
|
+
* @param projectRoot - Absolute path to the project root (for resolving imports)
|
|
2055
|
+
* @returns Which stories to capture and which to skip
|
|
2056
|
+
*/
|
|
2057
|
+
const getAffectedStoriesLocal = (stories, changedFiles, projectRoot) => {
|
|
2058
|
+
const aliases = parsePathAliases(path.join(projectRoot, "tsconfig.json"), projectRoot);
|
|
2059
|
+
const changedAbsolute = new Set(changedFiles.map((f) => path.resolve(projectRoot, f)));
|
|
2060
|
+
const affected = [];
|
|
2061
|
+
const skipped = [];
|
|
2062
|
+
const dependencyMap = /* @__PURE__ */ new Map();
|
|
2063
|
+
const depCache = /* @__PURE__ */ new Map();
|
|
2064
|
+
for (const story of stories) {
|
|
2065
|
+
if (!story.importPath) {
|
|
2066
|
+
affected.push(story.shotName);
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
const deps = traceDependencies(path.resolve(projectRoot, story.importPath), projectRoot, aliases, depCache);
|
|
2070
|
+
const relativeDeps = [...deps].map((d) => path.relative(projectRoot, d));
|
|
2071
|
+
dependencyMap.set(story.importPath, relativeDeps);
|
|
2072
|
+
let isAffected = false;
|
|
2073
|
+
for (const dep of deps) if (changedAbsolute.has(dep)) {
|
|
2074
|
+
isAffected = true;
|
|
2075
|
+
break;
|
|
2076
|
+
}
|
|
2077
|
+
if (isAffected) affected.push(story.shotName);
|
|
2078
|
+
else skipped.push(story.shotName);
|
|
2079
|
+
}
|
|
2080
|
+
return {
|
|
2081
|
+
affected,
|
|
2082
|
+
skipped,
|
|
2083
|
+
total: stories.length,
|
|
2084
|
+
dependencyMap
|
|
2085
|
+
};
|
|
2086
|
+
};
|
|
2087
|
+
//#endregion
|
|
1839
2088
|
//#region src/runner.ts
|
|
2089
|
+
const kebabCase = (str) => (str ?? "").replace(/([a-z\d])([A-Z])/g, "$1-$2").replace(/[\s_/]+/g, "-").toLowerCase();
|
|
2090
|
+
/** Generate the shot name from a storybook title and story name, matching the crawler's logic. */
|
|
2091
|
+
const generateShotName = (title, name) => [kebabCase(title), kebabCase(name)].filter(Boolean).join("--");
|
|
1840
2092
|
/**
|
|
1841
2093
|
* Get the list of files changed between the current HEAD and a base ref
|
|
1842
2094
|
* using git diff. Returns an empty array if git is unavailable or fails.
|
|
@@ -1941,30 +2193,46 @@ const platformRunner = async (config, apiToken) => {
|
|
|
1941
2193
|
`commitRefName = ${config.commitRefName}`,
|
|
1942
2194
|
`commitHash = ${config.commitHash}`
|
|
1943
2195
|
].join("\n - "));
|
|
1944
|
-
|
|
2196
|
+
log.process("info", "general", "🔍 Resolving ancestor builds from git history...");
|
|
2197
|
+
await sendInitToAPI(config, apiToken, await getParentCommits((commits) => sendHasBuildsWithCommitsToAPI(config, apiToken, commits)));
|
|
1945
2198
|
if (!await checkForCachedBuild(config, apiToken)) {
|
|
1946
2199
|
log.process("info", "general", "📂 Creating shot folders");
|
|
1947
2200
|
const createShotsStart = process.hrtime();
|
|
1948
2201
|
createShotsFolders();
|
|
1949
|
-
|
|
1950
|
-
let
|
|
2202
|
+
let turboSnapFilter;
|
|
2203
|
+
let turboSnapDependencyMap;
|
|
1951
2204
|
if (config.turboSnap && config.baseBranch) {
|
|
1952
2205
|
log.process("info", "general", `⚡ TurboSnap enabled, checking changed files against ${config.baseBranch}`);
|
|
1953
2206
|
const changedFiles = getChangedFiles(config.baseBranch);
|
|
1954
2207
|
if (changedFiles.length > 0) {
|
|
1955
2208
|
log.process("info", "general", `Found ${changedFiles.length} changed file(s)`);
|
|
1956
2209
|
try {
|
|
1957
|
-
const
|
|
1958
|
-
|
|
1959
|
-
if (
|
|
1960
|
-
const
|
|
1961
|
-
|
|
2210
|
+
const storybookPath = config.storybookShots?.storybookUrl ?? "";
|
|
2211
|
+
const indexJsonPath = path.join(storybookPath.startsWith("http") ? "" : storybookPath, "index.json");
|
|
2212
|
+
if (existsSync(indexJsonPath)) {
|
|
2213
|
+
const indexJson = JSON.parse(fs.readFileSync(indexJsonPath, "utf-8"));
|
|
2214
|
+
const entries = indexJson.entries ?? indexJson.stories ?? {};
|
|
2215
|
+
const turboResult = getAffectedStoriesLocal(Object.values(entries).filter((e) => e.type !== "docs").map((e) => ({
|
|
2216
|
+
shotName: generateShotName(e.title, e.name),
|
|
2217
|
+
importPath: e.importPath
|
|
2218
|
+
})), changedFiles, process.cwd());
|
|
2219
|
+
log.process("info", "general", `⚡ TurboSnap: ${turboResult.affected.length} affected, ${turboResult.skipped.length} skipped out of ${turboResult.total} stories`);
|
|
2220
|
+
turboSnapFilter = new Set(turboResult.affected);
|
|
2221
|
+
turboSnapDependencyMap = turboResult.dependencyMap;
|
|
2222
|
+
} else try {
|
|
2223
|
+
const serverResult = await getAffectedStories(config, apiToken, changedFiles);
|
|
2224
|
+
log.process("info", "general", `⚡ TurboSnap (server): ${serverResult.affectedCount} affected, ${serverResult.skippedCount} skipped`);
|
|
2225
|
+
if (serverResult.affected.length > 0) turboSnapFilter = new Set(serverResult.affected);
|
|
2226
|
+
} catch (error) {
|
|
2227
|
+
if (error instanceof Error) log.process("warn", "general", `TurboSnap server query failed, capturing all stories: ${error.message}`);
|
|
1962
2228
|
}
|
|
1963
2229
|
} catch (error) {
|
|
1964
2230
|
if (error instanceof Error) log.process("warn", "general", `TurboSnap filtering failed, capturing all stories: ${error.message}`);
|
|
1965
2231
|
}
|
|
1966
2232
|
} else log.process("info", "general", "TurboSnap: no changed files detected, capturing all stories");
|
|
1967
2233
|
}
|
|
2234
|
+
log.process("info", "general", "📸 Creating shots");
|
|
2235
|
+
let shotItems = await createShots(turboSnapFilter);
|
|
1968
2236
|
const shotNames = shotItems.map((shotItem) => shotItem.shotName);
|
|
1969
2237
|
const uniqueShotNames = new Set(shotNames);
|
|
1970
2238
|
if (shotNames.length !== uniqueShotNames.size) {
|
|
@@ -2000,7 +2268,8 @@ const platformRunner = async (config, apiToken) => {
|
|
|
2000
2268
|
apiToken,
|
|
2001
2269
|
uploadToken,
|
|
2002
2270
|
requiredFileHashes,
|
|
2003
|
-
extendedShotItems
|
|
2271
|
+
extendedShotItems,
|
|
2272
|
+
dependencyMap: turboSnapDependencyMap
|
|
2004
2273
|
});
|
|
2005
2274
|
await processShots(config, apiToken, uploadToken, shotItems.map((shotItem) => ({
|
|
2006
2275
|
name: `${shotItem.shotMode}/${shotItem.shotName}`,
|