Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,103 @@ jobs:
if: steps.retry0.outcome=='failure'
run: echo "::warning title=Flaky tests::${{ matrix.package }} flaked on the first attempt and was recovered by a spec-level retry (tracked in PER-9011)."

# The Snyk-backed lockfile-diff path (resolveAffectedDeps in
# packages/cli-command/src/lockfileDiff.js) and its IntelliStory callers
# require snyk-nodejs-lockfile-parser, which needs Node >=18. On the Node 14
# matrix above those tests are xdescribe'd / bail, so this leg runs the
# @percy/cli-command suite on Node 20 to exercise that code in CI. See PPLT-5844.
test-node20:
name: Test ${{ matrix.package }} (Node ${{ matrix.node }})
# Skip only for the automated release PR (see the build job for rationale).
if: >-
${{ !(github.event_name == 'pull_request'
&& github.event.pull_request.user.login == 'github-actions[bot]'
&& github.event.pull_request.head.repo.full_name == github.repository
&& startsWith(github.head_ref, 'release/')) }}
needs: [build]
strategy:
matrix:
os: [ubuntu-latest]
node: [20]
package:
- '@percy/cli-command'
runs-on: ${{ matrix.os }}
# Collect failed node-test spec names so retries re-run only the specs that
# flaked instead of the whole suite. Non-PERCY_ name so it doesn't trip
# cli-doctor's env-audit tests. See PER-9011.
env:
CLI_TEST_FAILURES_FILE: ${{ github.workspace }}/.cli-test-failures.json
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1
with:
node-version: ${{ matrix.node }}
- uses: actions/cache@f4b3439a656ba812b8cb417d2d49f9c810103092 # v3.4.0
with:
path: |
node_modules
packages/*/node_modules
packages/core/.local-chromium
key: >
${{ runner.os }}/node-${{ matrix.node }}/
${{ hashFiles('.github/.cache-key') }}/
${{ hashFiles('**/yarn.lock') }}
restore-keys: >
${{ runner.os }}/node-${{ matrix.node }}/
${{ hashFiles('.github/.cache-key') }}/
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: dist
path: packages
- run: yarn
- name: Install browser dependencies
run: |
sudo apt-get update
sudo apt-get install -y --fix-missing libgbm-dev
if: ${{ matrix.os == 'ubuntu-latest' }}
# First attempt runs the full suite WITH coverage (enforces the 100%
# gate) and records any failed specs.
- name: Run tests
continue-on-error: true
id: retry0
run: yarn workspace ${{ matrix.package }} test:coverage --colors
# Retries re-run ONLY the specs that failed in the previous attempt, and
# WITHOUT coverage (a subset can't hit the 100% threshold). If retry0
# failed with no recorded spec failures (e.g. a real coverage drop), the
# runner preserves that failure instead of masking it. See PER-9011.
- name: Run tests Retry (1/4)
continue-on-error: true
id: retry1
if: steps.retry0.outcome=='failure'
env:
CLI_TEST_ONLY_FAILED: '1'
run: yarn workspace ${{ matrix.package }} test --colors
- name: Run tests Retry (2/4)
continue-on-error: true
id: retry2
if: steps.retry1.outcome=='failure'
env:
CLI_TEST_ONLY_FAILED: '1'
run: yarn workspace ${{ matrix.package }} test --colors
- name: Run tests Retry (3/4)
continue-on-error: true
id: retry3
if: steps.retry2.outcome=='failure'
env:
CLI_TEST_ONLY_FAILED: '1'
run: yarn workspace ${{ matrix.package }} test --colors
- name: Run tests Retry (4/4)
id: retry4
if: steps.retry3.outcome=='failure'
env:
CLI_TEST_ONLY_FAILED: '1'
run: yarn workspace ${{ matrix.package }} test --colors
# Keep "green via retry" honest: surface a warning whenever the first
# attempt failed and a retry recovered it, so flakiness stays visible.
- name: Flag flaky tests
if: steps.retry0.outcome=='failure'
run: echo "::warning title=Flaky tests::${{ matrix.package }} flaked on the first attempt and was recovered by a spec-level retry (tracked in PER-9011)."

regression:
name: Regression
# Skip only for the automated release PR (see the build job for rationale).
Expand Down
2 changes: 2 additions & 0 deletions .semgrepignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ packages/core/src/api.js
# in the file-load helper anyway. No user input flows here.
packages/core/test/unit/maestro-hierarchy.test.js
packages/core/test/unit/maestro-hierarchy.parity.test.js

