Skip to content

Commit df7916b

Browse files
committed
Incorporated review comments.
1 parent 73b7b81 commit df7916b

15 files changed

+317
-260
lines changed

src/compare/compare.module.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,24 @@ import { CompareService } from './compare.service';
33
import { LookSameService } from './libs/looks-same/looks-same.service';
44
import { OdiffService } from './libs/odiff/odiff.service';
55
import { PixelmatchService } from './libs/pixelmatch/pixelmatch.service';
6+
import { AWSS3Service } from 'src/shared/static/aws-s3.servce.';
7+
import { HardDiskService } from 'src/shared/static/hard-disk.service';
8+
import { STATIC_SERVICE, StaticService } from 'src/shared/static/static-service.interface';
69

710
@Module({
8-
providers: [CompareService, PixelmatchService, LookSameService, OdiffService],
11+
providers: [
12+
{
13+
provide: STATIC_SERVICE,
14+
useFactory: (): StaticService => {
15+
const isAWSDefined = process.env.USE_AWS_S3_BUCKET?.trim().toLowerCase() === 'true';
16+
return isAWSDefined ? new AWSS3Service() : new HardDiskService();
17+
},
18+
},
19+
CompareService,
20+
PixelmatchService,
21+
LookSameService,
22+
OdiffService,
23+
],
924
exports: [CompareService],
1025
})
1126
export class CompareModule {}

