-
Notifications
You must be signed in to change notification settings - Fork 0
/
assertions.ts
475 lines (429 loc) · 19.4 KB
/
assertions.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
/*
* Copyright (c) 2021, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { expect } from 'chai';
import { JsonMap, Nullable } from '@salesforce/ts-types';
import fg from 'fast-glob';
import { Connection } from '@salesforce/core';
import { FileResponseSuccess, MetadataResolver } from '@salesforce/source-deploy-retrieve';
import { debug, Debugger } from 'debug';
import { ApexClass, ApexTestResult, Commands, Context, SourceMember, SourceState, StatusResult } from './types';
import { ExecutionLog } from './executionLog';
import { countFiles, FileTracker } from './fileTracker';
/* eslint-disable no-await-in-loop */
/**
* Assertions is a class that is designed to encapsulate common assertions we want to
* make during NUT testings
*
* To see debug logs for command executions set these env vars:
* - DEBUG=assertions:* (for logs from all nuts)
* - DEBUG=assertions:<filename.nut.ts> (for logs from specific nut)
*/
export class Assertions {
private debug: Debugger;
private metadataResolver: MetadataResolver;
private projectDir: string;
private connection: Nullable<Connection>;
private commands: Commands;
public constructor(context: Context, private executionLog: ExecutionLog, private fileTracker: FileTracker) {
this.projectDir = context.projectDir;
this.connection = context.connection;
this.commands = context.commands;
this.debug = debug(`assertions:${context.nut}`);
this.metadataResolver = new MetadataResolver();
}
/**
* Expect given file to be changed according to the file history provided by FileTracker
*/
public fileToBeChanged(file: string): void {
const fileHistory = this.fileTracker.getLatest(file);
expect(fileHistory?.changedFromPrevious, 'File to be changed').to.be.true;
}
/**
* Expect given file to NOT be changed according to the file history provided by FileTracker
*/
public fileToNotBeChanged(file: string): void {
const fileHistory = this.fileTracker.getLatest(file);
expect(fileHistory?.changedFromPrevious, 'File to NOT be changed').to.be.false;
}
/**
* Expect all files found by globs to be changed according to the file history provided by FileTracker
*/
public async filesToBeChanged(globs: string[], ignore: string[] = []): Promise<void> {
const all = await this.doGlob(globs);
// don't assert a result if nothing is to be ignored
const toIgnore = await this.doGlob(ignore, false);
const toTrack = all.filter((file) => !toIgnore.includes(file));
const fileHistories = toTrack
// StaticResource types are inconsistently changed
.filter((f) => !f.endsWith('.resource-meta.xml'))
.filter((f) => !f.endsWith('.resource'))
.map((f) => this.fileTracker.getLatest(path.normalize(f)))
.filter((f) => !!f);
expect(fileHistories, 'file history to exist for tracked files').length.to.be.greaterThan(0);
const allChanged = fileHistories.every((f) => f?.changedFromPrevious);
expect(allChanged, 'all files to be changed').to.be.true;
}
/**
* Expect all files found by globs to NOT be changed according to the file history provided by FileTracker
*/
public async filesToNotBeChanged(globs: string[], ignore: string[] = []): Promise<void> {
const all = await this.doGlob(globs);
const toIgnore = await this.doGlob(ignore, false);
const toTrack = all.filter((file) => !toIgnore.includes(file));
const fileHistories = toTrack
.filter((f) => !f.endsWith('.resource-meta.xml'))
.map((f) => this.fileTracker.getLatest(f))
.filter((f) => !!f);
expect(fileHistories, 'file history to exist for tracked files').length.to.be.greaterThan(0);
const allChanged = fileHistories.every((f) => f?.changedFromPrevious);
expect(allChanged, 'all files to NOT be changed').to.be.false;
}
/**
* Finds all files in project based on the provided globs and expects them to be updated on the server
*/
public async filesToBeDeployed(
globs: string[],
ignore: string[] = [],
deployCommand = this.commands.deploy
): Promise<void> {
await this.filesToBeUpdated(globs, ignore, deployCommand);
}
/**
* Finds all files in project based on the provided globs and expects them to be retrieved from the server
*/
public async filesToBeRetrieved(
globs: string[],
ignore: string[] = [],
retrieveCommand = this.commands.retrieve
): Promise<void> {
await this.filesToBeUpdated(globs, ignore, retrieveCommand);
}
/**
* Finds all files in project based on the provided globs and expects them to be updated on the server by comparing to what was returned in the FileResponse[]
*/
public async filesToBeDeployedViaResult(
globs: string[],
ignore: string[] = [],
result: FileResponseSuccess[]
): Promise<void> {
const all = await this.doGlob(globs);
const ignoreFiles = await this.doGlob(ignore, false);
// glob will return files with '/' as separators, this won't work on Windows
const toTrack = all.filter((file) => !ignoreFiles.includes(file)).map((file) => file.replace(/\//g, path.sep));
result.map((source) => {
expect(toTrack, `toTrack: ${toTrack.join('\n')}, missing file : ${source.filePath}`).to.include(source.filePath);
});
}
/**
* Finds all files in project based on the provided globs and expects them to NOT be updated on the server
*/
public async filesToNotBeDeployed(
globs: string[],
ignore: string[] = [],
deployCommand = this.commands.deploy
): Promise<void> {
await this.filesToNotBeUpdated(globs, ignore, deployCommand);
}
/**
* Finds all files in project based on the provided globs and expects SOME of them to NOT be updated on the server.
* This is helpful for testing force:source:deploy:cancel where we can know beforehand which files will be deployed.
*/
public async someFilesToNotBeDeployed(globs: string[], deployCommand = this.commands.deploy): Promise<void> {
await this.someFilesToNotBeUpdated(globs, deployCommand);
}
/**
* Expects given file to exist
*/
public async fileToExist(file: string): Promise<void> {
const fullPath = file.startsWith(this.projectDir) ? file : path.join(this.projectDir, file);
const fileExists = fs.existsSync(fullPath);
expect(fileExists, `${fullPath} to exist`).to.be.true;
}
/**
* Expects given globs to return files
*/
public async filesToExist(globs: string[]): Promise<void> {
for (const glob of globs) {
const results = await this.doGlob([glob]);
expect(results.length, `expect files to be found by glob: ${glob}`).to.be.greaterThan(0);
}
}
/**
* Expects given globs to NOT return files
*/
public async filesToNotExist(globs: string[]): Promise<void> {
for (const glob of globs) {
const results = await this.doGlob([glob], false);
expect(results.length, `expect no files to be found by glob: ${glob}`).to.equal(0);
}
}
/**
* Expects files to exist in convert output directory
*/
// eslint-disable-next-line class-methods-use-this
public async filesToBeConverted(directory: string, globs: string[]): Promise<void> {
directory = directory.split(path.sep).join('/');
const fullGlobs = globs.map((glob) => [directory, glob].join('/'));
const convertedFiles = await fg(fullGlobs);
expect(convertedFiles.length, 'files to be converted').to.be.greaterThan(0);
}
/**
* Expects files found by glob to not contain any of the provided strings
*/
public async filesToNotContainString(glob: string, ...strings: string[]): Promise<void> {
const files = await this.doGlob([glob]);
for (const file of files) {
const contents = await fs.promises.readFile(file, 'utf-8');
for (const str of strings) {
expect(contents, `expect ${file} to not include ${str}`).to.not.include(str);
}
}
}
/**
* Expects files found by glob to contain the provided strings
*/
public async filesToContainString(glob: string, ...strings: string[]): Promise<void> {
const files = await this.doGlob([glob]);
for (const file of files) {
const contents = await fs.promises.readFile(file, 'utf-8');
for (const str of strings) {
expect(contents, `expect ${file} to not include ${str}`).to.include(str);
}
}
}
/**
* Expect the retrieved package to exist and contain some files
*/
public async packagesToBeRetrieved(pkgNames: string[]): Promise<void> {
for (const pkgName of pkgNames) {
await this.fileToExist(pkgName);
await this.directoryToHaveSomeFiles(pkgName);
}
}
/**
* Expect all given files to be be updated in the org
*/
public async filesToBePushed(globs: string[]): Promise<void> {
await this.filesToBeUpdated(globs, [], this.commands.push);
}
/**
* Expect the given directory to contain at least 1 file
*/
public async directoryToHaveSomeFiles(directory: string): Promise<void> {
const fullPath = directory.startsWith(this.projectDir) ? directory : path.join(this.projectDir, directory);
const fileCount = await countFiles([fullPath]);
expect(fileCount, `at least 1 file found in ${directory}`).to.be.greaterThan(0);
}
/**
* Expect status to return no results
*/
// eslint-disable-next-line class-methods-use-this
public statusToBeEmpty(result: StatusResult): void {
expect(result.length, 'status to have no results').to.equal(0);
}
/**
* Expect all given files to have the given state
*/
public statusFilesToHaveState(result: StatusResult, state: SourceState, files: string[]): void {
for (const file of files) {
this.statusFileToHaveState(result, state, file);
}
expect(result.length, 'all files to be present in json response').to.equal(files.length);
}
/**
* Expect given file to have the given state
*/
// eslint-disable-next-line class-methods-use-this
public statusFileToHaveState(result: StatusResult, state: SourceState, file: string): void {
const expectedFile = result.find((r) => r.filePath === file);
expect(expectedFile, `${file} to be present in json response`).to.not.be.undefined;
expect(expectedFile?.state, `${file} to have state ${state}`).to.equal(state);
}
/**
* Expect all given files to have the given state
*/
// eslint-disable-next-line class-methods-use-this
public statusToOnlyHaveState(result: StatusResult, state: SourceState): void {
const allStates = result.every((r) => r.state === state);
expect(allStates, `all files to have ${state}`).to.be.true;
}
/**
* Expect all files to have a conflict state
*/
// eslint-disable-next-line class-methods-use-this
public statusToOnlyHaveConflicts(result: StatusResult): void {
const allConflicts = result.every((r) => r.state.includes('Conflict'));
expect(allConflicts, 'all results to show conflict state').to.be.true;
}
/**
* Expect json to have given error name
*/
// eslint-disable-next-line class-methods-use-this
public errorToHaveName(result: JsonMap, name: string): void {
expect(result).to.have.property('name');
expect(result.name, `error name to equal ${name}`).to.equal(name);
}
/**
* Expect error json to include given message
*/
// eslint-disable-next-line class-methods-use-this
public errorToHaveMessage(result: JsonMap, message: string): void {
expect(result).to.have.property('message');
expect(result.message, `error name to include ${message}`).to.include(message);
}
/**
* Expect no apex tests to be run
*/
public async noApexTestsToBeRun(): Promise<void> {
const executionTimestamp = this.executionLog.getLatestTimestamp(this.commands.deploy);
const testResults = await this.retrieveApexTestResults();
const testsRunAfterTimestamp = testResults.filter((r) => new Date(r.TestTimestamp) > executionTimestamp);
expect(testsRunAfterTimestamp.length, 'no tests to be run during deploy').to.equal(0);
}
/**
* Expect some apex tests to be run
*/
public async apexTestsToBeRun(): Promise<void> {
const executionTimestamp = this.executionLog.getLatestTimestamp(this.commands.deploy);
const testResults = await this.retrieveApexTestResults();
const testsRunAfterTimestamp = testResults.filter((r) => new Date(r.TestTimestamp) > executionTimestamp);
expect(testsRunAfterTimestamp.length, 'tests to be run during deploy').to.be.greaterThan(0);
}
/**
* Expect apex tests owned by the provided classes to be run
*/
public async specificApexTestsToBeRun(classNames: string[]): Promise<void> {
const apexClasses = await this.retrieveApexClasses(classNames);
const classIds = apexClasses.map((c) => c.Id);
const executionTimestamp = this.executionLog.getLatestTimestamp(this.commands.deploy);
const testResults = await this.retrieveApexTestResults();
const testsRunAfterTimestamp = testResults.filter(
(r) => new Date(r.TestTimestamp) > executionTimestamp && classIds.includes(r.ApexClassId)
);
expect(testsRunAfterTimestamp.length, 'tests to be run during deploy').to.be.greaterThan(0);
}
/**
* Expect result to have given property
*/
// eslint-disable-next-line class-methods-use-this
public toHaveProperty(result: JsonMap, prop: string): void {
expect(result).to.have.property(prop);
}
/**
* Expect result to have given property and for that property to equal the given value
*/
// eslint-disable-next-line class-methods-use-this
public toHavePropertyAndValue(result: JsonMap, prop: string, value: string | number): void {
expect(result).to.have.property(prop);
expect(result[prop], `${prop} to have value ${value.toString()}`).to.equal(value);
}
/**
* Expect result to have given property and for that property to NOT equal the given value
*/
// eslint-disable-next-line class-methods-use-this
public toHavePropertyAndNotValue(result: JsonMap, prop: string, value: string | number): void {
expect(result).to.have.property(prop);
expect(result[prop], `${prop} to have value that does not equal ${value.toString()}`).to.not.equal(value);
}
private async filesToBeUpdated(globs: string[], ignore: string[] = [], command: string): Promise<void> {
const sourceMembers = this.executionLog.getLatest(command)?.sourceMembers || [];
const latestSourceMembers = await this.retrieveSourceMembers(globs, ignore);
for (const sourceMember of latestSourceMembers) {
const assertionMessage = `expect RevisionCounter for ${sourceMember.MemberName} (${sourceMember.MemberType}) to be incremented`;
const preCommandExecution = sourceMembers.find(
(s) => s.MemberType === sourceMember.MemberType && s.MemberName === sourceMember.MemberName
) ?? { RevisionCounter: 0 };
expect(sourceMember.RevisionCounter, assertionMessage).to.be.greaterThan(preCommandExecution.RevisionCounter);
}
}
private async filesToNotBeUpdated(globs: string[], ignore: string[] = [], command: string): Promise<void> {
const sourceMembers = this.executionLog.getLatest(command)?.sourceMembers || [];
const latestSourceMembers = await this.retrieveSourceMembers(globs, ignore);
if (!latestSourceMembers.length) {
// Not finding any source members based on the globs means that there is no SourceMember for those files
// which we're assuming means that it hasn't been deployed to the org yet.
// That's acceptable for this test since we're testing that metadata hasn't been deployed.
expect(latestSourceMembers.length).to.equal(0);
} else {
for (const sourceMember of latestSourceMembers) {
const assertionMessage = `expect RevisionCounter for ${sourceMember.MemberName} (${sourceMember.MemberType}) to NOT be incremented`;
const preCommandExecution = sourceMembers.find(
(s) => s.MemberType === sourceMember.MemberType && s.MemberName === sourceMember.MemberName
) ?? { RevisionCounter: 0 };
expect(sourceMember.RevisionCounter, assertionMessage).to.equal(preCommandExecution.RevisionCounter);
}
}
}
private async someFilesToNotBeUpdated(globs: string[], command: string): Promise<void> {
const sourceMembers = this.executionLog.getLatest(command)?.sourceMembers || [];
const latestSourceMembers = await this.retrieveSourceMembers(globs);
const someAreNotUpdated = latestSourceMembers.some((sourceMember) => {
const preCommandExecution = sourceMembers.find(
(s) => s.MemberType === sourceMember.MemberType && s.MemberName === sourceMember.MemberName
) ?? { RevisionCounter: 0 };
return sourceMember.RevisionCounter === preCommandExecution.RevisionCounter;
});
expect(someAreNotUpdated, 'expect some SourceMembers to not be updated').to.be.true;
}
private async retrieveSourceMembers(globs: string[], ignore: string[] = []): Promise<SourceMember[]> {
const query = 'SELECT Id,MemberName,MemberType,RevisionCounter FROM SourceMember';
const result = await this.connection?.tooling.query<SourceMember>(query, { autoFetch: true, maxFetch: 50_000 });
const all = await this.doGlob(globs);
const ignoreFiles = await this.doGlob(ignore, false);
const toTrack = all.filter((file) => !ignoreFiles.includes(file));
const membersMap = new Map<string, Set<string>>();
for (const file of toTrack) {
// glob will return files with '/' as separators, this won't work on Windows
const components = this.metadataResolver.getComponentsFromPath(file.replace(/\//g, path.sep));
for (const component of components) {
const metadataType = component.type.name;
const metadataName = component.fullName;
if (membersMap.has(metadataType)) {
const updated = membersMap.get(metadataType)!.add(metadataName);
membersMap.set(metadataType, updated);
} else {
membersMap.set(metadataType, new Set([metadataName]));
}
}
}
return (result?.records ?? []).filter((sourceMember) =>
membersMap.get(sourceMember.MemberType)?.has(sourceMember.MemberName)
);
}
private async retrieveApexTestResults(): Promise<ApexTestResult[]> {
const query = 'SELECT TestTimestamp, ApexClassId FROM ApexTestResult';
const result = await this.connection?.tooling.query<ApexTestResult>(query, { autoFetch: true, maxFetch: 50_000 });
return result?.records ?? [];
}
private async retrieveApexClasses(classNames?: string[]): Promise<ApexClass[]> {
const query = 'SELECT Name,Id FROM ApexClass';
const result = await this.connection?.tooling.query<ApexClass>(query, { autoFetch: true, maxFetch: 50_000 });
const records = result?.records ?? [];
return classNames ? records.filter((r) => classNames.includes(r.Name)) : records;
}
private async doGlob(globs: string[], assert = true): Promise<string[]> {
const files: string[] = [];
const dir = this.projectDir.replace(/\\/g, '/');
for (let glob of globs) {
let fullGlob = glob.replace(/\\/g, '/');
if (glob.startsWith('!')) {
glob = glob.substr(1);
fullGlob = glob.startsWith(dir) ? `!${glob}` : [`!${dir}`, glob].join('/');
} else {
fullGlob = glob.startsWith(dir) ? glob : [dir, glob].join('/');
}
this.debug(`Finding files using glob: ${fullGlob}`);
const globResults = await fg(fullGlob);
this.debug('Found: %O', globResults);
files.push(...globResults);
}
if (assert) expect(files.length, 'globs to return files').to.be.greaterThan(0);
return files;
}
}