packages/cli-command/test/noRequireBinding.test.js
7 changes: 6 additions & 1 deletion packages/cli-command/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
".": "./dist/index.js",
"./flags": "./dist/flags.js",
"./utils": "./dist/utils.js",
"./intelliStory": "./dist/intelliStory.js",
"./test/helpers": "./test/helpers.js"
},
"scripts": {
Expand All @@ -38,6 +39,10 @@
"dependencies": {
"@percy/config": "1.32.5-beta.0",
"@percy/core": "1.32.5-beta.0",
"@percy/logger": "1.32.5-beta.0"
"@percy/logger": "1.32.5-beta.0",
"glob-to-regexp": "^0.4.1"
},
"optionalDependencies": {
"snyk-nodejs-lockfile-parser": "2.7.1"
}
}
117 changes: 117 additions & 0 deletions packages/cli-command/src/graphTrace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import fs from 'fs';
import path from 'path';
import url from 'url';

const TEMPLATE_PATH = path.resolve(url.fileURLToPath(import.meta.url), '../graphTraceTemplate.html');

function templateKindOf(v) {
if (v.changed) return 'is_relevant';
switch (v.kind) {
case 'dependency': return 'package';
case 'component': return 'component';
case 'story': return 'story';
default: return 'component';
}
}

const KIND_RANK = { package: 0, component: 1, is_relevant: 1, story: 2 };

function computeLayout(rawVertices, edges, transitiveClosure) {
const n = rawVertices.length;
const vertices = rawVertices.map((v, i) => ({
index: i,
name: v.file_path,
kind: v.kind,
changed: !!v.changed,
row: 0,
col: 0
}));

const incomingMax = new Array(n).fill(0);
for (const triple of transitiveClosure) {
const [u, v, val] = triple;
if (u === v || val <= 0) continue;
if (v < 0 || v >= n) continue;
if (val > incomingMax[v]) incomingMax[v] = val;
}
for (let i = 0; i < n; i++) {
vertices[i].col = vertices[i].kind === 'dependency' ? 0 : incomingMax[i] + 1;
}

const iterations = n + 2;
for (let iter = 0; iter < iterations; iter++) {
let changed = false;
for (const [s, t] of edges) {
if (s < 0 || s >= n || t < 0 || t >= n) continue;
if (vertices[s].col < vertices[t].col) continue;
vertices[t].col = vertices[s].col + 1;
changed = true;
}
if (!changed) break;
}

let furthestNonStory = 0;
for (const v of vertices) {
if (v.kind === 'story') continue;
if (v.col > furthestNonStory) furthestNonStory = v.col;
}
for (const v of vertices) {
if (v.kind !== 'story') continue;
if (v.col < furthestNonStory + 1) v.col = furthestNonStory + 1;
}

const groups = new Map();
for (const v of vertices) {
let list = groups.get(v.col);
if (!list) groups.set(v.col, list = []);
list.push(v);
}
const rankOf = v => {
const r = KIND_RANK[templateKindOf(v)];
/* istanbul ignore next */
return r === undefined ? 99 : r;
};
for (const list of groups.values()) {
list.sort((a, b) => {
const ra = rankOf(a);
const rb = rankOf(b);
if (ra !== rb) return ra - rb;
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});
list.forEach((v, row) => { v.row = row; });
}

return vertices.map(v => ({
index: v.index,
name: v.name,
row: v.row,
col: v.col,
kind: templateKindOf(v)
}));
}

const LS = String.fromCharCode(0x2028);
const PS = String.fromCharCode(0x2029);
function safeJson(obj) {
return JSON.stringify(obj)
.replace(/<\//g, '<\\/')
.replace(/<!--/g, '<\\!--')
.replace(/--(!?)>/g, '--$1\\>')
.split(LS).join('\\u2028')
.split(PS).join('\\u2029');
}

export function renderGraphTraceHtml({ vertices, edges, transitiveClosureMatrixSparse }) {
const laidOutVertices = computeLayout(
vertices || [],
edges || [],
transitiveClosureMatrixSparse || []
);
const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
return template
.replace('__VERTICES_JSON__', safeJson(laidOutVertices))
.replace('__EDGES_JSON__', safeJson(edges || []))
.replace('__TRANSITIVE_CLOSURE_JSON__', safeJson(transitiveClosureMatrixSparse || []));
}
Loading
Loading