src/compare/libs/looks-same/looks-same.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Inject, Injectable, Logger } from '@nestjs/common';
22
import { TestStatus } from '@prisma/client';
33
import { PNG } from 'pngjs';
4-
import { StaticService } from '../../../shared/static/static.service';
4+
import { STATIC_SERVICE, StaticService } from '../../../shared/static/static-service.interface';
55
import { DiffResult } from '../../../test-runs/diffResult';
66
import { applyIgnoreAreas, parseConfig } from '../../utils';
77
import { ImageComparator } from '../image-comparator.interface';
@@ -21,7 +21,7 @@ export const DEFAULT_CONFIG: LooksSameConfig = {
2121
export class LookSameService implements ImageComparator {
2222
private readonly logger: Logger = new Logger(LookSameService.name);
2323

24-
constructor(private staticService: StaticService) {}
24+
constructor(@Inject(STATIC_SERVICE) private staticService: StaticService) {}
2525

2626
parseConfig(configJson: string): LooksSameConfig {
2727
return parseConfig(configJson, DEFAULT_CONFIG, this.logger);

src/compare/libs/odiff/odiff.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Inject, Injectable, Logger } from '@nestjs/common';
22
import { TestStatus } from '@prisma/client';
3-
import { StaticService } from '../../../shared/static/static.service';
3+
import { STATIC_SERVICE, StaticService } from '../../../shared/static/static-service.interface';
44
import { DiffResult } from '../../../test-runs/diffResult';
55
import { parseConfig } from '../../utils';
66
import { ImageComparator } from '../image-comparator.interface';
@@ -21,7 +21,7 @@ export const DEFAULT_CONFIG: OdiffConfig = {
2121
export class OdiffService implements ImageComparator {
2222
private readonly logger: Logger = new Logger(OdiffService.name);
2323

24-
constructor(private staticService: StaticService) {}
24+
constructor(@Inject(STATIC_SERVICE) private staticService: StaticService) {}
2525

2626
parseConfig(configJson: string): OdiffConfig {
2727
return parseConfig(configJson, DEFAULT_CONFIG, this.logger);

src/compare/libs/pixelmatch/pixelmatch.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Inject, Injectable, Logger } from '@nestjs/common';
22
import { TestStatus } from '@prisma/client';
33
import Pixelmatch from 'pixelmatch';
44
import { PNG } from 'pngjs';
5-
import { StaticService } from '../../../shared/static/static.service';
5+
import { STATIC_SERVICE, StaticService } from '../../../shared/static/static-service.interface';
66
import { DiffResult } from '../../../test-runs/diffResult';
77
import { scaleImageToSize, applyIgnoreAreas, parseConfig } from '../../utils';
88
import { DIFF_DIMENSION_RESULT, EQUAL_RESULT, NO_BASELINE_RESULT } from '../consts';
@@ -16,7 +16,7 @@ export const DEFAULT_CONFIG: PixelmatchConfig = { threshold: 0.1, ignoreAntialia
1616
export class PixelmatchService implements ImageComparator {
1717
private readonly logger: Logger = new Logger(PixelmatchService.name);
1818

19-
constructor(private staticService: StaticService) {}
19+
constructor(@Inject(STATIC_SERVICE) private staticService: StaticService) {}
2020

2121
parseConfig(configJson: string): PixelmatchConfig {
2222
return parseConfig(configJson, DEFAULT_CONFIG, this.logger);

src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { join } from 'path';
66
import * as bodyParser from 'body-parser';
77
import { readFileSync, existsSync } from 'fs';
88
import { HttpsOptions } from '@nestjs/common/interfaces/external/https-options.interface';
9-
import { IMAGE_PATH } from './shared/static/static.service';
109
import { NestExpressApplication } from '@nestjs/platform-express';
10+
import { IMAGE_PATH } from './shared/static/common-file-service';
1111

1212
function getHttpsOptions(): HttpsOptions | null {
1313
const keyPath = './secrets/ssl.key';

src/shared/shared.module.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
11
import { forwardRef, Global, Module } from '@nestjs/common';
2-
import { StaticService } from './static/static.service';
2+
import { STATIC_SERVICE, StaticService } from './static/static-service.interface';
33
import { EventsGateway } from '../shared/events/events.gateway';
44
import { PrismaService } from '../prisma/prisma.service';
55
import { TasksService } from './tasks/tasks.service';
66
import { TestVariationsModule } from '../test-variations/test-variations.module';
77
import { StaticController } from './static/static.controller';
8+
import { AWSS3Service } from './static/aws-s3.servce.';
9+
import { HardDiskService } from './static/hard-disk.service';
810

911
@Global()
1012
@Module({
11-
providers: [StaticService, EventsGateway, PrismaService, TasksService],
12-
exports: [StaticService, EventsGateway, PrismaService],
13+
providers: [
14+
{
15+
provide: STATIC_SERVICE,
16+
useFactory: (): StaticService => {
17+
const isAWSDefined = process.env.USE_AWS_S3_BUCKET?.trim().toLowerCase() === 'true';
18+
return isAWSDefined ? new AWSS3Service() : new HardDiskService();
19+
},
20+
},
21+
AWSS3Service,
22+
HardDiskService,
23+
EventsGateway,
24+
PrismaService,
25+
TasksService,
26+
],
27+
exports: [STATIC_SERVICE, EventsGateway, PrismaService],
1328
imports: [forwardRef(() => TestVariationsModule)],
1429
controllers: [StaticController],
1530
})
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { readFileSync, createWriteStream } from 'fs';
3+
import { PNG, PNGWithMetadata } from 'pngjs';
4+
5+
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
6+
import { Readable } from 'stream';
7+
import { StaticService } from './static-service.interface';
8+
import { CommonFileService } from './common-file-service';
9+
10+
@Injectable()
11+
export class AWSS3Service extends CommonFileService implements StaticService {
12+
private readonly AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
13+
private readonly AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
14+
private readonly AWS_REGION = process.env.AWS_REGION;
15+
private readonly AWS_S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME;
16+
17+
private s3Client: S3Client;
18+
19+
constructor() {
20+
super();
21+
this.s3Client = new S3Client({
22+
credentials: {
23+
accessKeyId: this.AWS_ACCESS_KEY_ID,
24+
secretAccessKey: this.AWS_SECRET_ACCESS_KEY,
25+
},
26+
region: this.AWS_REGION,
27+
});
28+
this.logger = new Logger(AWSS3Service.name);
29+
this.logger.log('AWS S3 service is being used for file storage.');
30+
setInterval(() => this.cleanupQueuedFiles(), this.DELETE_INTERVAL);
31+
this.cleanUpAllFiles();
32+
}
33+
34+
async saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise<string> {
35+
const { imageName } = this.generateNewImage(type);
36+
try {
37+
await this.s3Client.send(
38+
new PutObjectCommand({
39+
Bucket: this.AWS_S3_BUCKET_NAME,
40+
Key: imageName,
41+
ContentType: 'image/png',
42+
Body: imageBuffer,
43+
})
44+
);
45+
return imageName;
46+
} catch (ex) {
47+
throw new Error('Could not save file at AWS S3 : ' + ex);
48+
}
49+
}
50+
51+
async getImage(fileName: string): Promise<PNGWithMetadata> {
52+
if (!fileName) return null;
53+
try {
54+
if (!this.doesFileExistLocally(fileName)) {
55+
const localFileStream = await this.saveFileFromCloud(fileName);
56+
await new Promise<void>((resolve, reject) => {
57+
localFileStream.on('finish', () => {
58+
this.scheduleLocalFileDeletion(fileName);
59+
this.checkLocalDiskUsageAndClean();
60+
resolve();
61+
});
62+
localFileStream.on('error', (error) => {
63+
this.logger.error('Error writing file:', error);
64+
reject(error);
65+
});
66+
});
67+
}
68+
return PNG.sync.read(readFileSync(this.getImagePath(fileName)));
69+
} catch (ex) {
70+
this.logger.error(`Error from read : Cannot get image: ${fileName}. ${ex}`);
71+
}
72+
}
73+
74+
async saveFileFromCloud(fileName: string) {
75+
const command = new GetObjectCommand({ Bucket: this.AWS_S3_BUCKET_NAME, Key: fileName });
76+
const s3Response = await this.s3Client.send(command);
77+
const fileStream = s3Response.Body as Readable;
78+
const localFileStream = createWriteStream(this.getImagePath(fileName));
79+
fileStream.pipe(localFileStream);
80+
this.logger.log(`File feom AWS S3 saved at ${this.getImagePath(fileName)}`);
81+
return localFileStream;
82+
}
83+
84+
async deleteImage(imageName: string): Promise<boolean> {
85+
if (!imageName) return false;
86+
try {
87+
await this.s3Client.send(new DeleteObjectCommand({ Bucket: this.AWS_S3_BUCKET_NAME, Key: imageName }));
88+
return true;
89+
} catch (error) {
90+
this.logger.log(`Failed to delete file at AWS S3 for image ${imageName}:`, error);
91+
return false; // Return `false` if an error occurs.
92+
}
93+
}
94+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import path from 'path';
2+
import { unlink, mkdirSync, existsSync, unlinkSync, statSync, readdir } from 'fs';
3+
import { Logger } from '@nestjs/common/services/logger.service';
4+
5+
export const IMAGE_PATH = 'imageUploads/';
6+
7+
export class CommonFileService {
8+
readonly DELETE_INTERVAL = 60 * 60 * 1000; // Check for deletions every hour
9+
deletionQueue: { filePath: string; size: number }[] = [];
10+
readonly MAX_DISK_USAGE: number = parseInt(process.env.MAX_TEMP_STORAGE_FOR_S3_DOWNLOAD) * 1 * 1024 * 1024;
11+
logger: Logger;
12+
13+
generateNewImage(type: 'screenshot' | 'diff' | 'baseline'): { imageName: string; imagePath: string } {
14+
const imageName = `${Date.now()}.${type}.png`;
15+
return {
16+
imageName,
17+
imagePath: this.getImagePath(imageName),
18+
};
19+
}
20+
21+
getImagePath(imageName: string): string {
22+
this.ensureDirectoryExistence(IMAGE_PATH);
23+
return path.resolve(IMAGE_PATH, imageName);
24+
}
25+
26+
private ensureDirectoryExistence(dir: string) {
27+
const filePath = path.resolve(dir);
28+
if (existsSync(filePath)) {
29+
return true;
30+
} else {
31+
mkdirSync(dir, { recursive: true });
32+
this.ensureDirectoryExistence(dir);
33+
}
34+
}
35+
36+
doesFileExistLocally(fileName: string) {
37+
return existsSync(this.getImagePath(fileName));
38+
}
39+
40+
scheduleLocalFileDeletion(fileName: string): void {
41+
const filePath = this.getImagePath(fileName);
42+
const fileSize = statSync(filePath).size;
43+
this.deletionQueue.push({ filePath, size: fileSize });
44+
this.logger.log(`Scheduled deletion for file: ${filePath} with size: ${fileSize} bytes`);
45+
}
46+
47+
checkLocalDiskUsageAndClean(): void {
48+
const totalDiskUsage = this.getTotalDiskUsage();
49+
if (totalDiskUsage > this.MAX_DISK_USAGE) {
50+
this.logger.log(`Disk usage exceeded. Triggering cleanup.`);
51+
this.cleanupQueuedFiles();
52+
}
53+
}
54+
55+
getTotalDiskUsage(): number {
56+
return this.deletionQueue.reduce((total, file) => total + file.size, 0);
57+
}
58+
59+
cleanUpAllFiles() {
60+
readdir(path.resolve(IMAGE_PATH), (err, files) => {
61+
if (err) throw err;
62+
for (const file of files) {
63+
unlink(path.join(path.resolve(IMAGE_PATH), file), () => {});
64+
}
65+
});
66+
}
67+
68+
cleanupQueuedFiles(): void {
69+
const totalDiskUsage = this.getTotalDiskUsage();
70+
this.logger.log(`Cleaning up files. Total disk usage: ${totalDiskUsage} bytes`);
71+
72+
// Delete files until the total disk usage is within the limit
73+
let currentUsage = totalDiskUsage;
74+
75+
if (currentUsage > this.MAX_DISK_USAGE) {
76+
// Sort files by the earliest (oldest) first for deletion
77+
this.deletionQueue.sort((a, b) => statSync(a.filePath).birthtimeMs - statSync(b.filePath).birthtimeMs);
78+
79+
// Reduce the size by half
80+
while (currentUsage > this.MAX_DISK_USAGE / 2 && this.deletionQueue.length > 0) {
81+
const fileToDelete = this.deletionQueue.shift();
82+
if (fileToDelete) {
83+
try {
84+
unlinkSync(fileToDelete.filePath);
85+
currentUsage -= fileToDelete.size;
86+
this.logger.log(`Deleted file: ${fileToDelete.filePath}`);
87+
} catch (error) {
88+
this.logger.log(`Failed to delete file: ${fileToDelete.filePath}. Error: ${error.message}`);
89+
}
90+
}
91+
}
92+
}
93+
}
94+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { writeFileSync, readFileSync, unlink } from 'fs';
3+
import { PNG, PNGWithMetadata } from 'pngjs';
4+
5+
import { StaticService } from './static-service.interface';
6+
import { CommonFileService } from './common-file-service';
7+
8+
@Injectable()
9+
export class HardDiskService extends CommonFileService implements StaticService {
10+
constructor() {
11+
super();
12+
this.logger = new Logger(HardDiskService.name);
13+
this.logger.log('Local file system is used for file storage.');
14+
}
15+
16+
saveFileFromCloud(fileName: string) {
17+
throw new Error(`Download of file ${fileName} is not applicable for local files.`);
18+
}
19+
20+
async saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer): Promise<string> {
21+
try {
22+
new PNG().parse(imageBuffer);
23+
} catch (ex) {
24+
throw new Error('Cannot parse image as PNG file: ' + ex);
25+
}
26+
27+
const { imageName, imagePath } = this.generateNewImage(type);
28+
writeFileSync(imagePath, imageBuffer);
29+
return imageName;
30+
}
31+
32+
async getImage(fileName: string): Promise<PNGWithMetadata> {
33+
if (!fileName) return null;
34+
try {
35+
return PNG.sync.read(readFileSync(this.getImagePath(fileName)));
36+
} catch (ex) {
37+
this.logger.error(`Error from read : Cannot get image: ${fileName}. ${ex}`);
38+
}
39+
}
40+
41+
async deleteImage(imageName: string): Promise<boolean> {
42+
if (!imageName) return false;
43+
return new Promise((resolvePromise) => {
44+
unlink(this.getImagePath(imageName), (err) => {
45+
if (err) {
46+
this.logger.error(err);
47+
resolvePromise(false);
48+
}
49+
resolvePromise(true);
50+
});
51+
});
52+
}
53+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export interface StaticService {
2+
// Below are service specific functions
3+
saveImage(type: 'screenshot' | 'diff' | 'baseline', imageBuffer: Buffer);
4+
getImage(fileName: string);
5+
deleteImage(imageName: string);
6+
7+
doesFileExistLocally(fileName: string);
8+
saveFileFromCloud(fileName: string);
9+
scheduleLocalFileDeletion(fileName: string);
10+
checkLocalDiskUsageAndClean();
11+
12+
getImagePath(imageName: string);
13+
generateNewImage(imageName: string);
14+
}
15+
16+
export const STATIC_SERVICE = 'FILE_SERVICE';

0 commit comments

Comments
 (0)