Skip to content

Commit 185790c

Browse files
author
Luca Forstner
committed
test(e2e): Add framework for canary testing
1 parent 9056516 commit 185790c

File tree

2 files changed

+209
-119
lines changed

2 files changed

+209
-119
lines changed

packages/e2e-tests/run.ts

Lines changed: 181 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -135,156 +135,218 @@ type TestResult = {
135135
result: 'PASS' | 'FAIL' | 'TIMEOUT';
136136
};
137137

138+
type VersionResult = {
139+
dependencyOverrides?: Record<string, string>;
140+
buildFailed: boolean;
141+
testResults: TestResult[];
142+
};
143+
138144
type RecipeResult = {
139145
testApplicationName: string;
140146
testApplicationPath: string;
141-
buildFailed: boolean;
142-
testResults: TestResult[];
147+
versionResults: VersionResult[];
143148
};
144149

145-
const recipeResults: RecipeResult[] = recipePaths.map(recipePath => {
146-
type Recipe = {
147-
testApplicationName: string;
148-
buildCommand?: string;
149-
buildTimeoutSeconds?: number;
150-
tests: {
151-
testName: string;
152-
testCommand: string;
153-
timeoutSeconds?: number;
154-
}[];
155-
};
150+
type Recipe = {
151+
testApplicationName: string;
152+
buildCommand?: string;
153+
buildTimeoutSeconds?: number;
154+
tests: {
155+
testName: string;
156+
testCommand: string;
157+
timeoutSeconds?: number;
158+
}[];
159+
versions?: { dependencyOverrides: Record<string, string> }[];
160+
canaryVersions?: { dependencyOverrides: Record<string, string> }[];
161+
};
156162

163+
const recipeResults: RecipeResult[] = recipePaths.map(recipePath => {
157164
const recipe: Recipe = JSON.parse(fs.readFileSync(recipePath, 'utf-8'));
165+
const recipeDirname = path.dirname(recipePath);
158166

159-
if (recipe.buildCommand) {
160-
console.log(`Running E2E test build command for test application "${recipe.testApplicationName}"`);
161-
const buildCommandProcess = childProcess.spawnSync(recipe.buildCommand, {
162-
cwd: path.dirname(recipePath),
163-
encoding: 'utf8',
164-
shell: true, // needed so we can pass the build command in as whole without splitting it up into args
165-
timeout: (recipe.buildTimeoutSeconds ?? DEFAULT_BUILD_TIMEOUT_SECONDS) * 1000,
166-
env: {
167-
...process.env,
168-
...envVarsToInject,
169-
},
170-
});
171-
172-
// Prepends some text to the output build command's output so we can distinguish it from logging in this script
173-
console.log(buildCommandProcess.stdout.replace(/^/gm, '[BUILD OUTPUT] '));
174-
console.log(buildCommandProcess.stderr.replace(/^/gm, '[BUILD OUTPUT] '));
167+
function runRecipe(dependencyOverrides: Record<string, string> | undefined): VersionResult {
168+
const dependencyOverridesInformationString = dependencyOverrides
169+
? ` (Dependency overrides: ${JSON.stringify(dependencyOverrides)})`
170+
: '';
175171

176-
const error: undefined | (Error & { code?: string }) = buildCommandProcess.error;
177-
178-
if (error?.code === 'ETIMEDOUT') {
179-
processShouldExitWithError = true;
180-
181-
printCIErrorMessage(
182-
`Build command in test application "${recipe.testApplicationName}" (${path.dirname(recipePath)}) timed out!`,
183-
);
184-
185-
return {
186-
testApplicationName: recipe.testApplicationName,
187-
testApplicationPath: recipePath,
188-
buildFailed: true,
189-
testResults: [],
190-
};
191-
} else if (buildCommandProcess.status !== 0) {
192-
processShouldExitWithError = true;
193-
194-
printCIErrorMessage(
195-
`Build command in test application "${recipe.testApplicationName}" (${path.dirname(recipePath)}) failed!`,
172+
if (recipe.buildCommand) {
173+
console.log(
174+
`Running E2E test build command for test application "${recipe.testApplicationName}"${dependencyOverridesInformationString}`,
196175
);
197-
198-
return {
199-
testApplicationName: recipe.testApplicationName,
200-
testApplicationPath: recipePath,
201-
buildFailed: true,
202-
testResults: [],
203-
};
176+
const buildCommandProcess = childProcess.spawnSync(recipe.buildCommand, {
177+
cwd: path.dirname(recipePath),
178+
encoding: 'utf8',
179+
shell: true, // needed so we can pass the build command in as whole without splitting it up into args
180+
timeout: (recipe.buildTimeoutSeconds ?? DEFAULT_BUILD_TIMEOUT_SECONDS) * 1000,
181+
env: {
182+
...process.env,
183+
...envVarsToInject,
184+
},
185+
});
186+
187+
// Prepends some text to the output build command's output so we can distinguish it from logging in this script
188+
console.log(buildCommandProcess.stdout.replace(/^/gm, '[BUILD OUTPUT] '));
189+
console.log(buildCommandProcess.stderr.replace(/^/gm, '[BUILD OUTPUT] '));
190+
191+
const error: undefined | (Error & { code?: string }) = buildCommandProcess.error;
192+
193+
if (error?.code === 'ETIMEDOUT') {
194+
processShouldExitWithError = true;
195+
196+
printCIErrorMessage(
197+
`Build command in test application "${recipe.testApplicationName}" (${path.dirname(recipePath)}) timed out!`,
198+
);
199+
200+
return {
201+
dependencyOverrides,
202+
buildFailed: true,
203+
testResults: [],
204+
};
205+
} else if (buildCommandProcess.status !== 0) {
206+
processShouldExitWithError = true;
207+
208+
printCIErrorMessage(
209+
`Build command in test application "${recipe.testApplicationName}" (${path.dirname(recipePath)}) failed!`,
210+
);
211+
212+
return {
213+
dependencyOverrides,
214+
buildFailed: true,
215+
testResults: [],
216+
};
217+
}
204218
}
205-
}
206219

207-
const testResults: TestResult[] = recipe.tests.map(test => {
208-
console.log(
209-
`Running E2E test command for test application "${recipe.testApplicationName}", test "${test.testName}"`,
210-
);
220+
const testResults: TestResult[] = recipe.tests.map(test => {
221+
console.log(
222+
`Running E2E test command for test application "${recipe.testApplicationName}", test "${test.testName}"${dependencyOverridesInformationString}`,
223+
);
211224

212-
const testProcessResult = childProcess.spawnSync(test.testCommand, {
213-
cwd: path.dirname(recipePath),
214-
timeout: (test.timeoutSeconds ?? DEFAULT_TEST_TIMEOUT_SECONDS) * 1000,
215-
encoding: 'utf8',
216-
shell: true, // needed so we can pass the test command in as whole without splitting it up into args
217-
env: {
218-
...process.env,
219-
...envVarsToInject,
220-
},
225+
const testProcessResult = childProcess.spawnSync(test.testCommand, {
226+
cwd: path.dirname(recipePath),
227+
timeout: (test.timeoutSeconds ?? DEFAULT_TEST_TIMEOUT_SECONDS) * 1000,
228+
encoding: 'utf8',
229+
shell: true, // needed so we can pass the test command in as whole without splitting it up into args
230+
env: {
231+
...process.env,
232+
...envVarsToInject,
233+
},
234+
});
235+
236+
// Prepends some text to the output test command's output so we can distinguish it from logging in this script
237+
console.log(testProcessResult.stdout.replace(/^/gm, '[TEST OUTPUT] '));
238+
console.log(testProcessResult.stderr.replace(/^/gm, '[TEST OUTPUT] '));
239+
240+
const error: undefined | (Error & { code?: string }) = testProcessResult.error;
241+
242+
if (error?.code === 'ETIMEDOUT') {
243+
processShouldExitWithError = true;
244+
printCIErrorMessage(
245+
`Test "${test.testName}" in test application "${recipe.testApplicationName}" (${path.dirname(
246+
recipePath,
247+
)}) timed out.`,
248+
);
249+
return {
250+
testName: test.testName,
251+
result: 'TIMEOUT',
252+
};
253+
} else if (testProcessResult.status !== 0) {
254+
processShouldExitWithError = true;
255+
printCIErrorMessage(
256+
`Test "${test.testName}" in test application "${recipe.testApplicationName}" (${path.dirname(
257+
recipePath,
258+
)}) failed.`,
259+
);
260+
return {
261+
testName: test.testName,
262+
result: 'FAIL',
263+
};
264+
} else {
265+
console.log(
266+
`Test "${test.testName}" in test application "${recipe.testApplicationName}" (${path.dirname(
267+
recipePath,
268+
)}) succeeded.`,
269+
);
270+
return {
271+
testName: test.testName,
272+
result: 'PASS',
273+
};
274+
}
221275
});
222276

223-
// Prepends some text to the output test command's output so we can distinguish it from logging in this script
224-
console.log(testProcessResult.stdout.replace(/^/gm, '[TEST OUTPUT] '));
225-
console.log(testProcessResult.stderr.replace(/^/gm, '[TEST OUTPUT] '));
277+
return {
278+
dependencyOverrides,
279+
buildFailed: false,
280+
testResults,
281+
};
282+
}
226283

227-
const error: undefined | (Error & { code?: string }) = testProcessResult.error;
284+
const versionsToRun: {
285+
dependencyOverrides?: Record<string, string>;
286+
}[] = (process.env.CANARY_E2E_TEST ? recipe.canaryVersions : recipe.versions) ?? [{}];
228287

229-
if (error?.code === 'ETIMEDOUT') {
230-
processShouldExitWithError = true;
231-
printCIErrorMessage(
232-
`Test "${test.testName}" in test application "${recipe.testApplicationName}" (${path.dirname(
233-
recipePath,
234-
)}) timed out.`,
235-
);
236-
return {
237-
testName: test.testName,
238-
result: 'TIMEOUT',
239-
};
240-
} else if (testProcessResult.status !== 0) {
241-
processShouldExitWithError = true;
242-
printCIErrorMessage(
243-
`Test "${test.testName}" in test application "${recipe.testApplicationName}" (${path.dirname(
244-
recipePath,
245-
)}) failed.`,
246-
);
247-
return {
248-
testName: test.testName,
249-
result: 'FAIL',
250-
};
251-
} else {
252-
console.log(
253-
`Test "${test.testName}" in test application "${recipe.testApplicationName}" (${path.dirname(
254-
recipePath,
255-
)}) succeeded.`,
288+
const versionResults = versionsToRun.map(({ dependencyOverrides }) => {
289+
if (dependencyOverrides) {
290+
// Back up original package.json
291+
fs.copyFileSync(path.resolve(recipeDirname, 'package.json'), path.resolve(recipeDirname, 'package.json.bak'));
292+
293+
// Override dependencies
294+
const packageJson: { dependencies?: Record<string, string> } = JSON.parse(
295+
fs.readFileSync(path.resolve(recipeDirname, 'package.json'), { encoding: 'utf-8' }),
256296
);
257-
return {
258-
testName: test.testName,
259-
result: 'PASS',
260-
};
297+
packageJson.dependencies = packageJson.dependencies
298+
? { ...packageJson.dependencies, ...dependencyOverrides }
299+
: dependencyOverrides;
300+
fs.writeFileSync(path.resolve(recipeDirname, 'package.json'), JSON.stringify(packageJson, null, 2), {
301+
encoding: 'utf-8',
302+
});
303+
}
304+
305+
try {
306+
return runRecipe(dependencyOverrides);
307+
} finally {
308+
if (dependencyOverrides) {
309+
// Restore original package.json
310+
fs.rmSync(path.resolve(recipeDirname, 'package.json'), { force: true });
311+
fs.copyFileSync(path.resolve(recipeDirname, 'package.json.bak'), path.resolve(recipeDirname, 'package.json'));
312+
fs.rmSync(path.resolve(recipeDirname, 'package.json.bak'), { force: true });
313+
}
261314
}
262315
});
263316

264317
return {
265318
testApplicationName: recipe.testApplicationName,
266319
testApplicationPath: recipePath,
267-
buildFailed: false,
268-
testResults,
320+
versionResults,
269321
};
270322
});
271323

272324
console.log('--------------------------------------');
273325
console.log('Test Result Summary:');
274326

275327
recipeResults.forEach(recipeResult => {
276-
if (recipeResult.buildFailed) {
277-
console.log(
278-
`● BUILD FAILED - ${recipeResult.testApplicationName} (${path.dirname(recipeResult.testApplicationPath)})`,
279-
);
280-
} else {
281-
console.log(
282-
`● BUILD SUCCEEDED - ${recipeResult.testApplicationName} (${path.dirname(recipeResult.testApplicationPath)})`,
283-
);
284-
recipeResult.testResults.forEach(testResult => {
285-
console.log(` ● ${testResult.result.padEnd(7, ' ')} ${testResult.testName}`);
286-
});
287-
}
328+
recipeResult.versionResults.forEach(versionResult => {
329+
const dependencyOverridesInformationString = versionResult.dependencyOverrides
330+
? ` (Dependency overrides: ${JSON.stringify(versionResult.dependencyOverrides)})`
331+
: '';
332+
333+
if (versionResult.buildFailed) {
334+
console.log(
335+
`● BUILD FAILED - ${recipeResult.testApplicationName} (${path.dirname(
336+
recipeResult.testApplicationPath,
337+
)})${dependencyOverridesInformationString}`,
338+
);
339+
} else {
340+
console.log(
341+
`● BUILD SUCCEEDED - ${recipeResult.testApplicationName} (${path.dirname(
342+
recipeResult.testApplicationPath,
343+
)})${dependencyOverridesInformationString}`,
344+
);
345+
versionResult.testResults.forEach(testResult => {
346+
console.log(` ● ${testResult.result.padEnd(7, ' ')} ${testResult.testName}`);
347+
});
348+
}
349+
});
288350
});
289351

290352
groupCIOutput('Cleanup', () => {

packages/e2e-tests/test-applications/standard-frontend-react/test-recipe.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,33 @@
77
"testName": "Playwright tests",
88
"testCommand": "yarn test"
99
}
10+
],
11+
"versions": [
12+
{
13+
"dependencyOverrides": {
14+
"react": "^18",
15+
"react-dom": "^18"
16+
}
17+
},
18+
{
19+
"dependencyOverrides": {
20+
"react": "^17",
21+
"react-dom": "^17"
22+
}
23+
},
24+
{
25+
"dependencyOverrides": {
26+
"react": "^16",
27+
"react-dom": "^16"
28+
}
29+
}
30+
],
31+
"canaryVersions": [
32+
{
33+
"dependencyOverrides": {
34+
"react": "latest",
35+
"react-dom": "latest"
36+
}
37+
}
1038
]
1139
}

0 commit comments

Comments
 (0)