Skip to content

Commit 3869132

Browse files
committed
feat: add acceptance tests for comparison [CMPA-595]
1 parent 93e6a50 commit 3869132

6 files changed

Lines changed: 176 additions & 13 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// swift-tools-version:5.6
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "LocalDep",
6+
products: [
7+
.library(name: "LocalDep", targets: ["LocalDep"]),
8+
],
9+
targets: [
10+
.target(name: "LocalDep"),
11+
]
12+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
public enum LocalDep {
2+
public static let greeting = "hello"
3+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// swift-tools-version:5.6
2+
// SwiftPM fixture for the unified-test-api equivalence suite.
3+
//
4+
// Unlike most lockfile ecosystems, the snyk-swiftpm-plugin resolves by shelling
5+
// out to `swift package show-dependencies`, which needs the dependencies on disk.
6+
// To stay network-free and deterministic we depend on a sibling package by path
7+
// (Deps/LocalDep) instead of a remote git URL — no fetch, no vendored checkout,
8+
// only the `swift` toolchain itself (gated via requiresCmd in the spec).
9+
import PackageDescription
10+
11+
let package = Package(
12+
name: "App",
13+
dependencies: [
14+
.package(path: "Deps/LocalDep"),
15+
],
16+
targets: [
17+
.executableTarget(name: "App", dependencies: [
18+
.product(name: "LocalDep", package: "LocalDep"),
19+
]),
20+
]
21+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import LocalDep
2+
3+
print(LocalDep.greeting)

test/jest/acceptance/snyk-test/equivalenceHelpers.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ export async function runBothFlows(
6464
server: FakeServer,
6565
env: Record<string, string | undefined>,
6666
runOptions: RunCommandOptions = {},
67+
/**
68+
* Extra feature flags to enable ONLY in the unified (FF-on) run — e.g. a
69+
* native resolver gate like 'internal_new_gradle_resolver'. The legacy run
70+
* never sees these, so the comparison becomes "native resolver vs legacy
71+
* CLI". Empty (the default) keeps both runs on the legacy resolver, which is
72+
* the Phase 1 endpoint-parity comparison.
73+
*/
74+
unifiedFlags: string[] = [],
6775
): Promise<EquivalenceResult> {
6876
const argsWithJson = argsString.includes('--json')
6977
? argsString
@@ -83,6 +91,9 @@ export async function runBothFlows(
8391

8492
server.restore();
8593
server.setFeatureFlag(UNIFIED_TEST_API_FF, true);
94+
for (const flag of unifiedFlags) {
95+
server.setFeatureFlag(flag, true);
96+
}
8697
const unifiedRun = await runSnykCLI(argsWithJson, {
8798
...runOptions,
8899
cwd,
@@ -162,6 +173,11 @@ export type AssertOptions = {
162173
/** Set true for fixtures that legitimately produce no submissions
163174
* (e.g. "no supported target files"). */
164175
expectNoSubmissions?: boolean;
176+
/** Set true for fixtures expected to be REJECTED by both flows (e.g. an
177+
* out-of-sync lockfile). The two flows have intentional exit-code
178+
* differences for failures (TS CLI exits 3, os-flows exits 2), so this
179+
* asserts the tolerance invariant "both fail" rather than equal codes. */
180+
expectError?: boolean;
165181
};
166182

167183
export function assertEquivalent(
@@ -170,6 +186,23 @@ export function assertEquivalent(
170186
): EquivalenceDiff {
171187
const { legacy, unified } = result;
172188

189+
// Error-parity mode: the fixture should be rejected by BOTH flows. Exit-code
190+
// values legitimately differ between the TS CLI and os-flows, so we assert
191+
// only the invariant that neither flow reported success.
192+
if (options.expectError) {
193+
if (legacy.code !== 0 && unified.code !== 0) {
194+
return { ok: true };
195+
}
196+
return {
197+
ok: false,
198+
reason: `expected both flows to fail: legacy=${legacy.code} unified=${unified.code}`,
199+
detail: {
200+
legacyStderr: legacy.stderr,
201+
unifiedStderr: unified.stderr,
202+
},
203+
};
204+
}
205+
173206
const bothEmpty =
174207
legacy.submissionCount === 0 && unified.submissionCount === 0;
175208

test/jest/acceptance/snyk-test/unified-test-api-equivalence.spec.ts

Lines changed: 104 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,44 @@
1010
* FF on → Go binary's os-flows extension resolves dep graphs via the plugin
1111
* orchestrator and posts to /rest/orgs/:orgId/tests.
1212
*
13-
* Starter corpus. Expand per resolveDepgraphs-rollout-plan.md.
13+
* Phase 1 of the rollout: breadth across every ecosystem that resolves through
14+
* the legacy-CLI fallback. The native resolver flags (cargo, pnpm, gradle, …)
15+
* stay OFF here, so BOTH runs resolve via the same legacy CLI and only the
16+
* submission endpoint differs — that endpoint/serialization parity is what this
17+
* phase proves. Native-resolver parity (gradle, pnpm) and exit-1/error parity
18+
* arrive in later phases.
19+
*
20+
* Fixtures that need an external build tool to resolve (go, sbt, swift, …) are
21+
* tagged with `requiresCmd` and skip when that tool is absent rather than
22+
* failing "inconclusive". Pure lockfile ecosystems (yarn, ruby, composer,
23+
* poetry, …) need no tool beyond what the harness already has.
24+
*
25+
* Note: pip/pipenv and hex/mix are deliberately excluded — their snyk plugins
26+
* resolve by introspecting an *installed* environment (site-packages / fetched
27+
* mix deps), so they need an install step and can't resolve from a clean
28+
* checkout. Python is covered via Poetry, which resolves offline from its lock.
1429
*/
1530

31+
import { execFileSync } from 'child_process';
1632
import { fakeServer } from '../../../acceptance/fake-server';
1733
import { createProjectFromWorkspace } from '../../util/createProject';
1834
import { getServerPort } from '../../util/getServerPort';
1935
import { assertEquivalent, runBothFlows } from './equivalenceHelpers';
2036

2137
jest.setTimeout(1000 * 60 * 3);
2238

39+
/** True when `cmd` resolves on PATH — used to skip fixtures whose toolchain is absent. */
40+
function commandAvailable(cmd: string): boolean {
41+
try {
42+
execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
43+
stdio: 'ignore',
44+
});
45+
return true;
46+
} catch {
47+
return false;
48+
}
49+
}
50+
2351
describe('snyk test — unified test API equivalence (FF off vs on)', () => {
2452
let server;
2553
let env: Record<string, string>;
@@ -47,9 +75,15 @@ describe('snyk test — unified test API equivalence (FF off vs on)', () => {
4775
name: string;
4876
args: string;
4977
expectNoSubmissions?: boolean;
78+
/** Expected to be rejected by both flows (e.g. out-of-sync lockfile). */
79+
expectError?: boolean;
80+
/** Binary that must be on PATH for the legacy CLI to resolve this fixture.
81+
* Omitted for lockfile-only ecosystems that need no external build tool. */
82+
requiresCmd?: string;
5083
};
5184

5285
const fixtures: Fixture[] = [
86+
// --- Starter corpus (unchanged) ---
5387
{ name: 'npm-package', args: 'test' },
5488
{ name: 'maven-app', args: 'test' },
5589
{ name: 'mono-repo-project', args: 'test --all-projects' },
@@ -58,24 +92,81 @@ describe('snyk test — unified test API equivalence (FF off vs on)', () => {
5892
args: 'test',
5993
expectNoSubmissions: true,
6094
},
95+
96+
// --- Phase 1: breadth via the legacy fallback ---
97+
// JavaScript (yarn) — resolved from yarn.lock; no tool beyond node.
98+
{ name: 'yarn-package', args: 'test' },
99+
{ name: 'yarn-workspaces', args: 'test --all-projects' },
100+
// Ruby — resolved from Gemfile.lock.
101+
{ name: 'ruby-app', args: 'test' },
102+
// PHP (Composer) — resolved from composer.lock.
103+
{ name: 'composer-app', args: 'test' },
104+
// CocoaPods — resolved from Podfile.lock.
105+
{ name: 'cocoapods-app', args: 'test' },
106+
// Swift (SwiftPM) — the plugin shells out to `swift package show-dependencies`,
107+
// so it needs the swift toolchain (gated) and the deps on disk. The fixture
108+
// depends on a sibling package by path, keeping resolution network-free.
109+
{ name: 'swift-local-dep', args: 'test', requiresCmd: 'swift' },
110+
// Go modules — needs the go toolchain.
111+
{ name: 'golang-gomodules', args: 'test', requiresCmd: 'go' },
112+
// Python (Poetry) — resolved from poetry.lock; no tool beyond node.
113+
// (pip/pipenv are intentionally NOT used here: their snyk plugins resolve by
114+
// introspecting *installed* site-packages, so they require a pip/pipenv
115+
// install step and can't resolve from a clean checkout the way a lockfile can.)
116+
{ name: 'poetry-app', args: 'test' },
117+
// .NET (NuGet) — resolved from project.assets.json; no tool beyond node.
118+
{ name: 'nuget-app-2', args: 'test' },
119+
// Scala (sbt) — needs sbt.
120+
{ name: 'sbt-app', args: 'test', requiresCmd: 'sbt' },
121+
122+
// --- Phase 2: option parity (same flag applied to BOTH flows) ---
123+
// Each option is passed to legacy and unified alike; the resolved graph and
124+
// project metadata must still match. Clean projects, so exit code stays 0.
125+
{ name: 'npm-package', args: 'test --dev' },
126+
{ name: 'npm-package', args: 'test --file=package.json' },
127+
{
128+
name: 'npm-package-pruneable',
129+
args: 'test --prune-repeated-subdependencies',
130+
},
131+
132+
// --- Phase 2: partial-failure parity under --all-projects ---
133+
// monorepo-bad-project mixes resolvable and unresolvable projects. This is
134+
// the exact scenario the os-flows change targeted ("tolerate per-project
135+
// resolution failures in the --all-projects orchestrator path"): both flows
136+
// should resolve the good projects, skip the bad one, and agree on the
137+
// submitted-project count and exit code.
138+
{ name: 'monorepo-bad-project', args: 'test --all-projects' },
139+
140+
// --- Phase 2: error parity (both flows must REJECT the bad input) ---
141+
// Exit-code values legitimately differ (TS CLI 3 vs os-flows 2), so these
142+
// assert the tolerance invariant "both fail" rather than equal codes.
143+
{ name: 'npm-out-of-sync', args: 'test', expectError: true },
144+
{ name: 'yarn-out-of-sync', args: 'test', expectError: true },
61145
];
62146

63147
describe.each(fixtures)(
64148
'fixture: $name ($args)',
65-
({ name, args, expectNoSubmissions }) => {
66-
test('FF off vs on produces equivalent dep graphs and exit code', async () => {
67-
const project = await createProjectFromWorkspace(name);
149+
({ name, args, expectNoSubmissions, expectError, requiresCmd }) => {
150+
const runnable = !requiresCmd || commandAvailable(requiresCmd);
151+
(runnable ? test : test.skip)(
152+
'FF off vs on produces equivalent dep graphs and exit code',
153+
async () => {
154+
const project = await createProjectFromWorkspace(name);
68155

69-
const result = await runBothFlows(project.path(), args, server, env);
70-
const diff = assertEquivalent(result, { expectNoSubmissions });
156+
const result = await runBothFlows(project.path(), args, server, env);
157+
const diff = assertEquivalent(result, {
158+
expectNoSubmissions,
159+
expectError,
160+
});
71161

72-
if (!diff.ok) {
73-
throw new Error(
74-
`Equivalence failed for ${name} (${args}): ${diff.reason}\n` +
75-
`detail=${JSON.stringify(diff.detail, null, 2)}`,
76-
);
77-
}
78-
});
162+
if (!diff.ok) {
163+
throw new Error(
164+
`Equivalence failed for ${name} (${args}): ${diff.reason}\n` +
165+
`detail=${JSON.stringify(diff.detail, null, 2)}`,
166+
);
167+
}
168+
},
169+
);
79170
},
80171
);
81172
});

0 commit comments

Comments
 (0)