From 01889d9ec3ae7eba624c08919329cb64c785b3d5 Mon Sep 17 00:00:00 2001 From: Silje Enge Kristensen Date: Fri, 28 Jun 2024 10:32:25 +0200 Subject: [PATCH 01/46] ci: set persist-credentials to false for checkout action --- .github/workflows/build-windows.yaml | 2 ++ .github/workflows/lint-and-test.yaml | 4 ++++ .github/workflows/publish-prerelease-docker.yaml | 2 ++ .github/workflows/publish-prerelease.yaml | 3 +++ 4 files changed, 11 insertions(+) diff --git a/.github/workflows/build-windows.yaml b/.github/workflows/build-windows.yaml index 604cb9e1..eb3af186 100644 --- a/.github/workflows/build-windows.yaml +++ b/.github/workflows/build-windows.yaml @@ -18,6 +18,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Use Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/lint-and-test.yaml b/.github/workflows/lint-and-test.yaml index a43d1860..179e020d 100644 --- a/.github/workflows/lint-and-test.yaml +++ b/.github/workflows/lint-and-test.yaml @@ -18,6 +18,8 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Use Node.js uses: actions/setup-node@v4 @@ -60,6 +62,8 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node_version }} uses: actions/setup-node@v4 diff --git a/.github/workflows/publish-prerelease-docker.yaml b/.github/workflows/publish-prerelease-docker.yaml index 819bf5f2..b76767df 100644 --- a/.github/workflows/publish-prerelease-docker.yaml +++ b/.github/workflows/publish-prerelease-docker.yaml @@ -14,6 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Determine if images should be published to DockerHub id: dockerhub diff --git a/.github/workflows/publish-prerelease.yaml b/.github/workflows/publish-prerelease.yaml index 40e94f6f..a6551da9 100644 --- a/.github/workflows/publish-prerelease.yaml +++ b/.github/workflows/publish-prerelease.yaml @@ -17,6 +17,8 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node_version }} uses: actions/setup-node@v4 with: @@ -46,6 +48,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false - name: Use Node.js uses: actions/setup-node@v4 with: From d5ac4a15468f568a27d2d8704559fd4ded7e36f3 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 2 Jul 2024 12:51:06 +0200 Subject: [PATCH 02/46] fix: improve the info message at '/' and refactor --- .../http-server/packages/generic/package.json | 2 +- .../packages/generic/scripts/prebuild.js | 20 +++++++++++++++ apps/http-server/packages/generic/src/lib.ts | 6 +++++ .../packages/generic/src/server.ts | 25 ++++++++----------- .../generic/src/storage/fileStorage.ts | 4 +-- .../packages/generic/src/storage/storage.ts | 13 ++++++++++ 6 files changed, 53 insertions(+), 17 deletions(-) create mode 100644 apps/http-server/packages/generic/scripts/prebuild.js diff --git a/apps/http-server/packages/generic/package.json b/apps/http-server/packages/generic/package.json index 6c737e09..9346c0a5 100644 --- a/apps/http-server/packages/generic/package.json +++ b/apps/http-server/packages/generic/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "yarn rimraf dist && yarn build:main", + "build": "yarn rimraf dist && node scripts/prebuild.js && yarn build:main", "build:main": "tsc -p tsconfig.json", "__test": "jest" }, diff --git a/apps/http-server/packages/generic/scripts/prebuild.js b/apps/http-server/packages/generic/scripts/prebuild.js new file mode 100644 index 00000000..78e4aaef --- /dev/null +++ b/apps/http-server/packages/generic/scripts/prebuild.js @@ -0,0 +1,20 @@ +const fs = require('fs').promises + +async function main() { + const packageJson = JSON.parse(await fs.readFile('package.json', 'utf8')) + let libStr = await fs.readFile('src/lib.ts', 'utf8') + + libStr = libStr.replace( + /export const PACKAGE_JSON_VERSION =.*/, + `export const PACKAGE_JSON_VERSION = '${packageJson.version}'` + ) + + await fs.writeFile('src/lib.ts', libStr, 'utf8') +} + +main().catch((e) => { + // eslint-disable-next-line no-console + console.error(e) + // eslint-disable-next-line no-process-exit + process.exit(1) +}) diff --git a/apps/http-server/packages/generic/src/lib.ts b/apps/http-server/packages/generic/src/lib.ts index 80eca911..1ed05861 100644 --- a/apps/http-server/packages/generic/src/lib.ts +++ b/apps/http-server/packages/generic/src/lib.ts @@ -23,3 +23,9 @@ export function valueOrFirst(text: string | string[] | undefined): string | unde return text } } + +/** + * The version of the package.json file + * Note: This variable is updated at build-time by scripts/prebuild.js + */ +export const PACKAGE_JSON_VERSION = '1.50.6' diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index 0f0d73bf..c7f5018d 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -8,9 +8,9 @@ import cors from '@koa/cors' import bodyParser from 'koa-bodyparser' import { HTTPServerConfig, LoggerInstance, stringifyError, first } from '@sofie-package-manager/api' -import { BadResponse, Storage } from './storage/storage' +import { BadResponse, Storage, isBadResponse } from './storage/storage' import { FileStorage } from './storage/fileStorage' -import { CTX, valueOrFirst } from './lib' +import { CTX, PACKAGE_JSON_VERSION, valueOrFirst } from './lib' import { parseFormData } from 'pechkin' const fsReadFile = promisify(fs.readFile) @@ -24,6 +24,8 @@ export class PackageProxyServer { private storage: Storage private logger: LoggerInstance + private startupTime = Date.now() + constructor(logger: LoggerInstance, private config: HTTPServerConfig) { this.logger = logger.category('PackageProxyServer') this.app.on('error', (err) => { @@ -123,17 +125,12 @@ export class PackageProxyServer { // Convenient pages: this.router.get('/', async (ctx) => { - let packageJson = { version: '0.0.0' } - try { - packageJson = JSON.parse( - await fsReadFile('../package.json', { - encoding: 'utf8', - }) - ) - } catch (err) { - // ignore + ctx.body = { + name: 'Package proxy server', + version: PACKAGE_JSON_VERSION, + uptime: Date.now() - this.startupTime, + info: this.storage.getInfo(), } - ctx.body = { name: 'Package proxy server', version: packageJson.version, info: this.storage.getInfo() } }) this.router.get('/uploadForm/:path+', async (ctx) => { // ctx.response.status = result.code @@ -165,10 +162,10 @@ export class PackageProxyServer { } }) } - private async handleStorage(ctx: CTX, storageFcn: () => Promise) { + private async handleStorage(ctx: CTX, storageFcn: () => Promise) { try { const result = await storageFcn() - if (result !== true) { + if (isBadResponse(result)) { ctx.response.status = result.code ctx.body = result.reason } diff --git a/apps/http-server/packages/generic/src/storage/fileStorage.ts b/apps/http-server/packages/generic/src/storage/fileStorage.ts index db8f8996..3416cd2a 100644 --- a/apps/http-server/packages/generic/src/storage/fileStorage.ts +++ b/apps/http-server/packages/generic/src/storage/fileStorage.ts @@ -5,7 +5,7 @@ import mime from 'mime-types' import prettyBytes from 'pretty-bytes' import { asyncPipe, CTX, CTXPost } from '../lib' import { HTTPServerConfig, LoggerInstance } from '@sofie-package-manager/api' -import { BadResponse, Storage } from './storage' +import { BadResponse, PackageInfo, Storage } from './storage' import { Readable } from 'stream' // Note: Explicit types here, due to that for some strange reason, promisify wont pass through the correct typings. @@ -39,7 +39,7 @@ export class FileStorage extends Storage { } getInfo(): string { - return this._basePath + return `basePath: "${this._basePath}", cleanFileAge: ${this.config.httpServer.cleanFileAge}` } async init(): Promise { diff --git a/apps/http-server/packages/generic/src/storage/storage.ts b/apps/http-server/packages/generic/src/storage/storage.ts index bbaee154..609b0302 100644 --- a/apps/http-server/packages/generic/src/storage/storage.ts +++ b/apps/http-server/packages/generic/src/storage/storage.ts @@ -18,3 +18,16 @@ export interface BadResponse { code: number reason: string } +export function isBadResponse(v: unknown): v is BadResponse { + return ( + typeof v === 'object' && + typeof (v as BadResponse).code === 'number' && + typeof (v as BadResponse).reason === 'string' + ) +} + +export type PackageInfo = { + path: string + size: string + modified: string +} From 1acfb0c21d0b4a0cbf76a959cff83ea2a8cdd102 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 2 Jul 2024 12:51:43 +0200 Subject: [PATCH 03/46] fix: http-server: add '/list' endpoint, that displays a HTML page with a list of the packages --- .../packages/generic/src/server.ts | 51 ++++++++++++++++++- .../generic/src/storage/fileStorage.ts | 12 ++--- .../packages/generic/src/storage/storage.ts | 2 +- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index c7f5018d..653b2113 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -8,7 +8,7 @@ import cors from '@koa/cors' import bodyParser from 'koa-bodyparser' import { HTTPServerConfig, LoggerInstance, stringifyError, first } from '@sofie-package-manager/api' -import { BadResponse, Storage, isBadResponse } from './storage/storage' +import { BadResponse, PackageInfo, Storage, isBadResponse } from './storage/storage' import { FileStorage } from './storage/fileStorage' import { CTX, PACKAGE_JSON_VERSION, valueOrFirst } from './lib' import { parseFormData } from 'pechkin' @@ -90,6 +90,9 @@ export class PackageProxyServer { this.router.get('/packages', async (ctx) => { await this.handleStorage(ctx, async () => this.storage.listPackages(ctx)) }) + this.router.get('/list', async (ctx) => { + await this.handleStorageHTMLList(ctx, async () => this.storage.listPackages(ctx)) + }) this.router.get('/package/:path+', async (ctx) => { await this.handleStorage(ctx, async () => this.storage.getPackage(ctx.params.path, ctx)) }) @@ -175,4 +178,50 @@ export class PackageProxyServer { ctx.body = 'Internal server error' } } + private async handleStorageHTMLList( + ctx: CTX, + storageFcn: () => Promise<{ packages: PackageInfo[] } | BadResponse> + ) { + try { + const result = await storageFcn() + if (isBadResponse(result)) { + ctx.response.status = result.code + ctx.body = result.reason + } else { + const packages = result.packages + + ctx.set('Content-Type', 'text/html') + ctx.body = ` + + + + + +

Packages

+ +${packages + .map( + (pkg) => + ` + + + + ` + ) + .join('')} +
${pkg.path}${pkg.size}${pkg.modified}
+ +` + } + } catch (err) { + this.logger.error(`Error in handleStorage: ${stringifyError(err)} `) + ctx.response.status = 500 + ctx.body = 'Internal server error' + } + } } diff --git a/apps/http-server/packages/generic/src/storage/fileStorage.ts b/apps/http-server/packages/generic/src/storage/fileStorage.ts index 3416cd2a..83620e68 100644 --- a/apps/http-server/packages/generic/src/storage/fileStorage.ts +++ b/apps/http-server/packages/generic/src/storage/fileStorage.ts @@ -46,12 +46,7 @@ export class FileStorage extends Storage { await fsMkDir(this._basePath, { recursive: true }) } - async listPackages(ctx: CTX): Promise { - type PackageInfo = { - path: string - size: string - modified: string - } + async listPackages(ctx: CTX): Promise<{ packages: PackageInfo[] } | BadResponse> { const packages: PackageInfo[] = [] const getAllFiles = async (basePath: string, dirPath: string) => { @@ -84,9 +79,8 @@ export class FileStorage extends Storage { return 0 }) - ctx.body = { packages: packages } - - return true + ctx.body = { packages } + return { packages } } private async getFileInfo(paramPath: string): Promise< | { diff --git a/apps/http-server/packages/generic/src/storage/storage.ts b/apps/http-server/packages/generic/src/storage/storage.ts index 609b0302..1ae9632e 100644 --- a/apps/http-server/packages/generic/src/storage/storage.ts +++ b/apps/http-server/packages/generic/src/storage/storage.ts @@ -4,7 +4,7 @@ import { CTX, CTXPost } from '../lib' export abstract class Storage { abstract init(): Promise abstract getInfo(): string - abstract listPackages(ctx: CTX): Promise + abstract listPackages(ctx: CTX): Promise<{ packages: PackageInfo[] } | BadResponse> abstract headPackage(path: string, ctx: CTX): Promise abstract getPackage(path: string, ctx: CTX): Promise abstract postPackage( From 88326fd678a71b2936b94f5f8e0e26b88e886f38 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 3 Jul 2024 11:42:17 +0200 Subject: [PATCH 04/46] chore: refactor http-server storage internal API so that the methods explicitly return their data instead of modifying ctx directly --- .../packages/generic/src/server.ts | 29 ++++--- .../generic/src/storage/fileStorage.ts | 81 +++++++++++-------- .../packages/generic/src/storage/storage.ts | 30 +++++-- 3 files changed, 91 insertions(+), 49 deletions(-) diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index 653b2113..f81ed7ff 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -8,7 +8,7 @@ import cors from '@koa/cors' import bodyParser from 'koa-bodyparser' import { HTTPServerConfig, LoggerInstance, stringifyError, first } from '@sofie-package-manager/api' -import { BadResponse, PackageInfo, Storage, isBadResponse } from './storage/storage' +import { BadResponse, PackageInfo, Sidecar, Storage, isBadResponse } from './storage/storage' import { FileStorage } from './storage/fileStorage' import { CTX, PACKAGE_JSON_VERSION, valueOrFirst } from './lib' import { parseFormData } from 'pechkin' @@ -88,16 +88,16 @@ export class PackageProxyServer { }) this.router.get('/packages', async (ctx) => { - await this.handleStorage(ctx, async () => this.storage.listPackages(ctx)) + await this.handleStorage(ctx, async () => this.storage.listPackages()) }) this.router.get('/list', async (ctx) => { - await this.handleStorageHTMLList(ctx, async () => this.storage.listPackages(ctx)) + await this.handleStorageHTMLList(ctx, async () => this.storage.listPackages()) }) this.router.get('/package/:path+', async (ctx) => { - await this.handleStorage(ctx, async () => this.storage.getPackage(ctx.params.path, ctx)) + await this.handleStorage(ctx, async () => this.storage.getPackage(ctx.params.path)) }) this.router.head('/package/:path+', async (ctx) => { - await this.handleStorage(ctx, async () => this.storage.headPackage(ctx.params.path, ctx)) + await this.handleStorage(ctx, async () => this.storage.headPackage(ctx.params.path)) }) this.router.post('/package/:path+', async (ctx) => { this.logger.debug(`POST ${ctx.request.URL}`) @@ -123,7 +123,7 @@ export class PackageProxyServer { }) this.router.delete('/package/:path+', async (ctx) => { this.logger.debug(`DELETE ${ctx.request.URL}`) - await this.handleStorage(ctx, async () => this.storage.deletePackage(ctx.params.path, ctx)) + await this.handleStorage(ctx, async () => this.storage.deletePackage(ctx.params.path)) }) // Convenient pages: @@ -165,12 +165,23 @@ export class PackageProxyServer { } }) } - private async handleStorage(ctx: CTX, storageFcn: () => Promise) { + private async handleStorage(ctx: CTX, storageFcn: () => Promise<{ sidecar: Sidecar; body?: any } | BadResponse>) { try { const result = await storageFcn() if (isBadResponse(result)) { ctx.response.status = result.code ctx.body = result.reason + } else { + ctx.response.status = result.sidecar.statusCode + if (result.sidecar.type !== undefined) ctx.type = result.sidecar.type + if (result.sidecar.length !== undefined) ctx.length = result.sidecar.length + if (result.sidecar.lastModified !== undefined) ctx.lastModified = result.sidecar.lastModified + + for (const [key, value] of Object.entries(result.sidecar.headers)) { + ctx.set(key, value) + } + + if (result.body) ctx.body = result.body } } catch (err) { this.logger.error(`Error in handleStorage: ${stringifyError(err)} `) @@ -180,7 +191,7 @@ export class PackageProxyServer { } private async handleStorageHTMLList( ctx: CTX, - storageFcn: () => Promise<{ packages: PackageInfo[] } | BadResponse> + storageFcn: () => Promise<{ body: { packages: PackageInfo[] } } | BadResponse> ) { try { const result = await storageFcn() @@ -188,7 +199,7 @@ export class PackageProxyServer { ctx.response.status = result.code ctx.body = result.reason } else { - const packages = result.packages + const packages = result.body.packages ctx.set('Content-Type', 'text/html') ctx.body = ` diff --git a/apps/http-server/packages/generic/src/storage/fileStorage.ts b/apps/http-server/packages/generic/src/storage/fileStorage.ts index 83620e68..fe1309be 100644 --- a/apps/http-server/packages/generic/src/storage/fileStorage.ts +++ b/apps/http-server/packages/generic/src/storage/fileStorage.ts @@ -3,9 +3,9 @@ import path from 'path' import { promisify } from 'util' import mime from 'mime-types' import prettyBytes from 'pretty-bytes' -import { asyncPipe, CTX, CTXPost } from '../lib' +import { asyncPipe, CTXPost } from '../lib' import { HTTPServerConfig, LoggerInstance } from '@sofie-package-manager/api' -import { BadResponse, PackageInfo, Storage } from './storage' +import { BadResponse, PackageInfo, Sidecar, Storage } from './storage' import { Readable } from 'stream' // Note: Explicit types here, due to that for some strange reason, promisify wont pass through the correct typings. @@ -46,7 +46,7 @@ export class FileStorage extends Storage { await fsMkDir(this._basePath, { recursive: true }) } - async listPackages(ctx: CTX): Promise<{ packages: PackageInfo[] } | BadResponse> { + async listPackages(): Promise<{ sidecar: Sidecar; body: { packages: PackageInfo[] } } | BadResponse> { const packages: PackageInfo[] = [] const getAllFiles = async (basePath: string, dirPath: string) => { @@ -79,8 +79,12 @@ export class FileStorage extends Storage { return 0 }) - ctx.body = { packages } - return { packages } + const sidecar: Sidecar = { + statusCode: 200, + headers: {}, + } + + return { sidecar, body: { packages } } } private async getFileInfo(paramPath: string): Promise< | { @@ -112,40 +116,42 @@ export class FileStorage extends Storage { lastModified: stat.mtime, } } - async headPackage(paramPath: string, ctx: CTX): Promise { + async headPackage(paramPath: string): Promise<{ sidecar: Sidecar } | BadResponse> { const fileInfo = await this.getFileInfo(paramPath) if (!fileInfo.found) { return { code: 404, reason: 'Package not found' } } - this.setHeaders(fileInfo, ctx) - - ctx.response.status = 204 - - ctx.body = undefined + const sidecar: Sidecar = { + statusCode: 204, + headers: {}, + } + this.updateSideCarWithFileInfo(sidecar, fileInfo) - return true + return { sidecar } } - async getPackage(paramPath: string, ctx: CTX): Promise { + async getPackage(paramPath: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> { const fileInfo = await this.getFileInfo(paramPath) if (!fileInfo.found) { return { code: 404, reason: 'Package not found' } } - - this.setHeaders(fileInfo, ctx) + const sidecar: Sidecar = { + statusCode: 200, + headers: {}, + } + this.updateSideCarWithFileInfo(sidecar, fileInfo) const readStream = fs.createReadStream(fileInfo.fullPath) - ctx.body = readStream - return true + return { sidecar, body: readStream } } async postPackage( paramPath: string, ctx: CTXPost, fileStreamOrText: string | Readable | undefined - ): Promise { + ): Promise<{ sidecar: Sidecar; body: any } | BadResponse> { const fullPath = path.join(this._basePath, paramPath) await fsMkDir(path.dirname(fullPath), { recursive: true }) @@ -158,25 +164,28 @@ export class FileStorage extends Storage { plainText = fileStreamOrText } + const sidecar: Sidecar = { + statusCode: 200, + headers: {}, + } + if (plainText) { // store plain text into file await fsWriteFile(fullPath, plainText) - ctx.body = { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } - ctx.response.status = 201 - return true + sidecar.statusCode = 201 + return { sidecar, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } } else if (fileStreamOrText && typeof fileStreamOrText !== 'string') { const fileStream = fileStreamOrText await asyncPipe(fileStream, fs.createWriteStream(fullPath)) - ctx.body = { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } - ctx.response.status = 201 - return true + sidecar.statusCode = 201 + return { sidecar, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } } else { return { code: 400, reason: 'No files provided' } } } - async deletePackage(paramPath: string, ctx: CTXPost): Promise { + async deletePackage(paramPath: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> { const fullPath = path.join(this._basePath, paramPath) if (!(await this.exists(fullPath))) { @@ -185,8 +194,12 @@ export class FileStorage extends Storage { await fsUnlink(fullPath) - ctx.body = { message: `Deleted "${paramPath}"` } - return true + const sidecar: Sidecar = { + statusCode: 200, + headers: {}, + } + + return { sidecar, body: { message: `Deleted "${paramPath}"` } } } private async exists(fullPath: string) { @@ -274,16 +287,16 @@ export class FileStorage extends Storage { * @param {CTX} ctx * @memberof FileStorage */ - private setHeaders(info: FileInfo, ctx: CTX) { - ctx.type = info.mimeType - ctx.length = info.length - ctx.lastModified = info.lastModified + private updateSideCarWithFileInfo(sidecar: Sidecar, info: FileInfo): void { + sidecar.type = info.mimeType + sidecar.length = info.length + sidecar.lastModified = info.lastModified // Check the config. 0 or -1 means it's disabled: if (this.config.httpServer.cleanFileAge >= 0) { - ctx.set( - 'Expires', - FileStorage.calculateExpiresTimestamp(info.lastModified, this.config.httpServer.cleanFileAge) + sidecar.headers['Expires'] = FileStorage.calculateExpiresTimestamp( + info.lastModified, + this.config.httpServer.cleanFileAge ) } } diff --git a/apps/http-server/packages/generic/src/storage/storage.ts b/apps/http-server/packages/generic/src/storage/storage.ts index 1ae9632e..9120af27 100644 --- a/apps/http-server/packages/generic/src/storage/storage.ts +++ b/apps/http-server/packages/generic/src/storage/storage.ts @@ -1,18 +1,26 @@ import { Readable } from 'stream' -import { CTX, CTXPost } from '../lib' +import { CTXPost } from '../lib' export abstract class Storage { abstract init(): Promise abstract getInfo(): string - abstract listPackages(ctx: CTX): Promise<{ packages: PackageInfo[] } | BadResponse> - abstract headPackage(path: string, ctx: CTX): Promise - abstract getPackage(path: string, ctx: CTX): Promise + abstract listPackages(): Promise< + | { + sidecar: Sidecar + body: { + packages: PackageInfo[] + } + } + | BadResponse + > + abstract headPackage(path: string): Promise<{ sidecar: Sidecar } | BadResponse> + abstract getPackage(path: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> abstract postPackage( path: string, ctx: CTXPost, fileStreamOrText: string | Readable | undefined - ): Promise - abstract deletePackage(path: string, ctx: CTXPost): Promise + ): Promise<{ sidecar: Sidecar; body: any } | BadResponse> + abstract deletePackage(path: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> } export interface BadResponse { code: number @@ -31,3 +39,13 @@ export type PackageInfo = { size: string modified: string } + +export interface Sidecar { + statusCode: number + type?: string + length?: number + lastModified?: Date + headers: { + [key: string]: string + } +} From c266c0591ce405b4682394ede87c6562305bb0b6 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 3 Jul 2024 11:43:55 +0200 Subject: [PATCH 05/46] chore: move PACKAGE_JSON_VERSION to separate file --- apps/http-server/packages/generic/scripts/prebuild.js | 4 ++-- apps/http-server/packages/generic/src/lib.ts | 6 ------ apps/http-server/packages/generic/src/packageVersion.ts | 5 +++++ apps/http-server/packages/generic/src/server.ts | 3 ++- 4 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 apps/http-server/packages/generic/src/packageVersion.ts diff --git a/apps/http-server/packages/generic/scripts/prebuild.js b/apps/http-server/packages/generic/scripts/prebuild.js index 78e4aaef..25acca85 100644 --- a/apps/http-server/packages/generic/scripts/prebuild.js +++ b/apps/http-server/packages/generic/scripts/prebuild.js @@ -2,14 +2,14 @@ const fs = require('fs').promises async function main() { const packageJson = JSON.parse(await fs.readFile('package.json', 'utf8')) - let libStr = await fs.readFile('src/lib.ts', 'utf8') + let libStr = await fs.readFile('src/packageVersion.ts', 'utf8') libStr = libStr.replace( /export const PACKAGE_JSON_VERSION =.*/, `export const PACKAGE_JSON_VERSION = '${packageJson.version}'` ) - await fs.writeFile('src/lib.ts', libStr, 'utf8') + await fs.writeFile('src/packageVersion.ts', libStr, 'utf8') } main().catch((e) => { diff --git a/apps/http-server/packages/generic/src/lib.ts b/apps/http-server/packages/generic/src/lib.ts index 1ed05861..80eca911 100644 --- a/apps/http-server/packages/generic/src/lib.ts +++ b/apps/http-server/packages/generic/src/lib.ts @@ -23,9 +23,3 @@ export function valueOrFirst(text: string | string[] | undefined): string | unde return text } } - -/** - * The version of the package.json file - * Note: This variable is updated at build-time by scripts/prebuild.js - */ -export const PACKAGE_JSON_VERSION = '1.50.6' diff --git a/apps/http-server/packages/generic/src/packageVersion.ts b/apps/http-server/packages/generic/src/packageVersion.ts new file mode 100644 index 00000000..41fd52a0 --- /dev/null +++ b/apps/http-server/packages/generic/src/packageVersion.ts @@ -0,0 +1,5 @@ +/** + * The version of the package.json file + * Note: This variable is updated at build-time by scripts/prebuild.js + */ +export const PACKAGE_JSON_VERSION = '1.50.6' diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index f81ed7ff..ce0ccf10 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -10,8 +10,9 @@ import bodyParser from 'koa-bodyparser' import { HTTPServerConfig, LoggerInstance, stringifyError, first } from '@sofie-package-manager/api' import { BadResponse, PackageInfo, Sidecar, Storage, isBadResponse } from './storage/storage' import { FileStorage } from './storage/fileStorage' -import { CTX, PACKAGE_JSON_VERSION, valueOrFirst } from './lib' +import { CTX, valueOrFirst } from './lib' import { parseFormData } from 'pechkin' +import { PACKAGE_JSON_VERSION } from './packageVersion' const fsReadFile = promisify(fs.readFile) From 79e67b4d1c189a643b6ac8ed8b2c190db2cecf84 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 3 Jul 2024 11:45:44 +0200 Subject: [PATCH 06/46] fix: the path need to be relative to account for when the http-server is proxied --- apps/http-server/packages/generic/src/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index ce0ccf10..69a83875 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -220,7 +220,7 @@ ${packages .map( (pkg) => ` - ${pkg.path} + ${pkg.path} ${pkg.size} ${pkg.modified} ` From 92e496e579b9a32770b363cd4efe0086121b84bb Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 3 Jul 2024 13:32:10 +0200 Subject: [PATCH 07/46] chore: remove the packageVersion.ts file from git, it is auto-generated at build time --- apps/http-server/packages/generic/.gitignore | 1 + .../http-server/packages/generic/scripts/prebuild.js | 12 ++++++------ .../packages/generic/src/packageVersion.ts | 5 ----- apps/http-server/packages/generic/src/server.ts | 1 + 4 files changed, 8 insertions(+), 11 deletions(-) create mode 100644 apps/http-server/packages/generic/.gitignore delete mode 100644 apps/http-server/packages/generic/src/packageVersion.ts diff --git a/apps/http-server/packages/generic/.gitignore b/apps/http-server/packages/generic/.gitignore new file mode 100644 index 00000000..ec96dd6b --- /dev/null +++ b/apps/http-server/packages/generic/.gitignore @@ -0,0 +1 @@ +src/packageVersion.ts diff --git a/apps/http-server/packages/generic/scripts/prebuild.js b/apps/http-server/packages/generic/scripts/prebuild.js index 25acca85..8616d220 100644 --- a/apps/http-server/packages/generic/scripts/prebuild.js +++ b/apps/http-server/packages/generic/scripts/prebuild.js @@ -2,12 +2,12 @@ const fs = require('fs').promises async function main() { const packageJson = JSON.parse(await fs.readFile('package.json', 'utf8')) - let libStr = await fs.readFile('src/packageVersion.ts', 'utf8') - - libStr = libStr.replace( - /export const PACKAGE_JSON_VERSION =.*/, - `export const PACKAGE_JSON_VERSION = '${packageJson.version}'` - ) + const libStr = `// ****** This file is generated at build-time by scripts/prebuild.js ****** +/** + * The version of the package.json file + */ +export const PACKAGE_JSON_VERSION = '${packageJson.version}' +` await fs.writeFile('src/packageVersion.ts', libStr, 'utf8') } diff --git a/apps/http-server/packages/generic/src/packageVersion.ts b/apps/http-server/packages/generic/src/packageVersion.ts deleted file mode 100644 index 41fd52a0..00000000 --- a/apps/http-server/packages/generic/src/packageVersion.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * The version of the package.json file - * Note: This variable is updated at build-time by scripts/prebuild.js - */ -export const PACKAGE_JSON_VERSION = '1.50.6' diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index 69a83875..64a2b50a 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -12,6 +12,7 @@ import { BadResponse, PackageInfo, Sidecar, Storage, isBadResponse } from './sto import { FileStorage } from './storage/fileStorage' import { CTX, valueOrFirst } from './lib' import { parseFormData } from 'pechkin' +// eslint-disable-next-line node/no-unpublished-import import { PACKAGE_JSON_VERSION } from './packageVersion' const fsReadFile = promisify(fs.readFile) From 73259d97c2fa26dd0580a4dca332875f8cbd9759 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 3 Jul 2024 13:36:43 +0200 Subject: [PATCH 08/46] chore: refactor: rename Sidecar -> ResponseMeta --- .../packages/generic/src/server.ts | 20 +++--- .../generic/src/storage/fileStorage.ts | 61 +++++++++---------- .../packages/generic/src/storage/storage.ts | 14 ++--- 3 files changed, 47 insertions(+), 48 deletions(-) diff --git a/apps/http-server/packages/generic/src/server.ts b/apps/http-server/packages/generic/src/server.ts index 64a2b50a..8a94dcaa 100644 --- a/apps/http-server/packages/generic/src/server.ts +++ b/apps/http-server/packages/generic/src/server.ts @@ -8,7 +8,7 @@ import cors from '@koa/cors' import bodyParser from 'koa-bodyparser' import { HTTPServerConfig, LoggerInstance, stringifyError, first } from '@sofie-package-manager/api' -import { BadResponse, PackageInfo, Sidecar, Storage, isBadResponse } from './storage/storage' +import { BadResponse, PackageInfo, ResponseMeta, Storage, isBadResponse } from './storage/storage' import { FileStorage } from './storage/fileStorage' import { CTX, valueOrFirst } from './lib' import { parseFormData } from 'pechkin' @@ -167,20 +167,22 @@ export class PackageProxyServer { } }) } - private async handleStorage(ctx: CTX, storageFcn: () => Promise<{ sidecar: Sidecar; body?: any } | BadResponse>) { + private async handleStorage(ctx: CTX, storageFcn: () => Promise<{ meta: ResponseMeta; body?: any } | BadResponse>) { try { const result = await storageFcn() if (isBadResponse(result)) { ctx.response.status = result.code ctx.body = result.reason } else { - ctx.response.status = result.sidecar.statusCode - if (result.sidecar.type !== undefined) ctx.type = result.sidecar.type - if (result.sidecar.length !== undefined) ctx.length = result.sidecar.length - if (result.sidecar.lastModified !== undefined) ctx.lastModified = result.sidecar.lastModified - - for (const [key, value] of Object.entries(result.sidecar.headers)) { - ctx.set(key, value) + ctx.response.status = result.meta.statusCode + if (result.meta.type !== undefined) ctx.type = result.meta.type + if (result.meta.length !== undefined) ctx.length = result.meta.length + if (result.meta.lastModified !== undefined) ctx.lastModified = result.meta.lastModified + + if (result.meta.headers) { + for (const [key, value] of Object.entries(result.meta.headers)) { + ctx.set(key, value) + } } if (result.body) ctx.body = result.body diff --git a/apps/http-server/packages/generic/src/storage/fileStorage.ts b/apps/http-server/packages/generic/src/storage/fileStorage.ts index fe1309be..d3b46e90 100644 --- a/apps/http-server/packages/generic/src/storage/fileStorage.ts +++ b/apps/http-server/packages/generic/src/storage/fileStorage.ts @@ -5,7 +5,7 @@ import mime from 'mime-types' import prettyBytes from 'pretty-bytes' import { asyncPipe, CTXPost } from '../lib' import { HTTPServerConfig, LoggerInstance } from '@sofie-package-manager/api' -import { BadResponse, PackageInfo, Sidecar, Storage } from './storage' +import { BadResponse, PackageInfo, ResponseMeta, Storage } from './storage' import { Readable } from 'stream' // Note: Explicit types here, due to that for some strange reason, promisify wont pass through the correct typings. @@ -46,7 +46,7 @@ export class FileStorage extends Storage { await fsMkDir(this._basePath, { recursive: true }) } - async listPackages(): Promise<{ sidecar: Sidecar; body: { packages: PackageInfo[] } } | BadResponse> { + async listPackages(): Promise<{ meta: ResponseMeta; body: { packages: PackageInfo[] } } | BadResponse> { const packages: PackageInfo[] = [] const getAllFiles = async (basePath: string, dirPath: string) => { @@ -79,12 +79,11 @@ export class FileStorage extends Storage { return 0 }) - const sidecar: Sidecar = { + const meta: ResponseMeta = { statusCode: 200, - headers: {}, } - return { sidecar, body: { packages } } + return { meta, body: { packages } } } private async getFileInfo(paramPath: string): Promise< | { @@ -116,42 +115,40 @@ export class FileStorage extends Storage { lastModified: stat.mtime, } } - async headPackage(paramPath: string): Promise<{ sidecar: Sidecar } | BadResponse> { + async headPackage(paramPath: string): Promise<{ meta: ResponseMeta } | BadResponse> { const fileInfo = await this.getFileInfo(paramPath) if (!fileInfo.found) { return { code: 404, reason: 'Package not found' } } - const sidecar: Sidecar = { + const meta: ResponseMeta = { statusCode: 204, - headers: {}, } - this.updateSideCarWithFileInfo(sidecar, fileInfo) + this.updateMetaWithFileInfo(meta, fileInfo) - return { sidecar } + return { meta } } - async getPackage(paramPath: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> { + async getPackage(paramPath: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> { const fileInfo = await this.getFileInfo(paramPath) if (!fileInfo.found) { return { code: 404, reason: 'Package not found' } } - const sidecar: Sidecar = { + const meta: ResponseMeta = { statusCode: 200, - headers: {}, } - this.updateSideCarWithFileInfo(sidecar, fileInfo) + this.updateMetaWithFileInfo(meta, fileInfo) const readStream = fs.createReadStream(fileInfo.fullPath) - return { sidecar, body: readStream } + return { meta, body: readStream } } async postPackage( paramPath: string, ctx: CTXPost, fileStreamOrText: string | Readable | undefined - ): Promise<{ sidecar: Sidecar; body: any } | BadResponse> { + ): Promise<{ meta: ResponseMeta; body: any } | BadResponse> { const fullPath = path.join(this._basePath, paramPath) await fsMkDir(path.dirname(fullPath), { recursive: true }) @@ -164,28 +161,27 @@ export class FileStorage extends Storage { plainText = fileStreamOrText } - const sidecar: Sidecar = { + const meta: ResponseMeta = { statusCode: 200, - headers: {}, } if (plainText) { // store plain text into file await fsWriteFile(fullPath, plainText) - sidecar.statusCode = 201 - return { sidecar, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } + meta.statusCode = 201 + return { meta, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } } else if (fileStreamOrText && typeof fileStreamOrText !== 'string') { const fileStream = fileStreamOrText await asyncPipe(fileStream, fs.createWriteStream(fullPath)) - sidecar.statusCode = 201 - return { sidecar, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } + meta.statusCode = 201 + return { meta, body: { code: 201, message: `${exists ? 'Updated' : 'Inserted'} "${paramPath}"` } } } else { return { code: 400, reason: 'No files provided' } } } - async deletePackage(paramPath: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> { + async deletePackage(paramPath: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> { const fullPath = path.join(this._basePath, paramPath) if (!(await this.exists(fullPath))) { @@ -194,12 +190,11 @@ export class FileStorage extends Storage { await fsUnlink(fullPath) - const sidecar: Sidecar = { + const meta: ResponseMeta = { statusCode: 200, - headers: {}, } - return { sidecar, body: { message: `Deleted "${paramPath}"` } } + return { meta, body: { message: `Deleted "${paramPath}"` } } } private async exists(fullPath: string) { @@ -287,21 +282,23 @@ export class FileStorage extends Storage { * @param {CTX} ctx * @memberof FileStorage */ - private updateSideCarWithFileInfo(sidecar: Sidecar, info: FileInfo): void { - sidecar.type = info.mimeType - sidecar.length = info.length - sidecar.lastModified = info.lastModified + private updateMetaWithFileInfo(meta: ResponseMeta, info: FileInfo): void { + meta.type = info.mimeType + meta.length = info.length + meta.lastModified = info.lastModified + + if (!meta.headers) meta.headers = {} // Check the config. 0 or -1 means it's disabled: if (this.config.httpServer.cleanFileAge >= 0) { - sidecar.headers['Expires'] = FileStorage.calculateExpiresTimestamp( + meta.headers['Expires'] = FileStorage.calculateExpiresTimestamp( info.lastModified, this.config.httpServer.cleanFileAge ) } } /** - * Calculate the expiration timestamp, given a starting Date point and timespan duration + * Calculate the expiration timestamp, given a starting Date point and time-span duration * * @private * @static diff --git a/apps/http-server/packages/generic/src/storage/storage.ts b/apps/http-server/packages/generic/src/storage/storage.ts index 9120af27..b63e8626 100644 --- a/apps/http-server/packages/generic/src/storage/storage.ts +++ b/apps/http-server/packages/generic/src/storage/storage.ts @@ -6,21 +6,21 @@ export abstract class Storage { abstract getInfo(): string abstract listPackages(): Promise< | { - sidecar: Sidecar + meta: ResponseMeta body: { packages: PackageInfo[] } } | BadResponse > - abstract headPackage(path: string): Promise<{ sidecar: Sidecar } | BadResponse> - abstract getPackage(path: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> + abstract headPackage(path: string): Promise<{ meta: ResponseMeta } | BadResponse> + abstract getPackage(path: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> abstract postPackage( path: string, ctx: CTXPost, fileStreamOrText: string | Readable | undefined - ): Promise<{ sidecar: Sidecar; body: any } | BadResponse> - abstract deletePackage(path: string): Promise<{ sidecar: Sidecar; body: any } | BadResponse> + ): Promise<{ meta: ResponseMeta; body: any } | BadResponse> + abstract deletePackage(path: string): Promise<{ meta: ResponseMeta; body: any } | BadResponse> } export interface BadResponse { code: number @@ -40,12 +40,12 @@ export type PackageInfo = { modified: string } -export interface Sidecar { +export interface ResponseMeta { statusCode: number type?: string length?: number lastModified?: Date - headers: { + headers?: { [key: string]: string } } From 99424a6f5b28f0a0fc28d76ab88abd2260a2b402 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 4 Jul 2024 08:34:02 +0200 Subject: [PATCH 09/46] v1.50.7 --- CHANGELOG.md | 13 +++++++++++++ apps/http-server/app/CHANGELOG.md | 8 ++++++++ apps/http-server/app/package.json | 4 ++-- apps/http-server/packages/generic/CHANGELOG.md | 13 +++++++++++++ apps/http-server/packages/generic/package.json | 2 +- apps/single-app/app/CHANGELOG.md | 8 ++++++++ apps/single-app/app/package.json | 4 ++-- lerna.json | 2 +- tests/internal-tests/CHANGELOG.md | 8 ++++++++ tests/internal-tests/package.json | 4 ++-- yarn.lock | 8 ++++---- 11 files changed, 62 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b29d47..696d0c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.50.7](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.6...v1.50.7) (2024-07-04) + + +### Bug Fixes + +* http-server: add '/list' endpoint, that displays a HTML page with a list of the packages ([1acfb0c](https://github.com/nrkno/tv-automation-package-manager/commit/1acfb0c21d0b4a0cbf76a959cff83ea2a8cdd102)) +* improve the info message at '/' and refactor ([d5ac4a1](https://github.com/nrkno/tv-automation-package-manager/commit/d5ac4a15468f568a27d2d8704559fd4ded7e36f3)) +* the path need to be relative to account for when the http-server is proxied ([79e67b4](https://github.com/nrkno/tv-automation-package-manager/commit/79e67b4d1c189a643b6ac8ed8b2c190db2cecf84)) + + + + + # [1.50.5](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.4...v1.50.5) (2024-04-09) ### Bug Fixes diff --git a/apps/http-server/app/CHANGELOG.md b/apps/http-server/app/CHANGELOG.md index 600d2030..f4d02c46 100644 --- a/apps/http-server/app/CHANGELOG.md +++ b/apps/http-server/app/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.50.7](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.6...v1.50.7) (2024-07-04) + +**Note:** Version bump only for package @http-server/app + + + + + # [1.50.5](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.4...v1.50.5) (2024-04-09) **Note:** Version bump only for package @http-server/app diff --git a/apps/http-server/app/package.json b/apps/http-server/app/package.json index 643289b5..896a49e4 100644 --- a/apps/http-server/app/package.json +++ b/apps/http-server/app/package.json @@ -1,6 +1,6 @@ { "name": "@http-server/app", - "version": "1.50.6", + "version": "1.50.7", "description": "Upload to and serve proxies of packages", "private": true, "scripts": { @@ -11,7 +11,7 @@ "start": "node dist/index.js" }, "dependencies": { - "@http-server/generic": "1.50.6", + "@http-server/generic": "1.50.7", "rimraf": "^5.0.5" }, "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", diff --git a/apps/http-server/packages/generic/CHANGELOG.md b/apps/http-server/packages/generic/CHANGELOG.md index f12da781..0435c01f 100644 --- a/apps/http-server/packages/generic/CHANGELOG.md +++ b/apps/http-server/packages/generic/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.50.7](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.6...v1.50.7) (2024-07-04) + + +### Bug Fixes + +* http-server: add '/list' endpoint, that displays a HTML page with a list of the packages ([1acfb0c](https://github.com/nrkno/tv-automation-package-manager/commit/1acfb0c21d0b4a0cbf76a959cff83ea2a8cdd102)) +* improve the info message at '/' and refactor ([d5ac4a1](https://github.com/nrkno/tv-automation-package-manager/commit/d5ac4a15468f568a27d2d8704559fd4ded7e36f3)) +* the path need to be relative to account for when the http-server is proxied ([79e67b4](https://github.com/nrkno/tv-automation-package-manager/commit/79e67b4d1c189a643b6ac8ed8b2c190db2cecf84)) + + + + + # [1.50.5](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.4...v1.50.5) (2024-04-09) **Note:** Version bump only for package @http-server/generic diff --git a/apps/http-server/packages/generic/package.json b/apps/http-server/packages/generic/package.json index 9346c0a5..f4b450ec 100644 --- a/apps/http-server/packages/generic/package.json +++ b/apps/http-server/packages/generic/package.json @@ -1,6 +1,6 @@ { "name": "@http-server/generic", - "version": "1.50.6", + "version": "1.50.7", "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/apps/single-app/app/CHANGELOG.md b/apps/single-app/app/CHANGELOG.md index a0d5dddf..6c0b81a4 100644 --- a/apps/single-app/app/CHANGELOG.md +++ b/apps/single-app/app/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.50.7](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.6...v1.50.7) (2024-07-04) + +**Note:** Version bump only for package @single-app/app + + + + + # [1.50.5](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.4...v1.50.5) (2024-04-09) **Note:** Version bump only for package @single-app/app diff --git a/apps/single-app/app/package.json b/apps/single-app/app/package.json index c902e0b8..2e8e668f 100644 --- a/apps/single-app/app/package.json +++ b/apps/single-app/app/package.json @@ -1,6 +1,6 @@ { "name": "@single-app/app", - "version": "1.50.6", + "version": "1.50.7", "description": "Package Manager, http-proxy etc.. all in one application", "private": true, "scripts": { @@ -17,7 +17,7 @@ }, "dependencies": { "@appcontainer-node/generic": "1.50.6", - "@http-server/generic": "1.50.6", + "@http-server/generic": "1.50.7", "@package-manager/generic": "1.50.6", "@quantel-http-transformer-proxy/generic": "1.50.6", "@sofie-package-manager/api": "1.50.6", diff --git a/lerna.json b/lerna.json index 51647bc8..a2cb5eb7 100644 --- a/lerna.json +++ b/lerna.json @@ -4,6 +4,6 @@ "apps/**", "tests/**" ], - "version": "1.50.6", + "version": "1.50.7", "npmClient": "yarn" } diff --git a/tests/internal-tests/CHANGELOG.md b/tests/internal-tests/CHANGELOG.md index 22a665f3..c4e5442a 100644 --- a/tests/internal-tests/CHANGELOG.md +++ b/tests/internal-tests/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.50.7](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.6...v1.50.7) (2024-07-04) + +**Note:** Version bump only for package @tests/internal-tests + + + + + # [1.50.5](https://github.com/nrkno/tv-automation-package-manager/compare/v1.50.4...v1.50.5) (2024-04-09) ### Bug Fixes diff --git a/tests/internal-tests/package.json b/tests/internal-tests/package.json index 31a5f701..10e558d0 100644 --- a/tests/internal-tests/package.json +++ b/tests/internal-tests/package.json @@ -1,6 +1,6 @@ { "name": "@tests/internal-tests", - "version": "1.50.6", + "version": "1.50.7", "description": "Internal tests", "private": true, "scripts": { @@ -19,7 +19,7 @@ "tv-automation-quantel-gateway-client": "^3.1.7" }, "dependencies": { - "@http-server/generic": "1.50.6", + "@http-server/generic": "1.50.7", "@package-manager/generic": "1.50.6", "@sofie-package-manager/api": "1.50.6", "@sofie-package-manager/expectation-manager": "1.50.6", diff --git a/yarn.lock b/yarn.lock index 1563a003..4befdab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -596,13 +596,13 @@ __metadata: version: 0.0.0-use.local resolution: "@http-server/app@workspace:apps/http-server/app" dependencies: - "@http-server/generic": "npm:1.50.6" + "@http-server/generic": "npm:1.50.7" lerna: "npm:^6.6.1" rimraf: "npm:^5.0.5" languageName: unknown linkType: soft -"@http-server/generic@npm:1.50.6, @http-server/generic@workspace:apps/http-server/packages/generic": +"@http-server/generic@npm:1.50.7, @http-server/generic@workspace:apps/http-server/packages/generic": version: 0.0.0-use.local resolution: "@http-server/generic@workspace:apps/http-server/packages/generic" dependencies: @@ -1892,7 +1892,7 @@ __metadata: resolution: "@single-app/app@workspace:apps/single-app/app" dependencies: "@appcontainer-node/generic": "npm:1.50.6" - "@http-server/generic": "npm:1.50.6" + "@http-server/generic": "npm:1.50.7" "@package-manager/generic": "npm:1.50.6" "@quantel-http-transformer-proxy/generic": "npm:1.50.6" "@sofie-package-manager/api": "npm:1.50.6" @@ -2096,7 +2096,7 @@ __metadata: version: 0.0.0-use.local resolution: "@tests/internal-tests@workspace:tests/internal-tests" dependencies: - "@http-server/generic": "npm:1.50.6" + "@http-server/generic": "npm:1.50.7" "@package-manager/generic": "npm:1.50.6" "@sofie-package-manager/api": "npm:1.50.6" "@sofie-package-manager/expectation-manager": "npm:1.50.6" From 8610b6ebdb4bef441f9e56a3b3be512f3ccbcfad Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 11 Jul 2024 14:53:26 +0200 Subject: [PATCH 10/46] feat: add HTML Renderer --- .gitignore | 2 + apps/html-renderer/app/.gitignore | 4 + apps/html-renderer/app/README.md | 73 + apps/html-renderer/app/jest.config.js | 7 + apps/html-renderer/app/package.json | 75 + apps/html-renderer/app/scripts/post_build.js | 46 + .../app/scripts/prepare_build.js | 32 + .../app/src/__tests__/test.spec.ts | 7 + apps/html-renderer/app/src/config.ts | 107 ++ apps/html-renderer/app/src/config.ts.tmp | 608 ++++++++ apps/html-renderer/app/src/index.ts | 246 ++++ apps/html-renderer/app/tsconfig.json | 7 + .../html-renderer/packages/generic/.gitignore | 3 + .../packages/generic/jest.config.js | 7 + .../packages/generic/package.json | 34 + .../generic/src/__tests__/temp.spec.ts | 6 + .../packages/generic/src/index.ts | 1 + .../packages/generic/src/renderHTML.ts | 497 +++++++ .../packages/generic/tsconfig.json | 7 + .../nrk/expectations-lib.ts | 71 +- .../generateExpectations/nrk/expectations.ts | 3 + .../src/generateExpectations/nrk/types.ts | 8 + apps/single-app/app/expectedPackages.json | 69 +- scripts/build-win32.mjs | 2 +- scripts/gather-all-built.mjs | 16 +- shared/packages/api/src/config.ts | 10 +- shared/packages/api/src/expectationApi.ts | 86 +- shared/packages/api/src/ffmpeg.ts | 67 + shared/packages/api/src/htmlRenderer.ts | 114 ++ shared/packages/api/src/index.ts | 3 + shared/packages/api/src/inputApi.ts | 64 +- shared/packages/worker/package.json | 4 +- .../lib/__tests__/pathJoin.spec.ts | 11 - .../worker/accessorHandlers/lib/pathJoin.ts | 19 - .../expectationHandlers/RenderHTML.ts | 981 +++++++++++++ .../expectationHandlers/lib/ffmpeg.ts | 79 +- .../workers/genericWorker/genericWorker.ts | 19 +- yarn.lock | 1306 ++++++++++++++++- 38 files changed, 4564 insertions(+), 137 deletions(-) create mode 100644 apps/html-renderer/app/.gitignore create mode 100644 apps/html-renderer/app/README.md create mode 100644 apps/html-renderer/app/jest.config.js create mode 100644 apps/html-renderer/app/package.json create mode 100644 apps/html-renderer/app/scripts/post_build.js create mode 100644 apps/html-renderer/app/scripts/prepare_build.js create mode 100644 apps/html-renderer/app/src/__tests__/test.spec.ts create mode 100644 apps/html-renderer/app/src/config.ts create mode 100644 apps/html-renderer/app/src/config.ts.tmp create mode 100644 apps/html-renderer/app/src/index.ts create mode 100644 apps/html-renderer/app/tsconfig.json create mode 100644 apps/html-renderer/packages/generic/.gitignore create mode 100644 apps/html-renderer/packages/generic/jest.config.js create mode 100644 apps/html-renderer/packages/generic/package.json create mode 100644 apps/html-renderer/packages/generic/src/__tests__/temp.spec.ts create mode 100644 apps/html-renderer/packages/generic/src/index.ts create mode 100644 apps/html-renderer/packages/generic/src/renderHTML.ts create mode 100644 apps/html-renderer/packages/generic/tsconfig.json create mode 100644 shared/packages/api/src/ffmpeg.ts create mode 100644 shared/packages/api/src/htmlRenderer.ts delete mode 100644 shared/packages/worker/src/worker/accessorHandlers/lib/__tests__/pathJoin.spec.ts delete mode 100644 shared/packages/worker/src/worker/accessorHandlers/lib/pathJoin.ts create mode 100644 shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts diff --git a/.gitignore b/.gitignore index 31da931a..ddc98ca0 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ ffprobe.exe apps/single-app/app/expectedPackages.json_smartbull .ffmpeg/ signtool.exe +apps/single-app/app/tmp/ +apps/single-app/app/tmpRenderHTML/ diff --git a/apps/html-renderer/app/.gitignore b/apps/html-renderer/app/.gitignore new file mode 100644 index 00000000..b3ae841f --- /dev/null +++ b/apps/html-renderer/app/.gitignore @@ -0,0 +1,4 @@ +ffmpeg.exe +ffprobe.exe +log.log +deploy/* diff --git a/apps/html-renderer/app/README.md b/apps/html-renderer/app/README.md new file mode 100644 index 00000000..975f654c --- /dev/null +++ b/apps/html-renderer/app/README.md @@ -0,0 +1,73 @@ +# HTML Renderer + +## How to run + +### Default CasparCG template + +_This is used for a HTML file that follows the typical CasparCG lifespan (`update(data); play(); stop()`)_ + +```bash +html-renderer.exe -- --url=file://C:/templates/mytemplate.html outputPath="C:\\rendered" --screenshots=true --recording=true --recording-cropped=true --casparData='{"name":"John Doe"}' --casparDelay=1000 +``` + +### Standalone HTML template + +_This is used for a HTML file that doesn't require any additional input during its run._ + +```bash +html-renderer.exe -- --url=https://bouncingdvdlogo.com outputPath="C:\\rendered" --screenshots=true --recording=true --recording-cropped=false --genericWaitIdle=1000 --genericWaitPlay=1000 --genericWaitStop=1000 --width=480 --height=320 --zoom=0.25 +``` + +### Interactive mode + +_This is used for HTML templates that require manual handling (like, external API calls need to be made)_ + +```bash +html-renderer.exe -- --url=https://bouncingdvdlogo.com outputPath=C:\\rendered --interactive=1 +``` + +In interactive mode, commands are sent to the renderer via the console. The following commands are available: + +```json + +// Wait for this message before sending interactive messages. + { "status": "ready" } + + +// Wait for the load event to be fired + { "do": "waitForLoad" } +// Reply: + { "reply": "waitForLoad" } + +// Take a screenshot and save it as PNG + { "do": "takeScreenshot", "fileName": "screenshot.png" } +// Reply: + { "reply": "takeScreenshot" } + { "reply": "takeScreenshot", "error": "Unable to write file \"screenshot.png\"" } + +// Start recording + { "do": "startRecording", "fileName": "recording.webm" } +// Reply: + { "reply": "startRecording" } + { "reply": "startRecording", "error": "Recording already started" } + + +// Stop recording + { "do": "stopRecording" } +// Reply: + { "reply": "stopRecording"} + { "reply": "stopRecording", "error": "Unable to write file \"recording.webm\"" } + +// Analyze the recording and crop it to only include the region with content + { "do": "cropRecording", "fileName": "recording-cropped.webm" } +// Reply: + { "reply": "cropRecording" } + { "reply": "cropRecording", "error": "No recording found" } + +// Execute javascript in the renderer + { "do": "executeJs", "js": "update(\"myData\")" } +// Reply: + { "reply": "executeJs" } + { "reply": "executeJs", "error": "Some error" } + +``` diff --git a/apps/html-renderer/app/jest.config.js b/apps/html-renderer/app/jest.config.js new file mode 100644 index 00000000..e87056b5 --- /dev/null +++ b/apps/html-renderer/app/jest.config.js @@ -0,0 +1,7 @@ +const base = require('../../../jest.config.base') +const packageJson = require('./package') + +module.exports = { + ...base, + displayName: packageJson.name, +} diff --git a/apps/html-renderer/app/package.json b/apps/html-renderer/app/package.json new file mode 100644 index 00000000..7275a727 --- /dev/null +++ b/apps/html-renderer/app/package.json @@ -0,0 +1,75 @@ +{ + "name": "@html-renderer/app", + "version": "1.50.5", + "description": "HTML-renderer", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "yarn rimraf dist && yarn build:main", + "build:main": "tsc -p tsconfig.json", + "build-win32": "yarn prepare-build-win32 && electron-builder && yarn post-build-win32", + "prepare-build-win32": "node scripts/prepare_build.js", + "post-build-win32": "node scripts/post_build.js", + "__test": "jest", + "start": "electron dist/index.js" + }, + "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", + "engines": { + "node": ">=18" + }, + "lint-staged": { + "*.{js,css,json,md,scss}": [ + "prettier" + ], + "*.{ts,tsx}": [ + "eslint" + ] + }, + "peerDependencies": { + "ws": "*" + }, + "dependencies": { + "@html-renderer/generic": "1.50.5", + "@sofie-automation/shared-lib": "1.50.0", + "@sofie-package-manager/api": "1.50.5", + "portfinder": "^1.0.32", + "tslib": "^2.1.0", + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/ws": "^8.5.4", + "archiver": "^7.0.1", + "electron": "^30.0.6", + "electron-builder": "^24.13.3", + "lerna": "^6.6.1", + "rimraf": "^5.0.5" + }, + "build": { + "productName": "html-renderer", + "appId": "no.nrk.sofie.html-renderer", + "win": { + "extraFiles": [], + "target": [ + { + "target": "portable", + "arch": [ + "x64" + ] + } + ] + }, + "linux": { + "extraFiles": [] + }, + "electronVersion": "30.0.6", + "files": [ + "dist/**/*" + ], + "portable": { + "artifactName": "html-renderer.exe" + }, + "directories": { + "output": "deploy" + } + } +} diff --git a/apps/html-renderer/app/scripts/post_build.js b/apps/html-renderer/app/scripts/post_build.js new file mode 100644 index 00000000..be9128f9 --- /dev/null +++ b/apps/html-renderer/app/scripts/post_build.js @@ -0,0 +1,46 @@ +/* eslint-disable no-console, node/no-unpublished-require */ +const fs = require('fs') +const path = require('path') +const archiver = require('archiver') + +/* + * This script gathers the built files from electron-builder and zips them into a zip file + */ + +async function main() { + const myDir = path.resolve('.') + const deployDir = path.join(myDir, 'deploy') + const archiveDir = path.join(deployDir, 'win-unpacked') + + const zipFile = path.join(deployDir, 'html-renderer.zip') + + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipFile) + const archive = archiver('zip', { + zlib: { level: 5 }, // Sets the compression level. + }) + output.on('close', function () { + console.log(archive.pointer() + ' total bytes') + resolve() + }) + archive.on('warning', function (err) { + if (err.code === 'ENOENT') console.log(`WARNING: ${err}`) + else reject(err) + }) + archive.on('error', reject) + archive.pipe(output) + + console.log(`Archiving ${archiveDir}`) + archive.directory(archiveDir, false) + + archive.finalize() + }) + console.log('Zipping done, removing temporary artifacts...') + + // Remove the archived directory + await fs.promises.rm(archiveDir, { recursive: true }) + await fs.promises.rm(path.join(deployDir, 'html-renderer.exe'), { recursive: true }) + await fs.promises.rm(path.join(myDir, 'node_modules'), { recursive: true }) +} + +main().catch(console.error) diff --git a/apps/html-renderer/app/scripts/prepare_build.js b/apps/html-renderer/app/scripts/prepare_build.js new file mode 100644 index 00000000..aebecbc5 --- /dev/null +++ b/apps/html-renderer/app/scripts/prepare_build.js @@ -0,0 +1,32 @@ +const fs = require('fs') +const path = require('path') +/* eslint-disable no-console */ + +/* + * This script copies some dependencies from the main node_modules folder to the node_modules folder of this project. + * So that electron-builder includes them when building the executable. + +*/ + +async function main() { + // Things to copy: + + const baseDir = path.resolve('../../..') + const myDir = path.resolve('.') + + const libsToCopy = ['tslib', '@sofie-automation'] + + // Create node_modules folder + await fs.promises.mkdir(path.join(myDir, 'node_modules'), { recursive: true }) + + for (const lib of libsToCopy) { + const src = path.join(baseDir, `node_modules/${lib}`) + const target = path.join(myDir, `node_modules/${lib}`) + console.log(`Copying ${src} to ${target}`) + await fs.promises.cp(src, target, { + recursive: true, + }) + } +} + +main().catch(console.error) diff --git a/apps/html-renderer/app/src/__tests__/test.spec.ts b/apps/html-renderer/app/src/__tests__/test.spec.ts new file mode 100644 index 00000000..b65fc1f7 --- /dev/null +++ b/apps/html-renderer/app/src/__tests__/test.spec.ts @@ -0,0 +1,7 @@ +describe('tmp', () => { + test('tmp', () => { + // Note: To enable tests in this package, ensure that the "test" script is present in package.json + expect(1).toEqual(1) + }) +}) +export {} diff --git a/apps/html-renderer/app/src/config.ts b/apps/html-renderer/app/src/config.ts new file mode 100644 index 00000000..7121b8c2 --- /dev/null +++ b/apps/html-renderer/app/src/config.ts @@ -0,0 +1,107 @@ +import yargs = require('yargs/yargs') +import { + ProcessConfig, + getProcessConfig, + processOptions, + defineArguments, + getProcessArgv, +} from '@sofie-package-manager/api' + +/* + * This file contains various CLI argument definitions, used by the various processes that together constitutes the Package Manager + */ + +/** Generic CLI-argument-definitions for any process */ +const htmlRendererOptions = defineArguments({ + url: { type: 'string', describe: 'URL or path to the file to be rendered' }, + width: { type: 'number', describe: 'Width of the HTML renderer (default: 1920)' }, + height: { type: 'number', describe: 'Width of the HTML renderer (default: 1080)' }, + zoom: { type: 'number', describe: 'Zoom factor of the HTML renderer (default: 1)' }, + outputPath: { type: 'string', describe: 'File path to where the output files will be saved' }, + tempPath: { type: 'string', describe: 'File path to where temporary files will be saved (default: "tmp")' }, + screenshots: { type: 'boolean', describe: 'When true, will capture screenshots' }, + recording: { type: 'boolean', describe: 'When true, will capture recording' }, + 'recording-cropped': { + type: 'boolean', + describe: 'When true, will capture a recording cropped to the non-black area', + }, + casparData: { type: 'string', describe: '(JSON) data to send into the update() function of a CasparCG Template' }, + casparDelay: { + type: 'number', + describe: 'How long to wait between each action in a CasparCG template (default: 1000ms)', + }, + genericWaitIdle: { + type: 'number', + describe: 'For a generic HTML template, how long to wait before considering it idle', + }, + genericWaitPlay: { + type: 'number', + describe: 'For a generic HTML template, how long to wait before considering it playing', + }, + genericWaitStop: { + type: 'number', + describe: 'For a generic HTML template, how long to wait before considering it stopped', + }, + interactive: { + type: 'boolean', + describe: 'When true, will start the process in interactive mode. See Readme for docs.', + }, + test: { + type: 'boolean', + describe: 'When true, will simply trace the version and then close.', + }, +}) + +export interface HTMLRendererOptionsConfig { + url: string | undefined + width: number | undefined + height: number | undefined + zoom: number | undefined + outputPath: string | undefined + tempPath: string | undefined + screenshots: boolean | undefined + recording: boolean | undefined + 'recording-cropped': boolean | undefined + casparData: string | undefined + casparDelay: number | undefined + genericWaitIdle: number | undefined + genericWaitPlay: number | undefined + genericWaitStop: number | undefined + interactive: boolean | undefined + test: boolean | undefined +} +export async function getHTMLRendererConfig(): Promise<{ + process: ProcessConfig + htmlRenderer: HTMLRendererOptionsConfig +}> { + const argv = await Promise.resolve( + yargs(getProcessArgv()).options({ + ...processOptions, + ...htmlRendererOptions, + }).argv + ) + + return { + process: getProcessConfig(argv), + htmlRenderer: { + url: argv.url, + width: argv.width, + height: argv.height, + zoom: argv.zoom, + outputPath: argv.outputPath, + tempPath: argv.tempPath, + screenshots: argv.screenshots, + recording: argv.recording, + 'recording-cropped': argv['recording-cropped'], + casparData: argv.casparData, + casparDelay: argv.casparDelay, + genericWaitIdle: argv.genericWaitIdle, + genericWaitPlay: argv.genericWaitPlay, + genericWaitStop: argv.genericWaitStop, + interactive: argv.interactive, + test: argv.test, + }, + } +} + +// --------------------------------------------------------------------------------- diff --git a/apps/html-renderer/app/src/config.ts.tmp b/apps/html-renderer/app/src/config.ts.tmp new file mode 100644 index 00000000..8652dbf0 --- /dev/null +++ b/apps/html-renderer/app/src/config.ts.tmp @@ -0,0 +1,608 @@ +import { Options } from 'yargs' +import yargs = require('yargs/yargs') +import _ from 'underscore' +import { WorkerAgentConfig } from './worker' +import { AppContainerConfig } from './appContainer' +import { protectString } from './ProtectedString' +import { AppContainerId, WorkerAgentId } from './ids' + +/* + * This file contains various CLI argument definitions, used by the various processes that together constitutes the Package Manager + */ + +/** Generic CLI-argument-definitions for any process */ +const processOptions = defineArguments({ + logPath: { type: 'string', describe: 'Set to write logs to this file' }, + logLevel: { type: 'string', describe: 'Set default log level. (Might be overwritten by Sofie Core)' }, + + unsafeSSL: { + type: 'boolean', + default: process.env.UNSAFE_SSL === '1', + describe: 'Set to true to allow all SSL certificates (only use this in a safe, local environment)', + }, + certificates: { type: 'string', describe: 'SSL Certificates' }, +}) +/** CLI-argument-definitions for the Workforce process */ +const workforceArguments = defineArguments({ + port: { + type: 'number', + default: parseInt(process.env.WORKFORCE_PORT || '', 10) || 8070, + describe: 'The port number to start the Workforce websocket server on', + }, +}) +/** CLI-argument-definitions for the HTTP-Server process */ +const httpServerArguments = defineArguments({ + httpServerPort: { + type: 'number', + default: parseInt(process.env.HTTP_SERVER_PORT || '', 10) || 8080, + describe: 'The port number to use for the HTTP server', + }, + apiKeyRead: { + type: 'string', + default: process.env.HTTP_SERVER_API_KEY_READ || undefined, + describe: 'Set this to limit read-access', + }, + apiKeyWrite: { + type: 'string', + default: process.env.HTTP_SERVER_API_KEY_WRITE || undefined, + describe: 'Set this to limit write-access', + }, + cleanFileAge: { + type: 'number', + default: parseInt(process.env.HTTP_SERVER_CLEAN_FILE_AGE || '0', 10) || 3600 * 24 * 30, // default: 30 days + describe: + 'Automatically remove files older than this age, in seconds (defaults to 30 days). Set to -1 to disable.', + }, + basePath: { + type: 'string', + default: process.env.HTTP_SERVER_BASE_PATH || './fileStorage', + describe: 'The internal path to use for file storage', + }, +}) +/** CLI-argument-definitions for the Package Manager process */ +const packageManagerArguments = defineArguments({ + coreHost: { + type: 'string', + default: process.env.CORE_HOST || '127.0.0.1', + describe: 'The IP-address/hostName to Sofie Core', + }, + corePort: { + type: 'number', + default: parseInt(process.env.CORE_PORT || '', 10) || 3000, + describe: 'The port number of Sofie core (usually 80, 443 or 3000)', + }, + + deviceId: { + type: 'string', + default: process.env.DEVICE_ID || '', + describe: '(Optional) Unique devide id of this device', + }, + deviceToken: { + type: 'string', + default: process.env.DEVICE_TOKEN || '', + describe: '(Optional) access token of this device.', + }, + + disableWatchdog: { + type: 'boolean', + default: process.env.DISABLE_WATCHDOG === '1', + describe: 'Set to true to disable the Watchdog (it kills the process if connection to Core is lost)', + }, + + port: { + type: 'number', + default: parseInt(process.env.PACKAGE_MANAGER_PORT || '', 10) || 8060, + describe: 'The port number to start the Package Manager websocket server on', + }, + accessUrl: { + type: 'string', + default: process.env.PACKAGE_MANAGER_URL || 'ws://localhost:8060', + describe: 'The URL where Package Manager websocket server can be accessed', + }, + workforceURL: { + type: 'string', + default: process.env.WORKFORCE_URL || 'ws://localhost:8070', + describe: 'The URL to the Workforce', + }, + watchFiles: { + type: 'boolean', + default: process.env.WATCH_FILES === '1', + describe: 'If true, will watch the file "expectedPackages.json" as an additional source of expected packages.', + }, + noCore: { + type: 'boolean', + default: process.env.NO_CORE === '1', + describe: 'If true, Package Manager wont try to connect to Sofie Core', + }, + chaosMonkey: { + type: 'boolean', + default: process.env.CHAOS_MONKEY === '1', + describe: 'If true, enables the "chaos monkey"-feature, which will randomly kill processes every few seconds', + }, + concurrency: { + type: 'number', + default: parseInt(process.env.CONCURRENCY || '', 10) || undefined, + describe: 'How many expectation states can be evaluated at the same time', + }, +}) +/** CLI-argument-definitions for the Worker process */ +const workerArguments = defineArguments({ + workerId: { type: 'string', default: process.env.WORKER_ID || 'worker0', describe: 'Unique id of the worker' }, + workforceURL: { + type: 'string', + default: process.env.WORKFORCE_URL || 'ws://localhost:8070', + describe: 'The URL to the Workforce', + }, + appContainerURL: { + type: 'string', + default: process.env.APP_CONTAINER_URL || '', // 'ws://localhost:8090', + describe: 'The URL to the AppContainer', + }, + windowsDriveLetters: { + type: 'string', + default: process.env.WORKER_WINDOWS_DRIVE_LETTERS || 'X;Y;Z', + describe: 'Which Windows Drive letters can be used to map shares. ("X;Y;Z") ', + }, + resourceId: { + type: 'string', + default: process.env.WORKER_NETWORK_ID || 'default', + describe: 'Identifier of the local resource/computer this worker runs on', + }, + networkIds: { + type: 'string', + default: process.env.WORKER_NETWORK_ID || 'default', + describe: 'Identifier of the local networks this worker has access to ("networkA;networkB")', + }, + costMultiplier: { + type: 'number', + default: process.env.WORKER_COST_MULTIPLIER || 1, + describe: 'Multiply the cost of the worker with this', + }, + considerCPULoad: { + type: 'number', + default: process.env.WORKER_CONSIDER_CPU_LOAD || '', + describe: + 'If set, the worker will consider the CPU load of the system it runs on before it accepts jobs. Set to a value between 0 and 1, the worker will accept jobs if the CPU load is below the configured value.', + }, + pickUpCriticalExpectationsOnly: { + type: 'boolean', + default: process.env.WORKER_PICK_UP_CRITICAL_EXPECTATIONS_ONLY === '1' || false, + describe: 'If set to 1, the worker will only pick up expectations that are marked as critical for playout.', + }, +}) +/** CLI-argument-definitions for the AppContainer process */ +const appContainerArguments = defineArguments({ + appContainerId: { + type: 'string', + default: process.env.APP_CONTAINER_ID || 'appContainer0', + describe: 'Unique id of the appContainer', + }, + workforceURL: { + type: 'string', + default: process.env.WORKFORCE_URL || 'ws://localhost:8070', + describe: 'The URL to the Workforce', + }, + port: { + type: 'number', + default: parseInt(process.env.APP_CONTAINER_PORT || '', 10) || 8090, + describe: 'The port number to start the App Container websocket server on', + }, + maxRunningApps: { + type: 'number', + default: parseInt(process.env.APP_CONTAINER_MAX_RUNNING_APPS || '', 10) || 3, + describe: 'How many apps the appContainer can run at the same time', + }, + minRunningApps: { + type: 'number', + default: parseInt(process.env.APP_CONTAINER_MIN_RUNNING_APPS || '', 10) || 0, + describe: 'Minimum amount of apps (of a certain appType) to be running', + }, + maxAppKeepalive: { + type: 'number', + default: parseInt(process.env.APP_CONTAINER_MAX_APP_KEEPALIVE || '', 10) || 6 * 3600 * 1000, // ms (6 hours) + describe: 'Maximum time an app will be kept running', + }, + spinDownTime: { + type: 'number', + default: parseInt(process.env.APP_CONTAINER_SPIN_DOWN_TIME || '', 10) || 60 * 1000, // ms (1 minute) + describe: 'How long a Worker should stay idle before attempting to be spun down', + }, + minCriticalWorkerApps: { + type: 'number', + default: 0, + describe: 'Number of Workers reserved for fulfilling playout-critical expectations that will be kept running', + }, + + // These are passed-through to the spun-up workers: + resourceId: { + type: 'string', + default: process.env.WORKER_NETWORK_ID || 'default', + describe: 'Identifier of the local resource/computer this worker runs on', + }, + networkIds: { + type: 'string', + default: process.env.WORKER_NETWORK_ID || 'default', + describe: 'Identifier of the local networks this worker has access to ("networkA;networkB")', + }, + windowsDriveLetters: { + type: 'string', + default: process.env.WORKER_WINDOWS_DRIVE_LETTERS || 'X;Y;Z', + describe: 'Which Windows Drive letters can be used to map shares. ("X;Y;Z") ', + }, + costMultiplier: { + type: 'number', + default: process.env.WORKER_COST_MULTIPLIER || 1, + describe: 'Multiply the cost of the worker with this', + }, + considerCPULoad: { + type: 'number', + default: process.env.WORKER_CONSIDER_CPU_LOAD || '', + describe: + 'If set, the worker will consider the CPU load of the system it runs on before it accepts jobs. Set to a value between 0 and 1, the worker will accept jobs if the CPU load is below the configured value.', + }, +}) +/** CLI-argument-definitions for the "Single" process */ +const singleAppArguments = defineArguments({ + noHTTPServers: { + type: 'boolean', + default: process.env.NO_HTTP_SERVERS === '1', + describe: 'If set, the app will not start the HTTP servers', + }, + workerCount: { + type: 'number', + default: parseInt(process.env.WORKER_COUNT || '', 10) || 1, + describe: 'How many workers to spin up', + }, + workforcePort: { + type: 'number', + // 0 = Set the workforce port to whatever is available + default: parseInt(process.env.WORKFORCE_PORT || '', 10) || 0, + describe: 'The port number to start the Workforce websocket server on', + }, +}) +/** CLI-argument-definitions for the Quantel-HTTP-Transformer-Proxy process */ +const quantelHTTPTransformerProxyConfigArguments = defineArguments({ + quantelProxyPort: { + type: 'number', + default: parseInt(process.env.QUANTEL_HTTP_TRANSFORMER_PROXY_PORT || '', 10) || 8081, + describe: 'The port on which to server the Quantel-HTTP-Transformer-Proxy server on', + }, + quantelTransformerURL: { + type: 'string', + default: process.env.QUANTEL_HTTP_TRANSFORMER_URL || undefined, + describe: 'URL to the Quantel-HTTP-Transformer', + }, + + quantelTransformerRateLimitDuration: { + type: 'number', + default: parseInt(process.env.QUANTEL_HTTP_TRANSFORMER_RATE_LIMIT_DURATION || '', 10) || undefined, + describe: 'Rate Limit Duration for the Quantel-HTTP-Transformer [ms]', + }, + quantelTransformerRateLimitMax: { + type: 'number', + default: parseInt(process.env.QUANTEL_HTTP_TRANSFORMER_RATE_LIMIT_MAX || '', 10) || undefined, + describe: 'Rate Limit Max for the Quantel-HTTP-Transformer', + }, +}) + +export interface ProcessConfig { + logPath: string | undefined + logLevel: string | undefined + /** Will cause the Node app to blindly accept all certificates. Not recommenced unless in local, controlled networks. */ + unsafeSSL: boolean + /** Paths to certificates to load, for SSL-connections */ + certificates: string[] +} +function getProcessConfig(argv: { + logPath: string | undefined + logLevel: string | undefined + unsafeSSL: boolean + certificates: string | undefined +}) { + const certs: string[] = (argv.certificates || process.env.CERTIFICATES || '').split(';') || [] + return { + logPath: argv.logPath, + logLevel: argv.logLevel, + unsafeSSL: argv.unsafeSSL, + certificates: _.compact(certs), + } +} +// Configuration for the Workforce Application: ------------------------------ +export interface WorkforceConfig { + process: ProcessConfig + workforce: { + port: number | null + } +} + +export async function getWorkforceConfig(): Promise { + const argv = await Promise.resolve( + yargs(getProcessArgv()).options({ + ...workforceArguments, + ...processOptions, + }).argv + ) + + return { + process: getProcessConfig(argv), + workforce: { + port: argv.port, + }, + } +} +// Configuration for the HTTP server Application: ---------------------------------- +export interface HTTPServerConfig { + process: ProcessConfig + httpServer: { + port: number + + basePath: string + apiKeyRead: string | undefined + apiKeyWrite: string | undefined + /** Clean up (remove) files older than this age (in seconds). 0 or -1 means that it's disabled. */ + cleanFileAge: number + } +} +export async function getHTTPServerConfig(): Promise { + const argv = await Promise.resolve( + yargs(getProcessArgv()).options({ + ...httpServerArguments, + ...processOptions, + }).argv + ) + + if (!argv.apiKeyWrite && argv.apiKeyRead) { + throw new Error(`Error: When apiKeyRead is given, apiKeyWrite is required!`) + } + + return { + process: getProcessConfig(argv), + httpServer: { + port: argv.httpServerPort, + basePath: argv.basePath, + apiKeyRead: argv.apiKeyRead, + apiKeyWrite: argv.apiKeyWrite, + cleanFileAge: argv.cleanFileAge, + }, + } +} +// Configuration for the Package Manager Application: ------------------------------ +export interface PackageManagerConfig { + process: ProcessConfig + packageManager: { + coreHost: string + corePort: number + deviceId: string + deviceToken: string + disableWatchdog: boolean + + port: number | null + accessUrl: string | null + workforceURL: string | null + + watchFiles: boolean + noCore: boolean + chaosMonkey: boolean + concurrency?: number + } +} +export async function getPackageManagerConfig(): Promise { + const argv = await Promise.resolve( + yargs(getProcessArgv()).options({ + ...packageManagerArguments, + ...processOptions, + }).argv + ) + + return { + process: getProcessConfig(argv), + packageManager: { + coreHost: argv.coreHost, + corePort: argv.corePort, + deviceId: argv.deviceId, + deviceToken: argv.deviceToken, + disableWatchdog: argv.disableWatchdog, + + port: argv.port, + accessUrl: argv.accessUrl, + workforceURL: argv.workforceURL, + + watchFiles: argv.watchFiles, + noCore: argv.noCore, + chaosMonkey: argv.chaosMonkey, + concurrency: argv.concurrency, + }, + } +} +// Configuration for the Worker Application: ------------------------------ +export interface WorkerConfig { + process: ProcessConfig + worker: { + // Note: when changing these values, remember to also update appContainer.ts + workforceURL: string | null + appContainerURL: string | null + resourceId: string + networkIds: string[] + costMultiplier: number + considerCPULoad: number | null + pickUpCriticalExpectationsOnly: boolean + } & WorkerAgentConfig +} +export async function getWorkerConfig(): Promise { + const argv = await Promise.resolve( + yargs(getProcessArgv()).options({ + ...workerArguments, + ...processOptions, + }).argv + ) + + return { + process: getProcessConfig(argv), + worker: { + workerId: protectString(argv.workerId), + workforceURL: argv.workforceURL, + appContainerURL: argv.appContainerURL, + + resourceId: argv.resourceId, + networkIds: argv.networkIds ? argv.networkIds.split(';') : [], + windowsDriveLetters: argv.windowsDriveLetters ? argv.windowsDriveLetters.split(';') : [], + costMultiplier: + (typeof argv.costMultiplier === 'string' ? parseFloat(argv.costMultiplier) : argv.costMultiplier) || 1, + considerCPULoad: + (typeof argv.considerCPULoad === 'string' ? parseFloat(argv.considerCPULoad) : argv.considerCPULoad) || + null, + pickUpCriticalExpectationsOnly: argv.pickUpCriticalExpectationsOnly, + }, + } +} +// Configuration for the AppContainer Application: ------------------------------ +export interface AppContainerProcessConfig { + process: ProcessConfig + appContainer: AppContainerConfig +} +export async function getAppContainerConfig(): Promise { + const argv = await Promise.resolve( + yargs(getProcessArgv()).options({ + ...appContainerArguments, + ...processOptions, + }).argv + ) + + return { + process: getProcessConfig(argv), + appContainer: { + workforceURL: argv.workforceURL, + port: argv.port, + appContainerId: protectString(argv.appContainerId), + maxRunningApps: argv.maxRunningApps, + minRunningApps: argv.minRunningApps, + maxAppKeepalive: argv.maxAppKeepalive, + spinDownTime: argv.spinDownTime, + minCriticalWorkerApps: argv.minCriticalWorkerApps, + + worker: { + resourceId: argv.resourceId, + networkIds: argv.networkIds ? argv.networkIds.split(';') : [], + windowsDriveLetters: argv.windowsDriveLetters ? argv.windowsDriveLetters.split(';') : [], + costMultiplier: + (typeof argv.costMultiplier === 'string' ? parseFloat(argv.costMultiplier) : argv.costMultiplier) || + 1, + considerCPULoad: + (typeof argv.considerCPULoad === 'string' + ? parseFloat(argv.considerCPULoad) + : argv.considerCPULoad) || null, + }, + }, + } +} + +// Configuration for the Single-app Application: ------------------------------ +export interface SingleAppConfig + extends WorkforceConfig, + HTTPServerConfig, + PackageManagerConfig, + WorkerConfig, + AppContainerProcessConfig, + QuantelHTTPTransformerProxyConfig { + singleApp: { + noHTTPServers: boolean + workerCount: number + workforcePort: number + } +} + +export async function getSingleAppConfig(): Promise { + const options = { + ...workforceArguments, + ...httpServerArguments, + ...packageManagerArguments, + ...workerArguments, + ...processOptions, + ...singleAppArguments, + ...appContainerArguments, + ...quantelHTTPTransformerProxyConfigArguments, + } + // Remove some that are not used in the Single-App, so that they won't show up when running '--help': + + // @ts-expect-error not optional + delete options.corePort + // @ts-expect-error not optional + delete options.accessUrl + // @ts-expect-error not optional + delete options.workforceURL + // @ts-expect-error not optional + delete options.port + + const argv = await Promise.resolve(yargs(getProcessArgv()).options(options).argv) + + return { + process: getProcessConfig(argv), + workforce: (await getWorkforceConfig()).workforce, + httpServer: (await getHTTPServerConfig()).httpServer, + packageManager: (await getPackageManagerConfig()).packageManager, + worker: (await getWorkerConfig()).worker, + singleApp: { + noHTTPServers: argv.noHTTPServers ?? false, + workerCount: argv.workerCount || 1, + workforcePort: argv.workforcePort, + }, + appContainer: (await getAppContainerConfig()).appContainer, + quantelHTTPTransformerProxy: (await getQuantelHTTPTransformerProxyConfig()).quantelHTTPTransformerProxy, + } +} +// Configuration for the HTTP server Application: ---------------------------------- +export interface QuantelHTTPTransformerProxyConfig { + process: ProcessConfig + quantelHTTPTransformerProxy: { + port: number + + transformerURL?: string + + rateLimitDuration?: number + rateLimitMax?: number + } +} +export async function getQuantelHTTPTransformerProxyConfig(): Promise { + const argv = await Promise.resolve( + yargs(getProcessArgv()).options({ + ...quantelHTTPTransformerProxyConfigArguments, + ...processOptions, + }).argv + ) + + return { + process: getProcessConfig(argv), + quantelHTTPTransformerProxy: { + port: argv.quantelProxyPort, + transformerURL: argv.quantelTransformerURL, + rateLimitDuration: argv.quantelTransformerRateLimitDuration, + rateLimitMax: argv.quantelTransformerRateLimitMax, + }, + } +} +// --------------------------------------------------------------------------------- + +/** Helper function, to get strict typings for the yargs-Options. */ +function defineArguments(opts: O): O { + return opts +} + +function getProcessArgv() { + // Note: process.argv typically looks like this: + // [ + // 'C:\\Program Files\\nodejs\\node.exe', + // 'C:\\path\\to\\my\\package-manager\\apps\\single-app\\app\\dist\\index.js', + // '--', + // '--watchFiles=true', + // '--noCore=true', + // '--logLevel=debug' + // ] + + // Remove the first two arguments + let args = process.argv.slice(2) + + // If the first argument is just '--', remove it: + if (args[0] === '--') args = args.slice(1) + + return args +} diff --git a/apps/html-renderer/app/src/index.ts b/apps/html-renderer/app/src/index.ts new file mode 100644 index 00000000..4158d3ca --- /dev/null +++ b/apps/html-renderer/app/src/index.ts @@ -0,0 +1,246 @@ +import { WebSocketServer } from 'ws' +import * as portFinder from 'portfinder' +import { + setupLogger, + initializeLogger, + assertNever, + InteractiveStdOut, + InteractiveMessage, + InteractiveReply, +} from '@sofie-package-manager/api' +import { renderHTML, RenderHTMLOptions, InteractiveAPI } from '@html-renderer/generic' +import { getHTMLRendererConfig } from './config' + +const PACKAGE_VERSION = '1.50.5' + +async function main(): Promise { + const config = await getHTMLRendererConfig() + // eslint-disable-next-line no-console + if (config.htmlRenderer.test) { + // eslint-disable-next-line no-console + console.log(`Version: ${PACKAGE_VERSION}`) + // eslint-disable-next-line no-process-exit + process.exit(0) + } + + initializeLogger(config) + const logger = setupLogger(config, '') + + let url = config.htmlRenderer.url + if (!url) { + throw new Error('No "url" parameter provided') + } + url = url.trim() + // Is a url + if (url.match(/^(https?)|(file)/)) { + // Do nothing + } else { + // Assume it's a file path + url = `file://${url.replace(/\\/g, '/')}` + } + + let scripts: RenderHTMLOptions['scripts'] = [] + let interactive = undefined + + if (config.htmlRenderer.interactive) { + interactive = async (api: InteractiveAPI) => { + const port = await portFinder.getPortPromise() + const wss = new WebSocketServer({ + port, + }) + await new Promise((resolve, reject) => { + wss.once('listening', resolve) + wss.once('error', reject) + }) + + await new Promise((resolve, reject) => { + const interactiveLogStdOut = (message: InteractiveStdOut) => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(message)) + } + + // Signal that we're listening to at websocket port: + interactiveLogStdOut({ status: 'listening', port }) + + wss.on('connection', (ws) => { + console.log('client connected') + + const interactiveLog = (message: InteractiveReply) => { + ws.send(JSON.stringify(message)) + } + const onError = (message: any) => { + interactiveLog(message) + reject(new Error(message.error)) + } + + ws.on('message', (data) => { + const str = data.toString() + // console.log('received', str) + try { + const message = JSON.parse(str) as InteractiveMessage + if (typeof message === 'object') { + if (message.do === 'waitForLoad') { + api.waitForLoad() + .then(() => interactiveLog({ reply: 'waitForLoad' })) + .catch((e: unknown) => onError({ reply: 'waitForLoad', error: `${e}` })) + } else if (message.do === 'takeScreenshot') { + api.takeScreenshot(message.fileName) + .then(() => interactiveLog({ reply: 'takeScreenshot' })) + .catch((e: unknown) => onError({ reply: 'takeScreenshot', error: `${e}` })) + } else if (message.do === 'startRecording') { + api.startRecording(message.fileName) + .then(() => interactiveLog({ reply: 'startRecording' })) + .catch((e: unknown) => onError({ reply: 'startRecording', error: `${e}` })) + } else if (message.do === 'stopRecording') { + api.stopRecording() + .then(() => interactiveLog({ reply: 'stopRecording' })) + .catch((e: unknown) => onError({ reply: 'stopRecording', error: `${e}` })) + } else if (message.do === 'cropRecording') { + api.cropRecording(message.fileName) + .then(() => interactiveLog({ reply: 'cropRecording' })) + .catch((e: unknown) => onError({ reply: 'cropRecording', error: `${e}` })) + } else if (message.do === 'executeJs') { + api.executeJs(message.js) + .then(() => interactiveLog({ reply: 'executeJs' })) + .catch((e: unknown) => onError({ reply: 'executeJs', error: `${e}` })) + } else if (message.do === 'close') { + resolve() + } else { + assertNever(message) + onError({ reply: 'unsupported', error: `Unsupported message: ${str}` }) + } + } else onError({ reply: 'unsupported', error: `Unsupported message: ${str}` }) + } catch (e) { + onError({ reply: 'unsupported', error: `Error parsing message (${e})` }) + } + }) + }) + }) + } + } else if (config.htmlRenderer.casparData) { + const delayTime = config.htmlRenderer.casparDelay || 1000 + scripts = compact([ + { + wait: 0, + executeJs: `update(${config.htmlRenderer.casparData}); play();`, + ...(config.htmlRenderer.screenshots + ? { + takeScreenshot: { + name: 'idle.png', + }, + } + : {}), + ...(config.htmlRenderer.recording || config.htmlRenderer['recording-cropped'] + ? { + startRecording: { + name: 'recording.webm', + full: config.htmlRenderer.recording, + cropped: config.htmlRenderer['recording-cropped'], + }, + } + : {}), + }, + { + wait: delayTime, + ...(config.htmlRenderer.screenshots + ? { + takeScreenshot: { + name: 'play.png', + }, + } + : {}), + executeJs: `stop()`, + }, + { + wait: delayTime, + ...(config.htmlRenderer.screenshots + ? { + takeScreenshot: { + name: 'stop.png', + }, + } + : {}), + }, + ]) + } else if ( + config.htmlRenderer.genericWaitIdle || + config.htmlRenderer.genericWaitPlay || + config.htmlRenderer.genericWaitStop + ) { + scripts = compact([ + { + wait: config.htmlRenderer.genericWaitIdle || 0, + logInfo: 'State: Idle', + ...(config.htmlRenderer.screenshots + ? { + takeScreenshot: { + name: 'idle.png', + }, + } + : {}), + ...(config.htmlRenderer.recording || config.htmlRenderer['recording-cropped'] + ? { + startRecording: { + name: 'recording.webm', + full: config.htmlRenderer.recording, + cropped: config.htmlRenderer['recording-cropped'], + }, + } + : {}), + }, + { + wait: config.htmlRenderer.genericWaitPlay || 0, + logInfo: 'State: Play', + ...(config.htmlRenderer.screenshots + ? { + takeScreenshot: { + name: 'play.png', + }, + } + : {}), + }, + { + wait: config.htmlRenderer.genericWaitStop || 0, + logInfo: 'State: Stop', + ...(config.htmlRenderer.screenshots + ? { + takeScreenshot: { + name: 'stop.png', + }, + } + : {}), + }, + ]) + } else { + throw new Error( + 'No "interactive", "casparData" or "genericWaitIdle"/"genericWaitPlay"/"genericWaitStop" parameters provided' + ) + } + if (scripts.length === 0) { + logger.info(JSON.stringify(scripts)) + } + + const { exitCode, app } = await renderHTML({ + logger, + width: config.htmlRenderer.width ?? 1920, + height: config.htmlRenderer.height ?? 1080, + zoom: config.htmlRenderer.zoom ?? 1, + outputFolder: config.htmlRenderer.outputPath ?? '', + tempFolder: config.htmlRenderer.tempPath ?? 'tmp', + url, + scripts, + interactive, + }) + logger.info('Done, exiting...') + + app.exit(exitCode) +} + +main().catch((e) => { + console.error(e) + // eslint-disable-next-line no-process-exit + process.exit(1) +}) +function compact(array: (T | undefined | null | false)[]): T[] { + return array.filter(Boolean) as T[] +} diff --git a/apps/html-renderer/app/tsconfig.json b/apps/html-renderer/app/tsconfig.json new file mode 100644 index 00000000..0f202427 --- /dev/null +++ b/apps/html-renderer/app/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "outDir": "./dist" + } +} diff --git a/apps/html-renderer/packages/generic/.gitignore b/apps/html-renderer/packages/generic/.gitignore new file mode 100644 index 00000000..d0fa1c2f --- /dev/null +++ b/apps/html-renderer/packages/generic/.gitignore @@ -0,0 +1,3 @@ +ffmpeg.exe +ffprobe.exe +ffplay.exe diff --git a/apps/html-renderer/packages/generic/jest.config.js b/apps/html-renderer/packages/generic/jest.config.js new file mode 100644 index 00000000..132b326f --- /dev/null +++ b/apps/html-renderer/packages/generic/jest.config.js @@ -0,0 +1,7 @@ +const base = require('../../../../jest.config.base') +const packageJson = require('./package') + +module.exports = { + ...base, + displayName: packageJson.name, +} diff --git a/apps/html-renderer/packages/generic/package.json b/apps/html-renderer/packages/generic/package.json new file mode 100644 index 00000000..690ea6b7 --- /dev/null +++ b/apps/html-renderer/packages/generic/package.json @@ -0,0 +1,34 @@ +{ + "name": "@html-renderer/generic", + "version": "1.50.5", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "yarn rimraf dist && yarn build:main", + "build:main": "tsc -p tsconfig.json", + "__test": "jest" + }, + "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", + "engines": { + "node": ">=18" + }, + "lint-staged": { + "*.{js,css,json,md,scss}": [ + "prettier" + ], + "*.{ts,tsx}": [ + "eslint" + ] + }, + "peerDependencies": { + "@sofie-automation/shared-lib": "*" + }, + "dependencies": { + "@sofie-package-manager/api": "1.50.5", + "electron": "^30.0.6" + }, + "devDependencies": { + "rimraf": "^5.0.5" + } +} diff --git a/apps/html-renderer/packages/generic/src/__tests__/temp.spec.ts b/apps/html-renderer/packages/generic/src/__tests__/temp.spec.ts new file mode 100644 index 00000000..e62eae89 --- /dev/null +++ b/apps/html-renderer/packages/generic/src/__tests__/temp.spec.ts @@ -0,0 +1,6 @@ +describe('Temp', () => { + // This is just a placeholder, to be replaced with real tests later on + test('basic math', () => { + expect(1 + 1).toEqual(2) + }) +}) diff --git a/apps/html-renderer/packages/generic/src/index.ts b/apps/html-renderer/packages/generic/src/index.ts new file mode 100644 index 00000000..74162329 --- /dev/null +++ b/apps/html-renderer/packages/generic/src/index.ts @@ -0,0 +1 @@ +export { RenderHTMLOptions, renderHTML, InteractiveAPI } from './renderHTML' diff --git a/apps/html-renderer/packages/generic/src/renderHTML.ts b/apps/html-renderer/packages/generic/src/renderHTML.ts new file mode 100644 index 00000000..c25bda4d --- /dev/null +++ b/apps/html-renderer/packages/generic/src/renderHTML.ts @@ -0,0 +1,497 @@ +import { BrowserWindow, app, ipcMain } from 'electron' +import { spawn } from 'child_process' +import * as path from 'path' +import * as fs from 'fs' +import { LoggerInstance, escapeFilePath, getFFMpegExecutable, testFFMpeg } from '@sofie-package-manager/api' +import { sleep } from '@sofie-automation/shared-lib/dist/lib/lib' + +export interface RenderHTMLOptions { + logger: LoggerInstance + /** URL to the web page to render */ + url: string + /** Width of the window */ + width?: number + /** Height of the window */ + height?: number + /** Zoom factor */ + zoom?: number + /** Background color, default to black */ + backgroundColor?: string + + tempFolder?: string + outputFolder?: string + /** Scripts to execute */ + scripts: { + fcn?: (options: { webContents: Electron.WebContents }) => void | Promise + executeJs?: string + logInfo?: string + takeScreenshot?: { + /** PNG file */ + name: string + } + startRecording?: { + name: string + full?: boolean + cropped?: boolean + } + wait: number + }[] + userAgent?: string + /** Interactive mode, overrides scripts */ + interactive?: (api: InteractiveAPI) => Promise +} +export async function renderHTML(options: RenderHTMLOptions): Promise<{ + app: Electron.App + exitCode: number +}> { + try { + const testFFMpegResult = await testFFMpeg() + if (testFFMpegResult) { + throw new Error(`Cannot access FFMpeg executable: ${testFFMpegResult}`) + } + + await app.whenReady() + + const width = options.width || 1920 + const height = options.height || 1080 + const zoom = options.zoom || 1 + const tempFolder = options.tempFolder || 'tmp' + const outputFolder = options.outputFolder || '' + const logger = options.logger.category('RenderHTML') + + const win = new BrowserWindow({ + show: false, + alwaysOnTop: true, + webPreferences: { + // preload: join(__dirname, 'preload.js'), + nodeIntegration: false, + }, + height, + width, + }) + win.once('ready-to-show', () => { + // Wait for ready-to-show event to fire, + // otherwise zetZoomFactor will not work: + win.webContents.setZoomFactor(zoom) + }) + win.webContents.setAudioMuted(true) + if (options.userAgent) win.webContents.setUserAgent(options.userAgent) + + ipcMain.on('console', function (sender, type, args) { + logger.debug(`Electron: ${sender}, ${type}, ${args}`) + }) + // win.webContents.on('did-finish-load', (e: unknown) => log('did-finish-load', e)) + // win.webContents.on('did-fail-load', (e: unknown) => log('did-fail-load', e)) + // win.webContents.on('did-fail-provisional-load', (e: unknown) => log('did-fail-provisional-load', e)) + // win.webContents.on('did-frame-finish-load', (e: unknown) => log('did-frame-finish-load', e)) + // win.webContents.on('did-start-loading', (e: unknown) => log('did-start-loading', e)) + // win.webContents.on('did-stop-loading', (e: unknown) => log('did-stop-loading', e)) + // win.webContents.on('dom-ready', (e: unknown) => log('dom-ready', e)) + // win.webContents.on('page-favicon-updated', (e: unknown) => log('page-favicon-updated', e)) + // win.webContents.on('will-navigate', (e: unknown) => log('will-navigate', e)) + // win.webContents.on('plugin-crashed', (e: unknown) => log('plugin-crashed', e)) + // win.webContents.on('destroyed', (e: unknown) => log('destroyed', e)) + + let didFinishLoad = false + let didFailLoad: null | any = null + win.webContents.on('did-finish-load', () => (didFinishLoad = true)) + win.webContents.on('did-fail-load', (e) => { + didFailLoad = e + }) + + logger.verbose(`Loading URL: ${options.url}`) + win.loadURL(options.url).catch((_e) => { + // ignore, instead rely on 'did-finish-load' and 'did-fail-load' later + }) + // logger.verbose(`Loading done`) + + win.title = `HTML Renderer ${process.pid}` + + await win.webContents.insertCSS( + `html,body{ background-color: #${options.backgroundColor ?? '000000'} !important;}` + ) + + let exitCode = 0 + + let recording: { + fileName: string + tmpFolder: string + writeFilePromises: Promise[] + stopped: boolean + } | null = null + const api: InteractiveAPI = { + waitForLoad: async () => { + if (didFinishLoad) return + if (didFailLoad) throw new Error(`${didFailLoad}`) + else + await new Promise((resolve) => { + win.webContents.once('did-finish-load', () => resolve()) + }) + }, + takeScreenshot: async (fileName: string) => { + if (!fileName) throw new Error(`Invalid filename`) + + const image = await win.webContents.capturePage() + const filename = path.join(outputFolder, fileName) + + await fs.promises.mkdir(path.dirname(filename), { recursive: true }) + + if (fileName.endsWith('.png')) { + await fs.promises.writeFile(filename, image.toPNG()) + } else if (fileName.endsWith('.jpeg')) { + await fs.promises.writeFile(filename, image.toJPEG(90)) + } else { + throw new Error(`Unsupported file format: ${fileName}`) + } + return filename + }, + executeJs: async (js: string) => { + await win.webContents.executeJavaScript(js) + }, + startRecording: async (fileName: string, frameListener?: (frameIndex: number) => void) => { + if (recording?.stopped) { + await cleanupTemporaryFiles() + recording = null + } + if (recording) throw new Error(`Already recording`) + + const filename = path.join(outputFolder, fileName) + await fs.promises.mkdir(path.dirname(filename), { recursive: true }) + + let i = 0 + + const tmpFolder = path.resolve(path.join(tempFolder, `recording${process.pid}`)) + await fs.promises.mkdir(tmpFolder, { recursive: true }) + + recording = { + fileName: `${filename}`, + tmpFolder, + writeFilePromises: [], + stopped: false, + } + + win.webContents.beginFrameSubscription(false, (image) => { + if (!recording) throw new Error(`(internal error) received frame, but has no recording`) + i++ + frameListener?.(i) + + const buffer = image + .resize({ + width, + height, + }) + .toPNG() + + const tmpFile = path.join(tmpFolder, `img${pad(i, 5)}.png`) + recording.writeFilePromises.push(fs.promises.writeFile(tmpFile, buffer)) + }) + + return filename + }, + stopRecording: async () => { + if (!recording) throw new Error(`No current recording`) + if (recording.stopped) throw new Error(`Recording already stopped`) + recording.stopped = true + + win.webContents.endFrameSubscription() + + // Wait for all current file writes to finish: + await Promise.all(recording.writeFilePromises) + + let format: string + if (recording.fileName.endsWith('.webm')) { + format = 'webm' + } else if (recording.fileName.endsWith('.mp4')) { + format = 'mp4' + } else if (recording.fileName.endsWith('.mov')) { + format = 'mov' + } else { + throw new Error(`Unsupported file format: ${recording.fileName}`) + } + + // Convert the pngs to a video: + await ffmpeg(logger, [ + '-hide_banner', + '-y', + '-framerate', + '30', + '-s', + `${width}x${height}`, + '-i', + `${recording.tmpFolder}/img%05d.png`, + '-f', + format, // format: webm + '-an', // blocks all audio streams + '-c:v', + 'libvpx-vp9', // encoder for video (use VP9) + '-auto-alt-ref', + '1', + escapeFilePath(recording.fileName), + ]) + + await cleanupTemporaryFiles() + + return recording.fileName + }, + cropRecording: async (croppedFilename0: string) => { + if (!recording) throw new Error(`No recording`) + if (!recording.stopped) throw new Error(`Recording not stopped yet`) + + const croppedFilename = path.join(outputFolder, croppedFilename0) + await fs.promises.mkdir(path.dirname(croppedFilename), { recursive: true }) + + // Figure out the active bounding box + const boundingBox = { + x1: Infinity, + x2: -Infinity, + y1: Infinity, + y2: -Infinity, + } + await ffmpeg( + logger, + [ + '-hide_banner', + '-i', + escapeFilePath(recording.fileName), + '-vf', + 'bbox=min_val=50', + '-f', + 'null', + '-', + ], + { + onStderr: (data) => { + // [Parsed_bbox_0 @ 000002b6f5d474c0] n:25 pts:833 pts_time:0.833 x1:205 x2:236 y1:614 y2:650 w:32 h:37 crop=32:37:205:614 drawbox=205:614:32:37 + const m = data.match( + /Parsed_bbox.*x1:(?\d+).*x2:(?\d+).*y1:(?\d+).*y2:(?\d+)/ + ) + if (m && m.groups) { + boundingBox.x1 = Math.min(boundingBox.x1, parseInt(m.groups.x1, 10)) + boundingBox.x2 = Math.max(boundingBox.x2, parseInt(m.groups.x2, 10)) + boundingBox.y1 = Math.min(boundingBox.y1, parseInt(m.groups.y1, 10)) + boundingBox.y2 = Math.max(boundingBox.y2, parseInt(m.groups.y2, 10)) + } + }, + } + ) + + if ( + boundingBox.x1 === Infinity || + boundingBox.x2 === -Infinity || + boundingBox.y1 === Infinity || + boundingBox.y2 === -Infinity + ) { + logger.warn(`Could not determine bounding box`) + // Just copy the full video + await fs.promises.copyFile(recording.fileName, croppedFilename) + } else { + // Add margins: + boundingBox.x1 -= 10 + (boundingBox.x1 > width * 0.65 ? 10 : 0) + boundingBox.x2 += 10 + (boundingBox.x2 < width * 0.65 ? 10 : 0) + boundingBox.y1 -= 10 + (boundingBox.y1 > height * 0.65 ? 10 : 0) + boundingBox.y2 += 10 + (boundingBox.y2 < height * 0.65 ? 10 : 0) + + logger.verbose(`Saving cropped recording to ${croppedFilename}`) + // Generate a cropped video as well: + await ffmpeg(logger, [ + '-hide_banner', + '-y', + '-i', + escapeFilePath(recording.fileName), + '-filter:v', + `crop=${boundingBox.x2 - boundingBox.x1}:${boundingBox.y2 - boundingBox.y1}:${boundingBox.x1}:${ + boundingBox.y1 + }`, + escapeFilePath(croppedFilename), + ]) + logger.verbose(`Saved cropped recording`) + } + return croppedFilename + }, + } + const cleanupTemporaryFiles = async () => { + try { + if (recording) { + await fs.promises.rm(recording.tmpFolder, { recursive: true }) + } + // Look for old tmp files + const oldTmpFiles = await fs.promises.readdir(tempFolder) + for (const oldTmpFile of oldTmpFiles) { + const oldTmpFileath = path.join(tempFolder, oldTmpFile) + const stat = await fs.promises.stat(oldTmpFileath) + if (stat.ctimeMs < Date.now() - 60000) { + await fs.promises.rm(oldTmpFileath, { recursive: true }) + } + } + } catch (e) { + // Just log and continue... + // eslint-disable-next-line no-console + console.error(e) + } + } + if (options.interactive) { + await options.interactive(api) + } else { + await api.waitForLoad() + + const waitForScripts: Promise[] = [] + const maxDelay = Math.max(...options.scripts.map((s) => s.wait)) + const afterLoop: (() => void)[] = [] + for (const script of options.scripts) { + await sleep(script.wait) + + if (script.takeScreenshot) { + const fileName = await api.takeScreenshot(script.takeScreenshot.name) + logger.info(`Screenshot: ${fileName}`) + } + if (script.fcn) { + logger.verbose(`Executing fcn`) + await Promise.resolve(script.fcn({ webContents: win.webContents })) + } + if (script.executeJs) { + logger.verbose(`Executing js: ${script.executeJs}`) + await api.executeJs(script.executeJs) + } + if (script.logInfo) { + logger.info(script.logInfo) + } + + if (script.startRecording) { + let videoFilename = 'N/A' + const startRecording = async () => { + await new Promise((resolve, reject) => { + if (!script.startRecording) return + + const startTime = Date.now() + const idleFrameTime = Math.max(500, maxDelay) + let maxFrameIndex = 0 + const endRecording = () => { + logger.verbose(`Ending recording, got ${maxFrameIndex} frames`) + win.webContents.endFrameSubscription() + resolve() + } + + afterLoop.push(() => endRecording()) + + let endRecordingTimeout = setTimeout(() => { + endRecording() + }, idleFrameTime) + + api.startRecording(script.startRecording.name, (i) => { + // On Frame + maxFrameIndex = i + logger.verbose(`Frame ${i}, ${Date.now() - startTime}`) + + // End recording when idle + clearTimeout(endRecordingTimeout) + endRecordingTimeout = setTimeout(() => { + endRecording() + }, idleFrameTime) + }) + .then((fileName) => { + logger.verbose(`Start recording: ${fileName}`) + videoFilename = fileName + }) + .catch(reject) + }) + + logger.verbose(`Saving recording to ${videoFilename}`) + await api.stopRecording() + + try { + if (script.startRecording?.cropped) { + const croppedVideoFilename = `${videoFilename}-cropped.webm` + + await api.cropRecording(croppedVideoFilename) + + logger.info(`Cropped video: ${croppedVideoFilename}`) + } + + if (!script.startRecording?.full) { + await fs.promises.rm(videoFilename) + } else { + logger.info(`Video: ${videoFilename}`) + } + } catch (e) { + logger.error(`Aborting due to an error: ${e}`) + exitCode = 1 + } finally { + logger.verbose(`Removing temporary files...`) + if (!script.startRecording?.full) { + await fs.promises.rm(videoFilename) + } + } + } + waitForScripts.push(startRecording()) + } + } + // End of loop + afterLoop.forEach((fcn) => fcn()) + + await Promise.all(waitForScripts) + } + + win.close() + + return { + app: app, + exitCode, + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + return { + app: app, + exitCode: 1, + } + } +} +function pad(str: string | number, length: number, char = '0') { + str = str.toString() + while (str.length < length) { + str = char + str + } + return str +} +async function ffmpeg( + logger0: LoggerInstance, + args: string[], + options?: { + onStdout?: (data: string) => void + onStderr?: (data: string) => void + } +): Promise { + const logger = logger0.category('FFMpeg') + await new Promise((resolve, reject) => { + let logTrace = '' + const child = spawn(getFFMpegExecutable(), args, { + windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows + }) + + child.stdout.on('data', (data) => { + options?.onStdout?.(data.toString()) + logTrace += data.toString() + '\n' + logger.debug(data.toString()) + }) + child.stderr.on('data', (data) => { + options?.onStderr?.(data.toString()) + logTrace += data.toString() + '\n' + logger.debug(data.toString()) + }) + child.on('close', (code) => { + if (code !== 0) { + // eslint-disable-next-line no-console + console.error(logTrace) + reject(new Error(`ffmpeg process exited with code ${code}, args: ${args.join(' ')}`)) + } else resolve() + }) + }) +} +export type InteractiveAPI = { + waitForLoad: () => Promise + takeScreenshot: (fileName: string) => Promise + startRecording: (fileName: string, frameListener?: (frameIndex: number) => void) => Promise + stopRecording: () => Promise + cropRecording: (fileName: string) => Promise + executeJs: (js: string) => Promise +} diff --git a/apps/html-renderer/packages/generic/tsconfig.json b/apps/html-renderer/packages/generic/tsconfig.json new file mode 100644 index 00000000..fde612e6 --- /dev/null +++ b/apps/html-renderer/packages/generic/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "outDir": "./dist", + } +} diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts index 61738106..b0e1584b 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts @@ -14,6 +14,7 @@ import { ExpectationId, } from '@sofie-package-manager/api' import { + ExpectedPackageWrapHTMLTemplate, ExpectedPackageWrapJSONData, ExpectedPackageWrapMediaFile, ExpectedPackageWrapQuantel, @@ -401,7 +402,7 @@ export function generateMediaFileThumbnail( allowWaitForCPU: true, requiredForPlayout: false, usesCPUCount: 1, - removeDelay: 0, // The removal of the thumnail shouldn't be delayed + removeDelay: 0, // The removal of the thumbnail shouldn't be delayed removePackageOnUnFulfill: true, }, dependsOnFulfilled: [expectation.id], @@ -620,6 +621,74 @@ export function generateJsonDataCopy( } return exp } +export function generateHTMLRender( + managerId: ExpectationManagerId, + expWrap: ExpectedPackageWrap, + settings: PackageManagerSettings +): Expectation.RenderHTML { + const expWrapHTMLTemplate = expWrap as ExpectedPackageWrapHTMLTemplate + + const expectedPackage = expWrap.expectedPackage as ExpectedPackage.ExpectedPackageHtmlTemplate + + const endRequirement: Expectation.RenderHTML['endRequirement'] = { + targets: expWrapHTMLTemplate.targets as Expectation.SpecificPackageContainerOnPackage.FileTarget[], + content: { + prefix: expectedPackage.content.outputPrefix, + }, + version: { + ...expWrapHTMLTemplate.expectedPackage.version, + }, + } + if ( + endRequirement.version.renderer?.width === undefined && + endRequirement.version.renderer?.height === undefined && + endRequirement.version.renderer?.zoom === undefined + ) { + // Default: Render as thumbnails: + if (!endRequirement.version.renderer) endRequirement.version.renderer = {} + endRequirement.version.renderer.width = 1920 / 4 + endRequirement.version.renderer.height = 1080 / 4 + endRequirement.version.renderer.zoom = 1 / 4 + } + + const exp: Expectation.RenderHTML = { + id: protectString(hashObj(endRequirement)), + priority: expWrap.priority + PriorityAdditions.PREVIEW, + managerId: managerId, + fromPackages: [ + { + id: expWrap.expectedPackage._id, + expectedContentVersionHash: expWrap.expectedPackage.contentVersionHash, + }, + ], + type: Expectation.Type.RENDER_HTML, + statusReport: { + label: `Rendering HTML template`, + description: `Rendering HTML template "${expWrapHTMLTemplate.expectedPackage.content.path}"`, + displayRank: 11, + sendReport: !expWrap.external, + }, + + startRequirement: { + sources: expWrapHTMLTemplate.sources, + content: expWrapHTMLTemplate.expectedPackage.content, + version: { + type: Expectation.Version.Type.FILE_ON_DISK, + }, + }, + + endRequirement, + workOptions: { + allowWaitForCPU: true, + requiredForPlayout: false, + usesCPUCount: 1, + removeDelay: 0, // The removal of the thumbnail shouldn't be delayed + removePackageOnUnFulfill: true, + useTemporaryFilePath: settings.useTemporaryFilePath, + }, + } + return exp +} export function generatePackageCopyFileProxy( expectation: Expectation.FileCopy | Expectation.FileVerify | Expectation.QuantelClipCopy, diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts index bb75c739..631564ad 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations.ts @@ -27,6 +27,7 @@ import { generateJsonDataCopy, generatePackageCopyFileProxy, generatePackageLoudness, + generateHTMLRender, } from './expectations-lib' import { getSmartbullExpectedPackages, shouldBeIgnored } from './smartbull' import { TEMPORARY_STORAGE_ID } from './lib' @@ -126,6 +127,8 @@ function getBasicExpectations( exp = generateQuantelCopy(managerId, packageWrap) } else if (packageWrap.expectedPackage.type === ExpectedPackage.PackageType.JSON_DATA) { exp = generateJsonDataCopy(managerId, packageWrap, settings) + } else if (packageWrap.expectedPackage.type === ExpectedPackage.PackageType.HTML_TEMPLATE) { + exp = generateHTMLRender(managerId, packageWrap, settings) } if (exp) { results.push({ diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts index d8aa4986..11bcaad8 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/types.ts @@ -30,6 +30,14 @@ export interface ExpectedPackageWrapJSONData extends ExpectedPackageWrap { accessors: NonNullable }[] } +export interface ExpectedPackageWrapHTMLTemplate extends ExpectedPackageWrap { + expectedPackage: ExpectedPackage.ExpectedPackageHtmlTemplate + sources: { + containerId: PackageContainerId + label: string + accessors: NonNullable + }[] +} /* Notes on priorities: diff --git a/apps/single-app/app/expectedPackages.json b/apps/single-app/app/expectedPackages.json index 17266d50..1f5fc021 100644 --- a/apps/single-app/app/expectedPackages.json +++ b/apps/single-app/app/expectedPackages.json @@ -71,6 +71,18 @@ "allowWrite": true } } + }, + "theinternet": { + "label": "HTTP", + "accessors": { + "http": { + "type": "http", + "label": "http", + "baseUrl": "", + "allowRead": true, + "allowWrite": false + } + } } }, "expectedPackages": [ @@ -136,6 +148,61 @@ "core0" ], "sideEffect": {} + }, + { + "type": "html_template", + "_id": "test-html", + "contentVersionHash": "abc1234", + "content": { + "path": "https://www.bouncingdvdlogo.com/", + "outputPrefix": "" + }, + "version": { + "renderer": { + "width": 480, + "height": 320, + "zoom": 0.25 + }, + "steps": [ + { + "do": "waitForLoad" + }, + { + "do": "startRecording", + "fileName": "bouncingdvdlogo_recording.webm" + }, + { + "do": "sleep", + "duration": 1000 + }, + { + "do": "takeScreenshot", + "fileName": "bouncingdvdlogo_screenshot.png" + }, + { + "do": "sleep", + "duration": 4000 + }, + { + "do": "stopRecording" + } + ] + }, + "sources": [ + { + "containerId": "theinternet", + "accessors": { + "http": { + "type": "http", + "url": "" + } + } + } + ], + "layers": [ + "thumbnails0_local" + ], + "sideEffect": {} } ] -} \ No newline at end of file +} diff --git a/scripts/build-win32.mjs b/scripts/build-win32.mjs index caf5bb3a..c764df8d 100644 --- a/scripts/build-win32.mjs +++ b/scripts/build-win32.mjs @@ -65,7 +65,7 @@ if (!executableName) { ps = [] // Remove things that arent used, to reduce file size: - log(`Remove unused files...`) + log(`Remove unused files before build...`) const copiedFiles = [ ...(await glob(`${basePath}/node_modules/@*/app/*`)), ...(await glob(`${basePath}/node_modules/@*/generic/*`)), diff --git a/scripts/gather-all-built.mjs b/scripts/gather-all-built.mjs index 9eb1016f..c6492ea9 100644 --- a/scripts/gather-all-built.mjs +++ b/scripts/gather-all-built.mjs @@ -11,14 +11,14 @@ into a single folder, for convenience const targetFolder = 'deploy/' -// await rimraf(targetFolder) + await fse.mkdirp(targetFolder) // clear folder: const files = await fse.readdir(targetFolder) for (const file of files) { if ( // Only match executables: - file.match(/.*\.exe$/) && + file.match(/.*\.(exe|zip)$/) && // Leave the ffmpeg / ffprobe files: !file.match(/ffmpeg|ffprobe/) ) { @@ -32,7 +32,17 @@ for (const deployfolder of deployfolders) { if (deployfolder.match(/boilerplate/)) continue console.log(`Copying: ${deployfolder}`) - await fse.copy(deployfolder, targetFolder) + await fse.copy(deployfolder, targetFolder, { + filter: (src, _dest) => { + const m = ( + // Is executable or zip + src.match(/.*\.(exe|zip)$/) || + // Is the deploy folder + src.endsWith('deploy') + ) + return !!m + } + }) } console.log(`All files have been copied to: ${targetFolder}`) diff --git a/shared/packages/api/src/config.ts b/shared/packages/api/src/config.ts index 8652dbf0..7e214e7d 100644 --- a/shared/packages/api/src/config.ts +++ b/shared/packages/api/src/config.ts @@ -11,7 +11,7 @@ import { AppContainerId, WorkerAgentId } from './ids' */ /** Generic CLI-argument-definitions for any process */ -const processOptions = defineArguments({ +export const processOptions = defineArguments({ logPath: { type: 'string', describe: 'Set to write logs to this file' }, logLevel: { type: 'string', describe: 'Set default log level. (Might be overwritten by Sofie Core)' }, @@ -293,12 +293,12 @@ export interface ProcessConfig { /** Paths to certificates to load, for SSL-connections */ certificates: string[] } -function getProcessConfig(argv: { +export function getProcessConfig(argv: { logPath: string | undefined logLevel: string | undefined unsafeSSL: boolean certificates: string | undefined -}) { +}): ProcessConfig { const certs: string[] = (argv.certificates || process.env.CERTIFICATES || '').split(';') || [] return { logPath: argv.logPath, @@ -583,11 +583,11 @@ export async function getQuantelHTTPTransformerProxyConfig(): Promise(opts: O): O { +export function defineArguments(opts: O): O { return opts } -function getProcessArgv() { +export function getProcessArgv(): string[] { // Note: process.argv typically looks like this: // [ // 'C:\\Program Files\\nodejs\\node.exe', diff --git a/shared/packages/api/src/expectationApi.ts b/shared/packages/api/src/expectationApi.ts index fb78386e..e27bb8ee 100644 --- a/shared/packages/api/src/expectationApi.ts +++ b/shared/packages/api/src/expectationApi.ts @@ -26,6 +26,7 @@ export namespace Expectation { | QuantelClipPreview | JsonDataCopy | FileVerify + | RenderHTML /** Defines the Expectation type, used to separate the different Expectations */ export enum Type { @@ -34,6 +35,7 @@ export namespace Expectation { MEDIA_FILE_THUMBNAIL = 'media_file_thumbnail', MEDIA_FILE_PREVIEW = 'media_file_preview', FILE_VERIFY = 'file_verify', + RENDER_HTML = 'render_html', PACKAGE_SCAN = 'package_scan', PACKAGE_DEEP_SCAN = 'package_deep_scan', @@ -84,7 +86,7 @@ export namespace Expectation { version: any } /** Contains info that can be used during work on an expectation. Changes in this does NOT cause an invalidation of the expectation. */ - workOptions: WorkOptions.Base + workOptions: WorkOptions.Base & WorkOptions.RemoveDelay & WorkOptions.UseTemporaryFilePath /** Reference to another expectation. * Won't start until ALL other expectations are fulfilled. * If any of the other expectations are not fulfilled, this wont be fulfilled either. @@ -310,7 +312,7 @@ export namespace Expectation { type: Type.JSON_DATA_COPY startRequirement: { - sources: SpecificPackageContainerOnPackage.JSONDataSource[] + sources: SpecificPackageContainerOnPackage.FileSource[] } endRequirement: { targets: SpecificPackageContainerOnPackage.JSONDataTarget[] @@ -331,6 +333,75 @@ export namespace Expectation { } endRequirement: FileCopy['endRequirement'] } + /** Defines a "Verify File". Doesn't really do any work, just checks that the File exists at the Target. */ + export interface RenderHTML extends Base { + type: Type.RENDER_HTML + + startRequirement: { + sources: SpecificPackageContainerOnPackage.HTMLFileSource[] + content: { + path: string + } + version: Version.ExpectedFileOnDisk + } + endRequirement: { + targets: SpecificPackageContainerOnPackage.FileTarget[] + content: { + /** Prefix of output files */ + prefix?: string + } + version: { + renderer?: { + width?: number + height?: number + zoom?: number + /** (defaults to black) */ + backgroundColor?: string + userAgent?: string + } + + /** + * Convenience settings for a template that follows the typical CasparCG steps; + * update(data); play(); stop(); + * If this is set, steps are overridden */ + casparCG?: { + /** + * Data to send into the update() function of a CasparCG Template. + * Strings will be piped through as-is, objects will be JSON.stringified. + */ + data: { [key: string]: any } | null | string + + /** How long to wait between each action in a CasparCG template, (default: 1000ms) */ + delay?: number + } + + steps?: ( + | { do: 'waitForLoad' } + | { do: 'sleep'; duration: number } + | { + do: 'sendHTTPCommand' + url: string + /** GET, POST, PUT etc.. */ + method: string + body?: ArrayBuffer | ArrayBufferView | NodeJS.ReadableStream | string | URLSearchParams + + headers?: Record + } + | { do: 'takeScreenshot'; fileName: string } + | { do: 'startRecording'; fileName: string } + | { do: 'stopRecording' } + | { do: 'cropRecording'; fileName: string } + | { do: 'executeJs'; js: string } + // Store an object in memory + | { do: 'storeObject'; key: string; value: Record } + // Modify an object in memory. Path is a dot-separated string + | { do: 'modifyObject'; key: string; path: string; value: any } + // Send an object to the renderer as a postMessage + | { do: 'injectObject'; key: string } + )[] + } + } + } /** Contains definitions of specific PackageContainer types, used in the Expectation-definitions */ // eslint-disable-next-line @typescript-eslint/no-namespace @@ -389,6 +460,17 @@ export namespace Expectation { | AccessorOnPackage.CorePackageCollection } } + + /** Defines a PackageContainer for reading a HTML file. */ + export interface HTMLFileSource extends PackageContainerOnPackage { + accessors: { + [accessorId: string]: + | AccessorOnPackage.LocalFolder + | AccessorOnPackage.FileShare + | AccessorOnPackage.HTTP + | AccessorOnPackage.HTTPProxy + } + } } // eslint-disable-next-line @typescript-eslint/no-namespace diff --git a/shared/packages/api/src/ffmpeg.ts b/shared/packages/api/src/ffmpeg.ts new file mode 100644 index 00000000..59b27b33 --- /dev/null +++ b/shared/packages/api/src/ffmpeg.ts @@ -0,0 +1,67 @@ +import { spawn } from 'child_process' +import { stringifyError } from './lib' + +export interface OverriddenFFMpegExecutables { + ffmpeg: string + ffprobe: string +} + +let overriddenFFMpegPaths: OverriddenFFMpegExecutables | null = null +/** + * Override the paths of the ffmpeg executables, intended for unit testing purposes + * @param paths Paths to executables + */ +export function overrideFFMpegExecutables(paths: OverriddenFFMpegExecutables | null): void { + overriddenFFMpegPaths = paths +} + +export interface FFMpegProcess { + pid: number + cancel: () => void +} +export function getFFMpegExecutable(): string { + if (overriddenFFMpegPaths) return overriddenFFMpegPaths.ffmpeg + return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' +} +export function getFFProbeExecutable(): string { + if (overriddenFFMpegPaths) return overriddenFFMpegPaths.ffprobe + return process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe' +} +/** Check if FFMpeg is available, returns null if no error found */ +export async function testFFMpeg(): Promise { + return testFFExecutable(getFFMpegExecutable()) +} +/** Check if FFProbe is available */ +export async function testFFProbe(): Promise { + return testFFExecutable(getFFProbeExecutable()) +} +export async function testFFExecutable(ffExecutable: string): Promise { + return new Promise((resolve) => { + const ffMpegProcess = spawn(ffExecutable, ['-version']) + let output = '' + ffMpegProcess.stderr.on('data', (data) => { + const str = data.toString() + output += str + }) + ffMpegProcess.stdout.on('data', (data) => { + const str = data.toString() + output += str + }) + ffMpegProcess.on('error', (err) => { + resolve(`Process ${ffExecutable} emitted error: ${stringifyError(err)}`) + }) + ffMpegProcess.on('exit', (code) => { + const m = output.match(/version ([\w-]+)/) // version N-102494-g2899fb61d2 + + if (code === 0) { + if (m) { + resolve(null) + } else { + resolve(`Process ${ffExecutable} bad version: ${output}`) + } + } else { + resolve(`Process ${ffExecutable} exited with code ${code}`) + } + }) + }) +} diff --git a/shared/packages/api/src/htmlRenderer.ts b/shared/packages/api/src/htmlRenderer.ts new file mode 100644 index 00000000..4b0d2e60 --- /dev/null +++ b/shared/packages/api/src/htmlRenderer.ts @@ -0,0 +1,114 @@ +import { spawn } from 'child_process' +import * as fs from 'fs/promises' +import * as path from 'path' +import { stringifyError } from './lib' + +let overriddenHTMLRendererPath: string | null = null +/** + * Override the paths of the html-renderer executables, intended for unit testing purposes + * @param paths Paths to executables + */ +export function overrideHTMLRendererExecutables(overridePath: string | null): void { + overriddenHTMLRendererPath = overridePath +} + +export interface HTMLRendererProcess { + pid: number + cancel: () => void +} +let htmlRenderExecutable = 'N/A' // Is set when testHtmlRenderer is run +export function getHtmlRendererExecutable(): string { + if (overriddenHTMLRendererPath) return overriddenHTMLRendererPath + return htmlRenderExecutable +} +/** Check if HTML-Renderer is available, returns null if no error found */ +export async function testHtmlRenderer(): Promise { + if (htmlRenderExecutable === 'N/A') { + let alternatives: string[] + if (process.platform === 'win32') { + alternatives = [ + 'html-renderer.exe', + path.resolve('html-renderer/html-renderer.exe'), + path.resolve('../html-renderer/html-renderer.exe'), + path.resolve('../../html-renderer/app/deploy/html-renderer/html-renderer.exe'), + ] + } else { + alternatives = [ + 'html-renderer', + path.resolve('html-renderer/html-renderer'), + path.resolve('../html-renderer'), + path.resolve('../html-renderer/html-renderer'), + path.resolve('../../html-renderer/app/deploy/html-renderer/html-renderer'), + ] + } + + for (const alternative of alternatives) { + // check if it exists + try { + await fs.access(alternative, fs.constants.X_OK) + } catch { + continue + } + // If it exists, use that path: + htmlRenderExecutable = alternative + } + if (htmlRenderExecutable === 'N/A') { + return `Not able to find any HTML-Renderer executable, tried: ${alternatives.join(', ')}` + } + } + + return testExecutable(getHtmlRendererExecutable()) +} + +export async function testExecutable(executable: string): Promise { + return new Promise((resolve) => { + const htmlRendererProcess = spawn(executable, ['--', '--test=true']) + let output = '' + htmlRendererProcess.stderr.on('data', (data) => { + const str = data.toString() + output += str + }) + htmlRendererProcess.stdout.on('data', (data) => { + const str = data.toString() + output += str + }) + htmlRendererProcess.on('error', (err) => { + resolve(`Process ${executable} emitted error: ${stringifyError(err)}`) + }) + htmlRendererProcess.on('exit', (code) => { + const m = output.match(/Version: ([\w.]+)/i) // Version 1.50.1 + + if (code === 0) { + if (m) { + resolve(null) + } else { + resolve(`Process ${executable} bad version: "${output}"`) + } + } else { + resolve(`Process ${executable} exited with code ${code}`) + } + }) + }) +} + +/** Messages sent from HTMLRenderer process over stdout */ +export type InteractiveStdOut = { status: 'listening'; port: number } + +/** Messages sent into HTMLRenderer process over websocket */ +export type InteractiveMessage = + | { do: 'waitForLoad' } + | { do: 'takeScreenshot'; fileName: string } + | { do: 'startRecording'; fileName: string } + | { do: 'stopRecording' } + | { do: 'cropRecording'; fileName: string } + | { do: 'executeJs'; js: string } + | { do: 'close' } +/** Messages sent from HTMLRenderer process over websocket */ +export type InteractiveReply = + | { reply: 'unsupported'; error: string } + | { reply: 'waitForLoad'; error?: string } + | { reply: 'takeScreenshot'; error?: string } + | { reply: 'startRecording'; error?: string } + | { reply: 'stopRecording'; error?: string } + | { reply: 'cropRecording'; error?: string } + | { reply: 'executeJs'; error?: string } diff --git a/shared/packages/api/src/index.ts b/shared/packages/api/src/index.ts index b0ebe221..13c83872 100644 --- a/shared/packages/api/src/index.ts +++ b/shared/packages/api/src/index.ts @@ -2,7 +2,9 @@ export * from './adapterClient' export * from './adapterServer' export * from './appContainer' export * from './config' +export * from './ffmpeg' export * from './filePath' +export * from './htmlRenderer' export * from './expectationApi' export * from './inputApi' export * from './HelpfulEventEmitter' @@ -12,6 +14,7 @@ export * from './lib' export * from './logger' export * from './methods' export * from './packageContainerApi' +export * from './pathJoin' export * from './process' export * from './status' export * from './statusReport' diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index 207fa174..af9f8c99 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -28,12 +28,17 @@ export const StatusCode = SofieStatusCode // eslint-disable-next-line @typescript-eslint/no-namespace export namespace ExpectedPackage { - export type Any = ExpectedPackageMediaFile | ExpectedPackageQuantelClip | ExpectedPackageJSONData + export type Any = + | ExpectedPackageMediaFile + | ExpectedPackageQuantelClip + | ExpectedPackageJSONData + | ExpectedPackageHtmlTemplate export enum PackageType { MEDIA_FILE = 'media_file', QUANTEL_CLIP = 'quantel_clip', JSON_DATA = 'json_data', + HTML_TEMPLATE = 'html_template', // TALLY_LABEL = 'tally_label' @@ -191,6 +196,63 @@ export namespace ExpectedPackage { } }[] } + export interface ExpectedPackageHtmlTemplate extends Base { + type: PackageType.HTML_TEMPLATE + content: { + /** path to the HTML template */ + path: string + /** Add prefix to output artifacts */ + outputPrefix: string + } + version: { + renderer?: { + /** Renderer width, defaults to 1920 */ + width?: number + /** Renderer width, defaults to 1080 */ + height?: number + /** Zoom level, defaults to 1 */ + zoom?: number + /** (defaults to black) */ + backgroundColor?: string + userAgent?: string + } + + /** + * Convenience settings for a template that follows the typical CasparCG steps; + * update(data); play(); stop(); + * If this is set, steps are overridden */ + casparCG?: { + /** + * Data to send into the update() function of a CasparCG Template. + * Strings will be piped through as-is, objects will be JSON.stringified. + */ + data: { [key: string]: any } | null | string + + /** How long to wait between each action in a CasparCG template, (default: 1000ms) */ + delay?: number + } + + steps?: ( + | { do: 'waitForLoad' } + | { do: 'sleep'; duration: number } + | { do: 'takeScreenshot'; fileName: string } + | { do: 'startRecording'; fileName: string } + | { do: 'stopRecording' } + | { do: 'cropRecording'; fileName: string } + | { do: 'executeJs'; js: string } + )[] + } + sources: { + containerId: PackageContainerId + accessors: { + [accessorId: AccessorId]: + | AccessorOnPackage.LocalFolder + | AccessorOnPackage.FileShare + | AccessorOnPackage.HTTP + | AccessorOnPackage.HTTPProxy + } + }[] + } } /** A PackageContainer defines a place that contains Packages, that can be read or written to. diff --git a/shared/packages/worker/package.json b/shared/packages/worker/package.json index 018f3d16..7cac676d 100644 --- a/shared/packages/worker/package.json +++ b/shared/packages/worker/package.json @@ -13,13 +13,15 @@ "node": ">=18" }, "peerDependencies": { - "@sofie-automation/shared-lib": "*" + "@sofie-automation/shared-lib": "*", + "ws": "*" }, "devDependencies": { "@types/deep-diff": "^1.0.0", "@types/node-fetch": "^2.5.8", "@types/proper-lockfile": "^4.1.4", "@types/tmp": "~0.2.2", + "@types/ws": "^8.5.4", "jest": "*", "jest-mock-extended": "^3.0.5", "rimraf": "^5.0.5" diff --git a/shared/packages/worker/src/worker/accessorHandlers/lib/__tests__/pathJoin.spec.ts b/shared/packages/worker/src/worker/accessorHandlers/lib/__tests__/pathJoin.spec.ts deleted file mode 100644 index 7cd6eab3..00000000 --- a/shared/packages/worker/src/worker/accessorHandlers/lib/__tests__/pathJoin.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { rebaseUrl } from '../pathJoin' -test('rebaseUrl', () => { - expect(rebaseUrl('https://a', 'b')).toBe('https://a/b') - expect(rebaseUrl('https://a/', 'b')).toBe('https://a/b') - expect(rebaseUrl('https://a/', '/b')).toBe('https://a/b') - expect(rebaseUrl('file://a/', '/b/')).toBe('file://a/b/') - expect(rebaseUrl('https:////a/b/', 'c')).toBe('https://a/b/c') - expect(rebaseUrl('https://a/', '//b/')).toBe('https://a/b/') - - expect(rebaseUrl('https://a/b//c/', '/d/e//f/')).toBe('https://a/b/c/d/e/f/') -}) diff --git a/shared/packages/worker/src/worker/accessorHandlers/lib/pathJoin.ts b/shared/packages/worker/src/worker/accessorHandlers/lib/pathJoin.ts deleted file mode 100644 index 8ddb5443..00000000 --- a/shared/packages/worker/src/worker/accessorHandlers/lib/pathJoin.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function removeBasePath(basePath: string, addPath: string): string { - addPath = addPath.replace(/\\/g, '/') - basePath = basePath.replace(/\\/g, '/') - - return addPath.replace(new RegExp('^' + escapeRegExp(basePath)), '') -} -function escapeRegExp(text: string): string { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') -} -export function rebaseUrl(baseUrl: string, relativeUrl: string): string { - const base = new URL(baseUrl) - const relative = new URL(baseUrl) - // relative path may contain URL-unsafe characters, this will encode them but leave any path elements intact - relative.pathname = relativeUrl - // at this point, relativeUrl.pathname will already include the leading `/` - base.pathname = base.pathname + relative.pathname - base.pathname = base.pathname.replace(/\/{2,}/g, '/') // Remove double slashes - return base.toString() -} diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts new file mode 100644 index 00000000..6c2b5beb --- /dev/null +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts @@ -0,0 +1,981 @@ +import { ChildProcessWithoutNullStreams, spawn } from 'child_process' +import * as fs from 'fs/promises' +import * as path from 'path' +import WebSocket from 'ws' +import { BaseWorker } from '../../../worker' +import { UniversalVersion, getStandardCost, makeUniversalVersion } from '../lib/lib' +import { + Accessor, + Expectation, + ReturnTypeDoYouSupportExpectation, + ReturnTypeGetCostFortExpectation, + ReturnTypeIsExpectationFulfilled, + ReturnTypeIsExpectationReadyToStartWorkingOn, + ReturnTypeRemoveExpectation, + stringifyError, + AccessorId, + startTimer, + hashObj, + getHtmlRendererExecutable, + assertNever, + hash, + protectString, + InteractiveStdOut, + InteractiveReply, + InteractiveMessage, + literal, +} from '@sofie-package-manager/api' + +import { IWorkInProgress, WorkInProgress } from '../../../lib/workInProgress' +import { checkWorkerHasAccessToPackageContainersOnPackage, lookupAccessorHandles, LookupPackageContainer } from './lib' +import { isFileFulfilled, isFileReadyToStartWorkingOn } from './lib/file' +import { ExpectationHandlerGenericWorker, GenericWorker } from '../genericWorker' +import { + isFileShareAccessorHandle, + isHTTPAccessorHandle, + isHTTPProxyAccessorHandle, + isLocalFolderAccessorHandle, +} from '../../../accessorHandlers/accessor' +import { LocalFolderAccessorHandle } from '../../../accessorHandlers/localFolder' +import { PackageReadStream, PutPackageHandler } from '../../../accessorHandlers/genericHandle' +import { ByteCounter } from '../../../lib/streamByteCounter' +import { fetchWithTimeout } from '../../../accessorHandlers/lib/fetch' + +/** + * Copies a file from one of the sources and into the target PackageContainer + */ +export const RenderHTML: ExpectationHandlerGenericWorker = { + doYouSupportExpectation(exp: Expectation.Any, genericWorker: GenericWorker): ReturnTypeDoYouSupportExpectation { + if (genericWorker.testHTMLRenderer) + return { + support: false, + reason: { + user: 'There is an issue with the Worker (HTMLRenderer)', + tech: `Cannot access HTMLRenderer executable: ${genericWorker.testHTMLRenderer}`, + }, + } + return checkWorkerHasAccessToPackageContainersOnPackage(genericWorker, { + sources: exp.startRequirement.sources, + targets: exp.endRequirement.targets, + }) + }, + getCostForExpectation: async ( + exp: Expectation.Any, + worker: BaseWorker + ): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + return getStandardCost(exp, worker) + }, + isExpectationReadyToStartWorkingOn: async ( + exp: Expectation.Any, + worker: BaseWorker + ): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + + const steps = getSteps(exp) + const { mainFileName } = getFileNames(steps) + if (!mainFileName) + return { + ready: false, + reason: { + user: 'No output (this is a configuration issue)', + tech: `No output filename (${steps.length} steps)`, + }, + } + + const lookupSource = await lookupSources(worker, exp) + const lookupTarget = await lookupTargets(worker, exp, mainFileName) + + return isFileReadyToStartWorkingOn(worker, lookupSource, lookupTarget) + }, + isExpectationFulfilled: async ( + exp: Expectation.Any, + _wasFulfilled: boolean, + worker: BaseWorker + ): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + + const steps = getSteps(exp) + const { fileNames, mainFileName } = getFileNames(steps) + if (!mainFileName) + return { + fulfilled: false, + reason: { + user: 'No output (this is a configuration issue)', + tech: `No output filename (${steps.length} steps)`, + }, + } + const lookupSource = await lookupSources(worker, exp) + + // First, check metadata + const mainLookupTarget = await lookupTargets(worker, exp, mainFileName) + + // Do a full check on the main file: + const mainFulfilledStatus = await isFileFulfilled(worker, lookupSource, mainLookupTarget) + if (!mainFulfilledStatus.fulfilled) return mainFulfilledStatus + + // Go through the other files, just check that they exist: + for (const fileName of fileNames) { + if (fileName === mainFileName) continue // already checked + + const lookupTarget = await lookupTargets(worker, exp, fileName) + + if (!lookupTarget.ready) + return { + fulfilled: false, + reason: { + user: `Not able to access target, due to: ${lookupTarget.reason.user} `, + tech: `Not able to access target: ${lookupTarget.reason.tech}`, + }, + } + + const issuePackage = await lookupTarget.handle.checkPackageReadAccess() + if (!issuePackage.success) { + return { + fulfilled: false, + reason: { + user: `Target package: ${issuePackage.reason.user}`, + tech: `Target package: ${issuePackage.reason.tech}`, + }, + } + } + } + return { + fulfilled: true, + } + }, + workOnExpectation: async (exp: Expectation.Any, worker: BaseWorker): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + // Render the HTML file + + /* + * What this one does is: + * 1. Spin up the HtmlRenderer executable and send it commands + to render the HTML file, take screenshots, record video etc, + according to the steps defined in the expectation. + 2. The output from HtmlRenderer executable are files in a temporary folder. + 3. Copy the files from the temporary folder to the target PackageContainer. + 4. Clean up the temporary files. + */ + const htmlRenderHandler = new HTMLRenderHandler(exp, worker) + + const lookupSource = await lookupSources(worker, exp) + if (!lookupSource.ready) throw new Error(`Can't start working due to source: ${lookupSource.reason.tech}`) + + const mainLookupTarget = await lookupTargets(worker, exp, htmlRenderHandler.mainFileName) + if (!mainLookupTarget.ready) + throw new Error(`Can't start working due to target: ${mainLookupTarget.reason.tech}`) + + const sourceHandle = lookupSource.handle + const mainTargetHandle = mainLookupTarget.handle + + if ( + (lookupSource.accessor.type === Accessor.AccessType.LOCAL_FOLDER || + lookupSource.accessor.type === Accessor.AccessType.FILE_SHARE || + lookupSource.accessor.type === Accessor.AccessType.HTTP || + lookupSource.accessor.type === Accessor.AccessType.HTTP_PROXY) && + (mainLookupTarget.accessor.type === Accessor.AccessType.LOCAL_FOLDER || + mainLookupTarget.accessor.type === Accessor.AccessType.FILE_SHARE || + mainLookupTarget.accessor.type === Accessor.AccessType.HTTP_PROXY) + ) { + if ( + !isLocalFolderAccessorHandle(sourceHandle) && + !isFileShareAccessorHandle(sourceHandle) && + !isHTTPAccessorHandle(sourceHandle) && + !isHTTPProxyAccessorHandle(sourceHandle) + ) + throw new Error(`Source AccessHandler type is wrong`) + if ( + !isLocalFolderAccessorHandle(mainTargetHandle) && + !isFileShareAccessorHandle(mainTargetHandle) && + !isHTTPProxyAccessorHandle(mainTargetHandle) + ) + throw new Error(`Target AccessHandler type is wrong`) + + let url: string + if (isLocalFolderAccessorHandle(sourceHandle)) { + url = `file://${sourceHandle.fullPath}` + } else if (isFileShareAccessorHandle(sourceHandle)) { + url = `file://${sourceHandle.fullPath}` + } else if (isHTTPAccessorHandle(sourceHandle)) { + url = `${sourceHandle.fullUrl}` + } else if (isHTTPProxyAccessorHandle(sourceHandle)) { + url = `${sourceHandle.fullUrl}` + } else { + assertNever(sourceHandle) + throw new Error(`Unsupported Source AccessHandler`) + } + + const workInProgress = new WorkInProgress({ workLabel: `Generating preview of "${url}"` }, async () => { + // On cancel + htmlRenderHandler.cancel() + }).do(async () => { + await lookupSource.handle.getPackageActualVersion() + + await htmlRenderHandler.run({ + workInProgress, + lookupSource, + mainLookupTarget, + url, + }) + }) + + return workInProgress + } else { + throw new Error( + `RenderHTML.workOnExpectation: Unsupported accessor source-target pair "${lookupSource.accessor.type}"-"${mainLookupTarget.accessor.type}"` + ) + } + }, + removeExpectation: async (exp: Expectation.Any, worker: BaseWorker): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + // Remove the files on the location + + const steps = getSteps(exp) + const { fileNames, mainFileName } = getFileNames(steps) + if (!mainFileName) + throw new Error( + `Can't start working due to no mainFileName (${steps.length} steps). This is a configuration issue.` + ) + + const lookupSource = await lookupSources(worker, exp) + if (!lookupSource.ready) throw new Error(`Can't start working due to source: ${lookupSource.reason.tech}`) + + const mainLookupTarget = await lookupTargets(worker, exp, mainFileName) + if (!mainLookupTarget.ready) { + return { + removed: false, + reason: { + user: `Can't access target, due to: ${mainLookupTarget.reason.user}`, + tech: `No access to target: ${mainLookupTarget.reason.tech}`, + }, + } + } + + for (const fileName of fileNames) { + if (fileName === mainFileName) continue // remove this last + const lookupTarget = await lookupTargets(worker, exp, fileName) + if (!lookupTarget.ready) { + throw new Error(`Cannot remove files due to target: ${lookupTarget.reason.tech}`) + } + + try { + await lookupTarget.handle.removePackage('expectation removed') + } catch (err) { + return { + removed: false, + reason: { + user: `Cannot remove file due to an internal error`, + tech: `Cannot remove file: ${stringifyError(err)}`, + }, + } + } + } + // Remove the main one last: + try { + await mainLookupTarget.handle.removePackage('expectation removed') + } catch (err) { + return { + removed: false, + reason: { + user: `Cannot remove file due to an internal error`, + tech: `Cannot remove file: ${stringifyError(err)}`, + }, + } + } + + return { + removed: true, + // reason: `Removed file "${exp.endRequirement.content.path}" from target` + } + }, +} +function isHTMLRender(exp: Expectation.Any): exp is Expectation.RenderHTML { + return exp.type === Expectation.Type.RENDER_HTML +} + +async function lookupSources( + worker: BaseWorker, + exp: Expectation.RenderHTML +): Promise> { + return lookupAccessorHandles( + worker, + exp.startRequirement.sources, + exp.startRequirement.content, + exp.workOptions, + { + read: true, + readPackage: true, + packageVersion: exp.startRequirement.version, + } + ) +} +async function lookupTargets( + worker: BaseWorker, + exp: Expectation.RenderHTML, + filePath: string +): Promise> { + return lookupAccessorHandles( + worker, + exp.endRequirement.targets, + { + filePath, + }, + exp.workOptions, + { + write: true, + writePackageContainer: true, + } + ) +} +type Steps = Required['steps'] +function getSteps(exp: Expectation.RenderHTML): Steps { + let steps: Steps + if (exp.endRequirement.version.casparCG) { + // Generate a set of steps for standard CasparCG templates + const casparData = exp.endRequirement.version.casparCG.data + const casparDataJSON = typeof casparData === 'string' ? casparData : JSON.stringify(casparData) + steps = [ + { do: 'waitForLoad' }, + { do: 'takeScreenshot', fileName: 'idle.png' }, + { do: 'startRecording', fileName: 'preview.webm' }, + { do: 'executeJs', js: `update(${casparDataJSON})` }, + { do: 'executeJs', js: `play()` }, + { do: 'sleep', duration: 1000 }, + { do: 'takeScreenshot', fileName: 'play.png' }, + { do: 'executeJs', js: `stop()` }, + { do: 'sleep', duration: 1000 }, + { do: 'takeScreenshot', fileName: 'stop.png' }, + { do: 'stopRecording' }, + { do: 'cropRecording', fileName: 'preview-cropped.webm' }, + ] + } else { + steps = exp.endRequirement.version.steps || [] + } + // Add prefix to steps fileName: + return steps.map((org) => { + const step = { ...org } + if ('fileName' in step) { + step.fileName = `${exp.endRequirement.content.prefix ?? ''}${step.fileName}` + } + return step + }) +} +function getFileNames(steps: Steps) { + const fileNames: string[] = [] + let mainFileName: string | undefined = undefined + for (const step of steps) { + if (step.do === 'takeScreenshot') { + fileNames.push(step.fileName) + if (!mainFileName) mainFileName = step.fileName + } else if (step.do === 'startRecording') { + fileNames.push(step.fileName) + mainFileName = step.fileName + } else if (step.do === 'cropRecording') { + fileNames.push(step.fileName) + } + } + return { fileNames, mainFileName } +} +function compact(array: (T | undefined | null | false)[]): T[] { + return array.filter(Boolean) as T[] +} +async function unlinkIfExists(path: string) { + try { + await fs.unlink(path) + } catch { + // ignore errors + } +} + +class HTMLRenderHandler { + public readonly mainFileName: string + + private wasCancelled = false + private sourceStream: PackageReadStream | undefined = undefined + private writeStream: PutPackageHandler | undefined = undefined + private steps: Steps + private fileNames: string[] + private timer: { get: () => number } + private htmlRenderer: HTMLRenderer | undefined + + private outputPath: string + private executeSteps: { step: Steps[number]; duration: number }[] + private outputFileNames: string[] + + constructor(public readonly exp: Expectation.RenderHTML, public readonly worker: BaseWorker) { + this.steps = getSteps(exp) + const f = getFileNames(this.steps) + if (!f.mainFileName) + throw new Error( + `Can't start working due to no mainFileName (${this.steps.length} steps). This is a configuration issue.` + ) + this.fileNames = f.fileNames + this.mainFileName = f.mainFileName + + this.timer = startTimer() + + this.outputPath = path.resolve('./tmpRenderHTML') + // Prefix the work-in-progress artifacts with a unique identifier + const filePrefix = hash(`${process.pid}_${Math.random()}`) + this.executeSteps = this.steps.map((step) => { + let defaultDuration = 500 + if ( + step.do === 'storeObject' || + step.do === 'modifyObject' || + step.do === 'injectObject' || + step.do === 'executeJs' + ) + defaultDuration = 10 + + const executeStep = { + step, + duration: 'duration' in step ? step.duration : defaultDuration, // Used to calculate total duration + } + if ('fileName' in executeStep.step) { + executeStep.step.fileName = `${filePrefix}_${executeStep.step.fileName}` + } + return executeStep + }) + const { fileNames } = getFileNames(this.executeSteps.map((s) => s.step)) + this.outputFileNames = fileNames + } + + cancel = () => { + this.wasCancelled = true + + this.htmlRenderer?.cancel() + // ffProbeProcess?.cancel() + this.sourceStream?.cancel() + this.writeStream?.abort() + } + + async run(options: { + workInProgress: WorkInProgress + lookupSource: LookupPackageContainer + mainLookupTarget: LookupPackageContainer + url: string + }) { + if (!options.lookupSource.ready) + throw new Error(`Can't start working due to source: ${options.lookupSource.reason.tech}`) + if (!options.mainLookupTarget.ready) + throw new Error(`Can't start working due to target: ${options.mainLookupTarget.reason.tech}`) + + const workInProgress = options.workInProgress + + const actualSourceVersion = await options.lookupSource.handle.getPackageActualVersion() + const actualSourceVersionHash = hashObj(actualSourceVersion) + const actualSourceUVersion = makeUniversalVersion(actualSourceVersion) + + try { + // Remote old temp files, if they exist: + await this.cleanTempFiles() + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.initialCleanTempFiles) + + // Render HTML file according to the steps: + this.htmlRenderer = new HTMLRenderer( + this.worker, + this.exp, + options.url, + workInProgress, + actualSourceVersionHash, + this.executeSteps, + this.outputPath + ) + await this.htmlRenderer.done + + // Move files to the target: + await this.moveOutputFilesToTarget({ + workInProgress, + actualSourceVersionHash, + }) + + // Write metadata + if (!this.wasCancelled) { + await options.mainLookupTarget.handle.updateMetadata(actualSourceUVersion) + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.writeMetadata) + } + + // Clean temp files: + await this.cleanTempFiles() + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.cleanTempFiles) + + // Clean other old files: + const files = await fs.readdir(this.outputPath) + await Promise.all( + files.map(async (file) => { + const fullPath = path.join(this.outputPath, file) + const lStat = await fs.lstat(fullPath) + if (Date.now() - lStat.mtimeMs > 1000 * 3600) { + await unlinkIfExists(fullPath) + } + }) + ) + + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.cleanOutputFiles) + + const duration = this.timer.get() + workInProgress._reportComplete( + actualSourceVersionHash, + { + user: `HTML Rendering completed in ${Math.round(duration / 100) / 10}s`, + tech: `HTML Rendering completed at ${Date.now()}`, + }, + undefined + ) + } catch (e) { + // cleanup + this.cancel() + + throw e + } finally { + await this.cleanTempFiles() + } + } + async moveOutputFilesToTarget(options: { workInProgress: WorkInProgress; actualSourceVersionHash: string }) { + // Move all this.outputFileNames files to our target + + for (let i = 0; i < this.outputFileNames.length; i++) { + if (this.wasCancelled) break + const tempFileName = this.outputFileNames[i] + const fileName = this.fileNames[i] + const localFileSourceHandle = new LocalFolderAccessorHandle( + this.worker, + protectString('tmpLocalHTMLRenderer'), + { + type: Accessor.AccessType.LOCAL_FOLDER, + allowRead: true, + folderPath: this.outputPath, + filePath: tempFileName, + }, + {}, + {} + ) + const lookupTarget = await lookupTargets(this.worker, this.exp, fileName) + if (!lookupTarget.ready) throw new Error(`Can't start working due to target: ${lookupTarget.reason.tech}`) + + const fileOperation = await lookupTarget.handle.prepareForOperation( + 'Copy file, using streams', + localFileSourceHandle + ) + + options.workInProgress._reportProgress( + options.actualSourceVersionHash, + REPORT_PROGRESS.copyFilesToTarget + 0.29 * (i / this.outputFileNames.length) + ) + + const fileSize = (await fs.stat(localFileSourceHandle.fullPath)).size + const byteCounter = new ByteCounter() + byteCounter.on('progress', (bytes: number) => { + if (this.writeStream?.usingCustomProgressEvent) return // ignore this callback, we'll be listening to writeStream.on('progress') instead. + + if (fileSize) { + const progress = bytes / fileSize + options.workInProgress._reportProgress( + options.actualSourceVersionHash, + REPORT_PROGRESS.copyFilesToTarget + 0.29 * ((i + progress) / this.outputFileNames.length) + ) + } + }) + + this.sourceStream = await localFileSourceHandle.getPackageReadStream() + this.writeStream = await lookupTarget.handle.putPackageStream( + this.sourceStream.readStream.pipe(byteCounter) + ) + this.writeStream.on('error', (err) => options.workInProgress._reportError(err)) + + if (this.writeStream.usingCustomProgressEvent) { + this.writeStream.on('progress', (progress) => { + options.workInProgress._reportProgress( + options.actualSourceVersionHash, + REPORT_PROGRESS.copyFilesToTarget + 0.29 * ((i + progress) / this.outputFileNames.length) + ) + }) + } + + await new Promise((resolve, reject) => { + if (!this.sourceStream) throw new Error(`sourceStream missing`) + if (!this.writeStream) throw new Error(`writeStream missing`) + + this.sourceStream.readStream.on('error', (err) => reject(err)) + this.writeStream.once('error', (err) => reject(err)) + this.writeStream.once('close', () => resolve()) + }) + this.writeStream.removeAllListeners() + this.writeStream.removeAllListeners() + + await lookupTarget.handle.finalizePackage(fileOperation) + } + } + private async cleanTempFiles() { + // Clean temp files: + await Promise.all( + this.outputFileNames.map(async (fileName) => unlinkIfExists(path.join(this.outputPath, fileName))) + ) + } +} + +class HTMLRenderer { + public done: Promise + + // @ts-expect-error is set in constructor + private resolve: () => void + // @ts-expect-error is set in constructor + private reject: (err: unknown) => void + + private totalStepDuration: number + private htmlRendererProcess: ChildProcessWithoutNullStreams | undefined = undefined + private ws: WebSocket | undefined = undefined + + private commandStepIndex = 0 + private commandStepDuration = 0 + private waitingForCommand: string | null = null + private timeoutDoNextCommand: NodeJS.Timeout | undefined = undefined + private processLastFewLines: string[] = [] + + private storedDataObjects: { + [key: string]: Record + } = {} + + constructor( + private worker: BaseWorker, + private exp: Expectation.RenderHTML, + private url: string, + private workInProgress: WorkInProgress, + private actualSourceVersionHash: string, + private executeSteps: { step: Steps[number]; duration: number }[], + private outputPath: string + ) { + this.totalStepDuration = this.executeSteps.reduce((prev, cur) => prev + cur.duration, 0) || 1 + + this.done = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + + this.spawnHTMLRendererProcess() + }) + } + cancel() { + // ensure this function doesn't throw, since it is called from various error event handlers + try { + this.htmlRendererProcess?.kill() + } catch (e) { + // This is probably OK, errors likely means that the process is already dead + } + this.htmlRendererProcess = undefined + } + private onError(err: unknown) { + if (err instanceof Error) { + err.message += `: ${this.processLastFewLines.join('\n')}` + } else if (typeof err === 'string') { + err += `: ${this.processLastFewLines.join('\n')}` + } + + this.reject(err) + this.cancel() + } + private onComplete() { + this.resolve() + } + + private spawnHTMLRendererProcess() { + this.htmlRendererProcess = spawn( + getHtmlRendererExecutable(), + compact([ + `--`, + `--url=${this.url}`, + this.exp.endRequirement.version.renderer?.width !== undefined && + `--width=${this.exp.endRequirement.version.renderer?.width}`, + this.exp.endRequirement.version.renderer?.height !== undefined && + `--height=${this.exp.endRequirement.version.renderer?.height}`, + this.exp.endRequirement.version.renderer?.zoom !== undefined && + `--zoom=${this.exp.endRequirement.version.renderer?.zoom}`, + `--outputPath=${this.outputPath}`, + `--interactive=true`, + ]), + { + windowsVerbatimArguments: true, // To fix an issue with arguments on Windows + } + ) + + const onClose = (code: number | null) => { + if (this.htmlRendererProcess) { + this.htmlRendererProcess.removeAllListeners() + this.htmlRendererProcess.stdin.removeAllListeners() + this.htmlRendererProcess.stdout.removeAllListeners() + + this.htmlRendererProcess = undefined + if (code === 0) { + // Do nothing + } else { + this.onError(new Error(`HTMLRenderer exit code ${code}`)) + } + } + } + this.htmlRendererProcess.on('close', (code) => onClose(code)) + this.htmlRendererProcess.on('exit', (code) => onClose(code)) + this.htmlRendererProcess.on('error', (err) => { + this.onError(new Error(`HTMLRenderer error: ${stringifyError(err)}`)) + }) + + let waitingForReady = true + this.htmlRendererProcess.stderr.on('data', (data) => { + const str = data.toString() + this.worker.logger.debug(`HTMLRenderer: stderr: ${str}`) + this.processLastFewLines.push(str) + if (this.processLastFewLines.length > 10) this.processLastFewLines.shift() + }) + this.htmlRendererProcess.stdout.on('data', (data) => { + try { + const str = data.toString() + this.worker.logger.debug(`HTMLRenderer: stdout: ${str}`) + this.processLastFewLines.push(str) + if (this.processLastFewLines.length > 10) this.processLastFewLines.shift() + + let message: InteractiveStdOut + try { + message = JSON.parse(str) + } catch { + // ignore parse errors + return + } + if (message.status === 'listening') { + // This message indicates that the HTML renderer is ready to accept interactive commands on the websocket server + if (waitingForReady) { + waitingForReady = false + + this.workInProgress._reportProgress( + this.actualSourceVersionHash, + REPORT_PROGRESS.setupWebSocketConnection + ) + this.setupWebSocketConnection(message.port).catch((e) => this.onError(e)) + } else { + this.onError( + new Error(`Unexpected reply from HTMLRenderer: ${message} (not waiting for 'listening')`) + ) + this.cancel() + } + } else { + assertNever(message.status) + } + } catch (e) { + this.onError(e) + } + }) + } + + private async setupWebSocketConnection(port: number) { + if (this.ws) throw new Error(`WebSocket already set up`) + + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + this.ws = ws + ws.once('close', () => { + ws.removeAllListeners() + delete this.ws + }) + ws.on('message', (data) => { + try { + const str = data.toString() + + this.worker.logger.debug(`HTMLRenderer: Received reply: ${str}`) + + let message: InteractiveReply | undefined = undefined + try { + message = JSON.parse(str) + } catch { + // ignore parse errors + } + + if (!message) { + // Other output, log and ignore: + this.worker.logger.debug(`HTMLRenderer: ${str}`) + } else if (message.error) { + this.onError(new Error(`Error reply from HTMLRenderer: ${message.error}`)) + } else if (message.reply) { + if (!this.waitingForCommand) { + this.onError( + new Error(`Unexpected reply from HTMLRenderer: ${message.reply} (not waiting for command)`) + ) + } else if (message.reply === this.waitingForCommand) { + // This message indicates that the HTML renderer has completed the previous command + this.waitingForCommand = null + + this.doNextCommand() + } else { + this.onError( + new Error(`Unexpected reply from HTMLRenderer: ${message.reply} (not waiting for command)`) + ) + } + } else { + assertNever(message) + // Other output, log and ignore: + this.worker.logger.debug(`HTMLRenderer: ${str}`) + } + } catch (e) { + this.onError(e) + } + }) + await new Promise((resolve, reject) => { + ws.once('open', resolve) + ws.once('error', reject) + }) + + this.worker.logger.debug(`HTMLRenderer: WebSocket connected`) + this.doNextCommand() + } + + private doNextCommand() { + if (!this.ws) throw new Error(`WebSocket not set up`) + if (this.timeoutDoNextCommand) { + clearTimeout(this.timeoutDoNextCommand) + this.timeoutDoNextCommand = undefined + } + if (this.waitingForCommand) throw new Error('Already waiting for command') + + const currentStep = this.executeSteps[this.commandStepIndex] + this.commandStepIndex++ + + if (currentStep) { + const stepStartDuration = this.commandStepDuration + this.commandStepDuration += currentStep.duration + + const reportStepProgress = ( + /** Progress within the step [0-1] */ + progress: number + ) => { + this.workInProgress._reportProgress( + this.actualSourceVersionHash, + REPORT_PROGRESS.sendCommands + + 0.49 * ((stepStartDuration + progress * currentStep.duration) / this.totalStepDuration) + ) + } + reportStepProgress(0) + if (currentStep.step.do === 'sleep') { + this.worker.logger.debug(`HTMLRenderer: Sleeping for ${currentStep.step.duration}ms`) + + // While sleeping, continuously report the progress + let reportTime = 0 + const reportInterval = setInterval(() => { + reportTime += 500 + reportStepProgress(reportTime / currentStep.duration) + }, 500) + setTimeout(() => { + clearInterval(reportInterval) + this.doNextCommand() + }, currentStep.step.duration) + } else if (currentStep.step.do === 'sendHTTPCommand') { + this.worker.logger.debug(`HTMLRenderer: Send HTTP command: ${JSON.stringify(currentStep.step)}`) + const step = currentStep.step + + fetchWithTimeout(step.url, { + method: step.method, + headers: step.headers, + body: step.body, + }) + .catch((err) => { + this.onError( + new Error( + `HTMLRenderer: Error when sending "${step.method}" to "${step.url}": ${stringifyError( + err + )}` + ) + ) + }) + .finally(() => { + this.doNextCommand() + }) + } else if (currentStep.step.do === 'storeObject') { + this.worker.logger.debug(`HTMLRenderer: Store object "${currentStep.step.key}"`) + this.storedDataObjects[currentStep.step.key] = currentStep.step.value + this.doNextCommand() + } else if (currentStep.step.do === 'modifyObject') { + this.worker.logger.debug(`HTMLRenderer: Modify object "${currentStep.step.key}"`) + + const obj = this.storedDataObjects[currentStep.step.key] + if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) + + modifyObject(obj, currentStep.step.path, currentStep.step.value) + this.doNextCommand() + } else if (currentStep.step.do === 'injectObject') { + this.worker.logger.debug(`HTMLRenderer: Inject object "${currentStep.step.key}"`) + + const obj = this.storedDataObjects[currentStep.step.key] + if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) + + // Execute javascript in the renderer, to simulate a postMessage event: + const cmd: InteractiveMessage = { + do: 'executeJs', + js: `window.postMessage(${JSON.stringify(obj)})`, + } + // Send command to the renderer: + this.setCommandToRenderer(cmd) + } else { + this.worker.logger.debug(`HTMLRenderer: Send command: ${JSON.stringify(currentStep.step)}`) + + // Send command to the renderer: + this.setCommandToRenderer(currentStep.step) + } + } else { + // Done, no more commands to send. + + this.workInProgress._reportProgress(this.actualSourceVersionHash, REPORT_PROGRESS.sendCommands + 0.49) + + // Send a close command to the renderer + this.ws.send(JSON.stringify(literal({ do: 'close' }))) + + // Wait a little bit before completion + setTimeout(() => { + this.ws?.close() + this.onComplete() + }, 500) + } + } + private setCommandToRenderer(cmd: InteractiveMessage) { + if (!this.ws) throw new Error(`WebSocket not set up`) + + this.ws.send(JSON.stringify(cmd) + '\n') + this.waitingForCommand = cmd.do + + this.timeoutDoNextCommand = setTimeout(() => { + this.onError(new Error(`Timeout waiting for command "${cmd.do}" after ${COMMAND_TIMEOUT} ms`)) + }, COMMAND_TIMEOUT) + } +} + +const COMMAND_TIMEOUT = 10000 +const REPORT_PROGRESS = { + initialCleanTempFiles: 0.05, + setupWebSocketConnection: 0.08, + sendCommands: 0.1, + copyFilesToTarget: 0.6, + writeMetadata: 0.9, + cleanTempFiles: 0.95, + cleanOutputFiles: 0.97, +} + +/** Modify an property inside an object */ +function modifyObject(obj: Record | Record[], objPath: string | string[], value: unknown) { + if (typeof objPath === 'string') objPath = objPath.split('.') + + if (typeof obj === 'object' && obj !== null) { + if (Array.isArray(obj)) { + const index = parseInt(objPath[0], 10) + if (isNaN(index)) throw new Error(`Invalid array key: ${objPath[0]}`) + + if (objPath.length === 1) { + obj[index] = value + } else { + modifyObject(obj[index], objPath.slice(1), value) + } + } else { + const key = objPath[0] + if (objPath.length === 1) { + obj[key] = value + } else { + modifyObject(obj[key], objPath.slice(1), value) + } + } + } else { + throw new Error(`Invalid object path: ${objPath.join('.')}`) + } +} diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/lib/ffmpeg.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/lib/ffmpeg.ts index c8b11533..5b6c050e 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/lib/ffmpeg.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/lib/ffmpeg.ts @@ -9,72 +9,19 @@ import { import { FileShareAccessorHandle } from '../../../../accessorHandlers/fileShare' import { HTTPProxyAccessorHandle } from '../../../../accessorHandlers/httpProxy' import { LocalFolderAccessorHandle } from '../../../../accessorHandlers/localFolder' -import { assertNever, escapeFilePath, stringifyError } from '@sofie-package-manager/api' - -export interface OverriddenFFMpegExecutables { - ffmpeg: string - ffprobe: string -} - -let overriddenFFMpegPaths: OverriddenFFMpegExecutables | null = null -/** - * Override the paths of the ffmpeg executables, intended for unit testing purposes - * @param paths Paths to executables - */ -export function overrideFFMpegExecutables(paths: OverriddenFFMpegExecutables | null): void { - overriddenFFMpegPaths = paths -} - -export interface FFMpegProcess { - pid: number - cancel: () => void -} -export function getFFMpegExecutable(): string { - if (overriddenFFMpegPaths) return overriddenFFMpegPaths.ffmpeg - return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' -} -export function getFFProbeExecutable(): string { - if (overriddenFFMpegPaths) return overriddenFFMpegPaths.ffprobe - return process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe' -} -/** Check if FFMpeg is available, returns null if no error found */ -export async function testFFMpeg(): Promise { - return testFFExecutable(getFFMpegExecutable()) -} -/** Check if FFProbe is available */ -export async function testFFProbe(): Promise { - return testFFExecutable(getFFProbeExecutable()) -} -export async function testFFExecutable(ffExecutable: string): Promise { - return new Promise((resolve) => { - const ffMpegProcess = spawn(ffExecutable, ['-version']) - let output = '' - ffMpegProcess.stderr.on('data', (data) => { - const str = data.toString() - output += str - }) - ffMpegProcess.stdout.on('data', (data) => { - const str = data.toString() - output += str - }) - ffMpegProcess.on('error', (err) => { - resolve(`Process ${ffExecutable} emitted error: ${stringifyError(err)}`) - }) - ffMpegProcess.on('exit', (code) => { - const m = output.match(/version ([\w-]+)/) // version N-102494-g2899fb61d2 - - if (code === 0) { - if (m) { - resolve(null) - } else { - resolve(`Process ${ffExecutable} bad version: ${output}`) - } - } else { - resolve(`Process ${ffExecutable} exited with code ${code}`) - } - }) - }) -} +import { + assertNever, + escapeFilePath, + stringifyError, + FFMpegProcess, + getFFMpegExecutable, + testFFMpeg, + testFFProbe, + overrideFFMpegExecutables, + getFFProbeExecutable, +} from '@sofie-package-manager/api' + +export { FFMpegProcess, testFFMpeg, testFFProbe, overrideFFMpegExecutables, getFFProbeExecutable, getFFMpegExecutable } /** Spawn an ffmpeg process and make it to output its content to the target */ export async function spawnFFMpeg( diff --git a/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts b/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts index 164980c7..57abf7c2 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts @@ -12,6 +12,7 @@ import { ReturnTypeRunPackageContainerCronJob, assertNever, stringifyError, + testHtmlRenderer, } from '@sofie-package-manager/api' import { BaseWorker, GenericWorkerAgentAPI } from '../../worker' import { FileCopy } from './expectationHandlers/fileCopy' @@ -31,6 +32,7 @@ import { testFFMpeg, testFFProbe } from './expectationHandlers/lib/ffmpeg' import { JsonDataCopy } from './expectationHandlers/jsonDataCopy' import { SetupPackageContainerMonitorsResult } from '../../accessorHandlers/genericHandle' import { FileVerify } from './expectationHandlers/fileVerify' +import { RenderHTML } from './expectationHandlers/RenderHTML' export type ExpectationHandlerGenericWorker = ExpectationHandler @@ -42,8 +44,10 @@ export class GenericWorker extends BaseWorker { public testFFMpeg: null | string = 'Not initialized' /** Contains the result of testing the FFProbe executable. null = all is well, otherwise contains error message */ public testFFProbe: null | string = 'Not initialized' + /** Contains the result of testing the HTMLRenderer executable. null = all is well, otherwise contains error message */ + public testHTMLRenderer: null | string = 'Not initialized' - private monitor: NodeJS.Timeout | undefined + private monitorExecutables: NodeJS.Timeout | undefined constructor( logger: LoggerInstance, @@ -58,23 +62,24 @@ export class GenericWorker extends BaseWorker { } async init(): Promise { await this.checkExecutables() - this.monitor = setInterval(() => { + this.monitorExecutables = setInterval(() => { this.checkExecutables().catch((err) => { this.logger.error(`Error in checkExecutables: ${stringifyError(err)}`) }) - }, 10 * 1000) + }, 1000 * 60 * 5) // Check every 5 minutes this.logger.debug(`Worker initialized`) } terminate(): void { - if (this.monitor) { - clearInterval(this.monitor) - delete this.monitor + if (this.monitorExecutables) { + clearInterval(this.monitorExecutables) + delete this.monitorExecutables } this.logger.debug(`Worker terminated`) } private async checkExecutables() { this.testFFMpeg = await testFFMpeg() this.testFFProbe = await testFFProbe() + this.testHTMLRenderer = await testHtmlRenderer() } async getCostFortExpectation(exp: Expectation.Any): Promise { return this.getExpectationHandler(exp).getCostForExpectation(exp, this) @@ -122,6 +127,8 @@ export class GenericWorker extends BaseWorker { return QuantelClipPreview case Expectation.Type.JSON_DATA_COPY: return JsonDataCopy + case Expectation.Type.RENDER_HTML: + return RenderHTML default: assertNever(exp) // @ts-expect-error exp.type is never diff --git a/yarn.lock b/yarn.lock index 75e9cf8f..3cca4507 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,13 @@ __metadata: version: 8 cacheKey: 10 +"7zip-bin@npm:~5.2.0": + version: 5.2.0 + resolution: "7zip-bin@npm:5.2.0" + checksum: 10/5339c7a56f57f8d7d16ac8d15f588d155e5705cd4822e0d161ea45e6fbfe4a5226b464a331ec555a1ec9376ad04e5f61c125656cf3a1507900c1968ccbcfe80b + languageName: node + linkType: hard + "@aashutoshrathi/word-wrap@npm:^1.2.3": version: 1.2.6 resolution: "@aashutoshrathi/word-wrap@npm:1.2.6" @@ -539,6 +546,91 @@ __metadata: languageName: node linkType: hard +"@develar/schema-utils@npm:~2.6.5": + version: 2.6.5 + resolution: "@develar/schema-utils@npm:2.6.5" + dependencies: + ajv: "npm:^6.12.0" + ajv-keywords: "npm:^3.4.1" + checksum: 10/a219d60afca9abe708171d7b361907e36526fa8e6e7c480c6c8b05c6611d7e0989b11c1b21b7bceff5d7ccdc92315d364358ec3fd8bc5113d4e869288f32ae9c + languageName: node + linkType: hard + +"@electron/asar@npm:^3.2.1": + version: 3.2.10 + resolution: "@electron/asar@npm:3.2.10" + dependencies: + commander: "npm:^5.0.0" + glob: "npm:^7.1.6" + minimatch: "npm:^3.0.4" + bin: + asar: bin/asar.js + checksum: 10/2ead53564c430fd4252a76e754936aab144e4aea968b2d9b06c8327d6a7ca9c082a1230d9e00f79d8087f3882c1a76f95c638c3290c7b0e76d8ebed2d552f97b + languageName: node + linkType: hard + +"@electron/get@npm:^2.0.0": + version: 2.0.3 + resolution: "@electron/get@npm:2.0.3" + dependencies: + debug: "npm:^4.1.1" + env-paths: "npm:^2.2.0" + fs-extra: "npm:^8.1.0" + global-agent: "npm:^3.0.0" + got: "npm:^11.8.5" + progress: "npm:^2.0.3" + semver: "npm:^6.2.0" + sumchecker: "npm:^3.0.1" + dependenciesMeta: + global-agent: + optional: true + checksum: 10/ac736cdeac52513b23038c761ebcb9fd315d443675f12c975805d7bcddcdabe5be492310ce5f6f1915d27013bcdcf19d0dac73c72353120948bbdf01fb3e11bf + languageName: node + linkType: hard + +"@electron/notarize@npm:2.2.1": + version: 2.2.1 + resolution: "@electron/notarize@npm:2.2.1" + dependencies: + debug: "npm:^4.1.1" + fs-extra: "npm:^9.0.1" + promise-retry: "npm:^2.0.1" + checksum: 10/6d5bb78a0ba0af59a07daf01ace17a33869893641639c94d0f74ca060698d8cf61fca4002c61592a70f6f20e03987fc1138625853d947394749b1bd46ed2db3c + languageName: node + linkType: hard + +"@electron/osx-sign@npm:1.0.5": + version: 1.0.5 + resolution: "@electron/osx-sign@npm:1.0.5" + dependencies: + compare-version: "npm:^0.1.2" + debug: "npm:^4.3.4" + fs-extra: "npm:^10.0.0" + isbinaryfile: "npm:^4.0.8" + minimist: "npm:^1.2.6" + plist: "npm:^3.0.5" + bin: + electron-osx-flat: bin/electron-osx-flat.js + electron-osx-sign: bin/electron-osx-sign.js + checksum: 10/b8df7c097954e754fec99544d5c6787bcc53de5125557399978ec17084bfb7e8d94b6857b5b2b14f6b2c030cd1086f05f816615a6480a7b581ac8584e2120fcf + languageName: node + linkType: hard + +"@electron/universal@npm:1.5.1": + version: 1.5.1 + resolution: "@electron/universal@npm:1.5.1" + dependencies: + "@electron/asar": "npm:^3.2.1" + "@malept/cross-spawn-promise": "npm:^1.1.0" + debug: "npm:^4.3.1" + dir-compare: "npm:^3.0.0" + fs-extra: "npm:^9.0.1" + minimatch: "npm:^3.0.4" + plist: "npm:^3.0.4" + checksum: 10/9e6cd5dbc05350c1a0e9a947651171de5d5e36976094f9dd2267451b872cd6b6759cb40cf222bf8b4383a7d86103cacb5eeeeb532f27c64c439c77ba50fa61f1 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" @@ -588,6 +680,39 @@ __metadata: languageName: node linkType: hard +"@html-renderer/app@workspace:apps/html-renderer/app": + version: 0.0.0-use.local + resolution: "@html-renderer/app@workspace:apps/html-renderer/app" + dependencies: + "@html-renderer/generic": "npm:1.50.5" + "@sofie-automation/shared-lib": "npm:1.50.0" + "@sofie-package-manager/api": "npm:1.50.5" + "@types/ws": "npm:^8.5.4" + archiver: "npm:^7.0.1" + electron: "npm:^30.0.6" + electron-builder: "npm:^24.13.3" + lerna: "npm:^6.6.1" + portfinder: "npm:^1.0.32" + rimraf: "npm:^5.0.5" + tslib: "npm:^2.1.0" + yargs: "npm:^17.7.2" + peerDependencies: + ws: "*" + languageName: unknown + linkType: soft + +"@html-renderer/generic@npm:1.50.5, @html-renderer/generic@workspace:apps/html-renderer/packages/generic": + version: 0.0.0-use.local + resolution: "@html-renderer/generic@workspace:apps/html-renderer/packages/generic" + dependencies: + "@sofie-package-manager/api": "npm:1.50.5" + electron: "npm:^30.0.6" + rimraf: "npm:^5.0.5" + peerDependencies: + "@sofie-automation/shared-lib": "*" + languageName: unknown + linkType: soft + "@http-server/app@workspace:apps/http-server/app": version: 0.0.0-use.local resolution: "@http-server/app@workspace:apps/http-server/app" @@ -1109,6 +1234,27 @@ __metadata: languageName: node linkType: hard +"@malept/cross-spawn-promise@npm:^1.1.0": + version: 1.1.1 + resolution: "@malept/cross-spawn-promise@npm:1.1.1" + dependencies: + cross-spawn: "npm:^7.0.1" + checksum: 10/8f04dcbe023e7ee4e82040e32aa29c1774a2d79a36d4a1df2da381281f99419e51a950c77d54662cfd2bc9195db2fbb0068e0f790ac08d7ad981180687051e96 + languageName: node + linkType: hard + +"@malept/flatpak-bundler@npm:^0.4.0": + version: 0.4.0 + resolution: "@malept/flatpak-bundler@npm:0.4.0" + dependencies: + debug: "npm:^4.1.1" + fs-extra: "npm:^9.0.0" + lodash: "npm:^4.17.15" + tmp-promise: "npm:^3.0.2" + checksum: 10/14e04215bcc3dc4cafe8343893ab869f69ad768272563cbb39e7d2e876dc5e03dde9f0c1b0506308a4d90d945871491b05b77c26521b2b4b38245aeed8085d3b + languageName: node + linkType: hard + "@mos-connection/model@npm:^3.0.4": version: 3.0.4 resolution: "@mos-connection/model@npm:3.0.4" @@ -2022,6 +2168,7 @@ __metadata: "@types/node-fetch": "npm:^2.5.8" "@types/proper-lockfile": "npm:^4.1.4" "@types/tmp": "npm:~0.2.2" + "@types/ws": "npm:^8.5.4" abort-controller: "npm:^3.0.0" atem-connection: "npm:^3.2.0" deep-diff: "npm:^1.0.2" @@ -2038,6 +2185,7 @@ __metadata: xml-js: "npm:^1.6.11" peerDependencies: "@sofie-automation/shared-lib": "*" + ws: "*" languageName: unknown linkType: soft @@ -2217,6 +2365,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4.1.6": + version: 4.1.12 + resolution: "@types/debug@npm:4.1.12" + dependencies: + "@types/ms": "npm:*" + checksum: 10/47876a852de8240bfdaf7481357af2b88cb660d30c72e73789abf00c499d6bc7cd5e52f41c915d1b9cd8ec9fef5b05688d7b7aef17f7f272c2d04679508d1053 + languageName: node + linkType: hard + "@types/deep-diff@npm:^1.0.0": version: 1.0.5 resolution: "@types/deep-diff@npm:1.0.5" @@ -2254,6 +2411,15 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:9.0.13, @types/fs-extra@npm:^9.0.11": + version: 9.0.13 + resolution: "@types/fs-extra@npm:9.0.13" + dependencies: + "@types/node": "npm:*" + checksum: 10/ac545e377248039c596ef27d9f277b813507ebdd95d05f32fe7e9c67eb1ed567dafb4ba59f5fdcb6601dd7fd396ff9ba24f8c122e89cef096cdc17987c50a7fa + languageName: node + linkType: hard + "@types/glob@npm:*": version: 8.1.0 resolution: "@types/glob@npm:8.1.0" @@ -2481,6 +2647,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 0.7.34 + resolution: "@types/ms@npm:0.7.34" + checksum: 10/f38d36e7b6edecd9badc9cf50474159e9da5fa6965a75186cceaf883278611b9df6669dc3a3cc122b7938d317b68a9e3d573d316fcb35d1be47ec9e468c6bd8a + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.5.8": version: 2.6.2 resolution: "@types/node-fetch@npm:2.6.2" @@ -2500,6 +2673,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.9.0": + version: 20.12.12 + resolution: "@types/node@npm:20.12.12" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10/e3945da0a3017bdc1f88f15bdfb823f526b2a717bd58d4640082d6eb0bd2794b5c99bfb914b9e9324ec116dce36066990353ed1c777e8a7b0641f772575793c4 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0, @types/normalize-package-data@npm:^2.4.1": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -2514,6 +2696,16 @@ __metadata: languageName: node linkType: hard +"@types/plist@npm:^3.0.1": + version: 3.0.5 + resolution: "@types/plist@npm:3.0.5" + dependencies: + "@types/node": "npm:*" + xmlbuilder: "npm:>=11.0.1" + checksum: 10/71417189c9bc0d0cb4595106cea7c7a8a7274f64d2e9c4dd558efd7993bcfdada58be6917189e3be7c455fe4e5557004658fd13bd12254eafed8c56e0868b59e + languageName: node + linkType: hard + "@types/prettier@npm:^2.1.5": version: 2.7.3 resolution: "@types/prettier@npm:2.7.3" @@ -2608,6 +2800,13 @@ __metadata: languageName: node linkType: hard +"@types/verror@npm:^1.10.3": + version: 1.10.10 + resolution: "@types/verror@npm:1.10.10" + checksum: 10/2865053bded09809edb8bcb899bf8fb82701000434d979d7aa72f9163c1c5b88d1e3bca47e4a4f5eb81d7ec168842c7fffe93dc56c4d4b7afc9d38d92408212d + languageName: node + linkType: hard + "@types/winston@npm:^2.3.9": version: 2.4.4 resolution: "@types/winston@npm:2.4.4" @@ -2651,6 +2850,15 @@ __metadata: languageName: node linkType: hard +"@types/yauzl@npm:^2.9.1": + version: 2.10.3 + resolution: "@types/yauzl@npm:2.10.3" + dependencies: + "@types/node": "npm:*" + checksum: 10/5ee966ea7bd6b2802f31ad4281c92c4c0b6dfa593c378a2582c58541fa113bec3d70eb0696b34ad95e8e6861a884cba6c3e351285816693ed176222f840a8c08 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^5.62.0": version: 5.62.0 resolution: "@typescript-eslint/eslint-plugin@npm:5.62.0" @@ -2812,6 +3020,13 @@ __metadata: languageName: unknown linkType: soft +"@xmldom/xmldom@npm:^0.8.8": + version: 0.8.10 + resolution: "@xmldom/xmldom@npm:0.8.10" + checksum: 10/62400bc5e0e75b90650e33a5ceeb8d94829dd11f9b260962b71a784cd014ddccec3e603fe788af9c1e839fa4648d8c521ebd80d8b752878d3a40edabc9ce7ccf + languageName: node + linkType: hard + "@yao-pkg/pkg-fetch@npm:3.5.9": version: 3.5.9 resolution: "@yao-pkg/pkg-fetch@npm:3.5.9" @@ -2991,7 +3206,16 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.12.4": +"ajv-keywords@npm:^3.4.1": + version: 3.5.2 + resolution: "ajv-keywords@npm:3.5.2" + peerDependencies: + ajv: ^6.9.1 + checksum: 10/d57c9d5bf8849bddcbd801b79bc3d2ddc736c2adb6b93a6a365429589dd7993ddbd5d37c6025ed6a7f89c27506b80131d5345c5b1fa6a97e40cd10a96bcd228c + languageName: node + linkType: hard + +"ajv@npm:^6.10.0, ajv@npm:^6.12.0, ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" dependencies: @@ -3098,6 +3322,51 @@ __metadata: languageName: node linkType: hard +"app-builder-bin@npm:4.0.0": + version: 4.0.0 + resolution: "app-builder-bin@npm:4.0.0" + checksum: 10/5d401b2670acb381c76f96467320af0569748c66950adacc239ef85f5ac9d7b44e93c387ad3fea22c25d42ca9f6f6ccd53e827cdf9eb6b6cddd2091d88c5289d + languageName: node + linkType: hard + +"app-builder-lib@npm:24.13.3": + version: 24.13.3 + resolution: "app-builder-lib@npm:24.13.3" + dependencies: + "@develar/schema-utils": "npm:~2.6.5" + "@electron/notarize": "npm:2.2.1" + "@electron/osx-sign": "npm:1.0.5" + "@electron/universal": "npm:1.5.1" + "@malept/flatpak-bundler": "npm:^0.4.0" + "@types/fs-extra": "npm:9.0.13" + async-exit-hook: "npm:^2.0.1" + bluebird-lst: "npm:^1.0.9" + builder-util: "npm:24.13.1" + builder-util-runtime: "npm:9.2.4" + chromium-pickle-js: "npm:^0.2.0" + debug: "npm:^4.3.4" + ejs: "npm:^3.1.8" + electron-publish: "npm:24.13.1" + form-data: "npm:^4.0.0" + fs-extra: "npm:^10.1.0" + hosted-git-info: "npm:^4.1.0" + is-ci: "npm:^3.0.0" + isbinaryfile: "npm:^5.0.0" + js-yaml: "npm:^4.1.0" + lazy-val: "npm:^1.0.5" + minimatch: "npm:^5.1.1" + read-config-file: "npm:6.3.2" + sanitize-filename: "npm:^1.6.3" + semver: "npm:^7.3.8" + tar: "npm:^6.1.12" + temp-file: "npm:^3.4.0" + peerDependencies: + dmg-builder: 24.13.3 + electron-builder-squirrel-windows: 24.13.3 + checksum: 10/4379dc87e0e037a8e11eead68195673680fb4f90570bdc19fffa301d4c5bf5dcac2d38738885fed2cae5eeb5be9be0c93d682ee25e376322a25d0767dec4a415 + languageName: node + linkType: hard + "aproba@npm:^1.0.3 || ^2.0.0, aproba@npm:^2.0.0": version: 2.0.0 resolution: "aproba@npm:2.0.0" @@ -3105,6 +3374,36 @@ __metadata: languageName: node linkType: hard +"archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": + version: 5.0.2 + resolution: "archiver-utils@npm:5.0.2" + dependencies: + glob: "npm:^10.0.0" + graceful-fs: "npm:^4.2.0" + is-stream: "npm:^2.0.1" + lazystream: "npm:^1.0.0" + lodash: "npm:^4.17.15" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10/9dde4aa3f0cb1bdfe0b3d4c969f82e6cca9ae76338b7fee6f0071a14a2a38c0cdd1c41ecd3e362466585aa6cc5d07e9e435abea8c94fd9c7ace35f184abef9e4 + languageName: node + linkType: hard + +"archiver@npm:^7.0.1": + version: 7.0.1 + resolution: "archiver@npm:7.0.1" + dependencies: + archiver-utils: "npm:^5.0.2" + async: "npm:^3.2.4" + buffer-crc32: "npm:^1.0.0" + readable-stream: "npm:^4.0.0" + readdir-glob: "npm:^1.1.2" + tar-stream: "npm:^3.0.0" + zip-stream: "npm:^6.0.1" + checksum: 10/81c6102db99d7ffd5cb2aed02a678f551c6603991a059ca66ef59249942b835a651a3d3b5240af4f8bec4e61e13790357c9d1ad4a99982bd2cc4149575c31d67 + languageName: node + linkType: hard + "are-we-there-yet@npm:^3.0.0": version: 3.0.1 resolution: "are-we-there-yet@npm:3.0.1" @@ -3190,6 +3489,13 @@ __metadata: languageName: node linkType: hard +"assert-plus@npm:^1.0.0": + version: 1.0.0 + resolution: "assert-plus@npm:1.0.0" + checksum: 10/f4f991ae2df849cc678b1afba52d512a7cbf0d09613ba111e72255409ff9158550c775162a47b12d015d1b82b3c273e8e25df0e4783d3ddb008a293486d00a07 + languageName: node + linkType: hard + "astral-regex@npm:^2.0.0": version: 2.0.0 resolution: "astral-regex@npm:2.0.0" @@ -3197,6 +3503,13 @@ __metadata: languageName: node linkType: hard +"async-exit-hook@npm:^2.0.1": + version: 2.0.1 + resolution: "async-exit-hook@npm:2.0.1" + checksum: 10/fffabbe5ef194ec8283efed48eaf8f4b7982d547de6d4cf7aadf83c8690f0f7929ad01b7cb5de99935ea8f3deb2c21fd009892d8215a43b5a2dcc55c04d42c9f + languageName: node + linkType: hard + "async-ratelimiter@npm:^1.3.0": version: 1.3.1 resolution: "async-ratelimiter@npm:1.3.1" @@ -3204,6 +3517,15 @@ __metadata: languageName: node linkType: hard +"async@npm:^2.6.4": + version: 2.6.4 + resolution: "async@npm:2.6.4" + dependencies: + lodash: "npm:^4.17.14" + checksum: 10/df8e52817d74677ab50c438d618633b9450aff26deb274da6dfedb8014130909482acdc7753bce9b72e6171ce9a9f6a92566c4ced34c3cb3714d57421d58ad27 + languageName: node + linkType: hard + "async@npm:^3.2.3": version: 3.2.4 resolution: "async@npm:3.2.4" @@ -3211,6 +3533,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.4": + version: 3.2.5 + resolution: "async@npm:3.2.5" + checksum: 10/323c3615c3f0ab1ac25a6f953296bc0ac3213d5e0f1c0debdb12964e55963af288d570293c11e44f7967af58c06d2a88d0ea588c86ec0fbf62fa98037f604a0f + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -3254,6 +3583,13 @@ __metadata: languageName: node linkType: hard +"b4a@npm:^1.6.4": + version: 1.6.6 + resolution: "b4a@npm:1.6.6" + checksum: 10/6154a36bd78b53ecd2843a829352532a1bf9fc8081dab339ba06ca3c9ffcf25d340c3b18fe4ba0fc17a546a54c1ed814cea92cd6b895f6bd2837ca4ee0fc9f52 + languageName: node + linkType: hard + "babel-jest@npm:^29.7.0": version: 29.7.0 resolution: "babel-jest@npm:29.7.0" @@ -3337,6 +3673,13 @@ __metadata: languageName: node linkType: hard +"bare-events@npm:^2.2.0": + version: 2.2.2 + resolution: "bare-events@npm:2.2.2" + checksum: 10/79d50a739d9f2173e881e0957f9b0ee64befde3d7b6f955b1450de06a4c131f095415beaafa9772caa23c2ddfd70c56def0a3c5841b21488b7ff2c91d9f9898a + languageName: node + linkType: hard + "base64-arraybuffer-es6@npm:^0.3.1": version: 0.3.1 resolution: "base64-arraybuffer-es6@npm:0.3.1" @@ -3344,7 +3687,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1": +"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 @@ -3388,6 +3731,29 @@ __metadata: languageName: node linkType: hard +"bluebird-lst@npm:^1.0.9": + version: 1.0.9 + resolution: "bluebird-lst@npm:1.0.9" + dependencies: + bluebird: "npm:^3.5.5" + checksum: 10/9c06c4b2539ac03dca757b25b1381ae6d316bed24103c92e9f37a97cef00fec730240dd730b8bea73eb6a96ee34f2508652b4987ada69e6dca0267fdc7e72da4 + languageName: node + linkType: hard + +"bluebird@npm:^3.5.5": + version: 3.7.2 + resolution: "bluebird@npm:3.7.2" + checksum: 10/007c7bad22c5d799c8dd49c85b47d012a1fe3045be57447721e6afbd1d5be43237af1db62e26cb9b0d9ba812d2e4ca3bac82f6d7e016b6b88de06ee25ceb96e7 + languageName: node + linkType: hard + +"boolean@npm:^3.0.1": + version: 3.2.0 + resolution: "boolean@npm:3.2.0" + checksum: 10/d28a49dcaeef7fe10cf9fdf488214d3859f07350be8f5caa0c73ec621baf20650e5da6523262e5ce9221909519d4261c16d8430a5bf307fee9ef0e170cdb29f3 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -3448,6 +3814,27 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:^1.0.0": + version: 1.0.0 + resolution: "buffer-crc32@npm:1.0.0" + checksum: 10/ef3b7c07622435085c04300c9a51e850ec34a27b2445f758eef69b859c7827848c2282f3840ca6c1eef3829145a1580ce540cab03ccf4433827a2b95d3b09ca7 + languageName: node + linkType: hard + +"buffer-crc32@npm:~0.2.3": + version: 0.2.13 + resolution: "buffer-crc32@npm:0.2.13" + checksum: 10/06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c + languageName: node + linkType: hard + +"buffer-equal@npm:^1.0.0": + version: 1.0.1 + resolution: "buffer-equal@npm:1.0.1" + checksum: 10/0d56dbeec3d862b16f07fe1cc27751adab26219ff37b90fb0be1fe5c870ce1ce3ed45aad9d9b8c631dfc0e147315d02385ddefaf7f6cb24f067f91a2f8def324 + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -3455,7 +3842,7 @@ __metadata: languageName: node linkType: hard -"buffer@npm:^5.5.0": +"buffer@npm:^5.1.0, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: @@ -3475,6 +3862,40 @@ __metadata: languageName: node linkType: hard +"builder-util-runtime@npm:9.2.4": + version: 9.2.4 + resolution: "builder-util-runtime@npm:9.2.4" + dependencies: + debug: "npm:^4.3.4" + sax: "npm:^1.2.4" + checksum: 10/6b4b6518f8859a90cd6b49ff8053134d731438a588496e5f517ec6d12baef3f1a1c74aaa3142f9d4c61979fca1addc3e47432a956c122cfad582b2145a3df077 + languageName: node + linkType: hard + +"builder-util@npm:24.13.1": + version: 24.13.1 + resolution: "builder-util@npm:24.13.1" + dependencies: + 7zip-bin: "npm:~5.2.0" + "@types/debug": "npm:^4.1.6" + app-builder-bin: "npm:4.0.0" + bluebird-lst: "npm:^1.0.9" + builder-util-runtime: "npm:9.2.4" + chalk: "npm:^4.1.2" + cross-spawn: "npm:^7.0.3" + debug: "npm:^4.3.4" + fs-extra: "npm:^10.1.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" + is-ci: "npm:^3.0.0" + js-yaml: "npm:^4.1.0" + source-map-support: "npm:^0.5.19" + stat-mode: "npm:^1.0.0" + temp-file: "npm:^3.4.0" + checksum: 10/e63836c92010868e9ec8f06e7bfed9b4029915f4375e5173a46f14189204d2eacc1043495208f31d762ef519bcaaf70af625dc5db74290c551654afbb2aad0fd + languageName: node + linkType: hard + "builtins@npm:^1.0.3": version: 1.0.3 resolution: "builtins@npm:1.0.3" @@ -3773,6 +4194,13 @@ __metadata: languageName: node linkType: hard +"chromium-pickle-js@npm:^0.2.0": + version: 0.2.0 + resolution: "chromium-pickle-js@npm:0.2.0" + checksum: 10/4722e78edf21e8e21e14066fce98bce96f2244c82fcb4da5cf2811ccfc66dbb78fc1e0be94b79aed18ba33b8940bb3f3919822151d0b23e12c95574f62f7796f + languageName: node + linkType: hard + "ci-info@npm:^2.0.0": version: 2.0.0 resolution: "ci-info@npm:2.0.0" @@ -4099,6 +4527,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^5.0.0": + version: 5.1.0 + resolution: "commander@npm:5.1.0" + checksum: 10/3e2ef5c003c5179250161e42ce6d48e0e69a54af970c65b7f985c70095240c260fd647453efd4c2c5a31b30ce468f373dc70f769c2f54a2c014abc4792aaca28 + languageName: node + linkType: hard + "common-ancestor-path@npm:^1.0.1": version: 1.0.1 resolution: "common-ancestor-path@npm:1.0.1" @@ -4116,6 +4551,13 @@ __metadata: languageName: node linkType: hard +"compare-version@npm:^0.1.2": + version: 0.1.2 + resolution: "compare-version@npm:0.1.2" + checksum: 10/0ceaf50b5f912c8eb8eeca19375e617209d200abebd771e9306510166462e6f91ad764f33f210a3058ee27c83f2f001a7a4ca32f509da2d207d0143a3438a020 + languageName: node + linkType: hard + "compress-brotli@npm:^1.3.8": version: 1.3.8 resolution: "compress-brotli@npm:1.3.8" @@ -4126,6 +4568,19 @@ __metadata: languageName: node linkType: hard +"compress-commons@npm:^6.0.2": + version: 6.0.2 + resolution: "compress-commons@npm:6.0.2" + dependencies: + crc-32: "npm:^1.2.0" + crc32-stream: "npm:^6.0.0" + is-stream: "npm:^2.0.1" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10/78e3ba10aeef919a1c5bbac21e120f3e1558a31b2defebbfa1635274fc7f7e8a3a0ee748a06249589acd0b33a0d58144b8238ff77afc3220f8d403a96fcc13aa + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -4155,6 +4610,16 @@ __metadata: languageName: node linkType: hard +"config-file-ts@npm:^0.2.4": + version: 0.2.6 + resolution: "config-file-ts@npm:0.2.6" + dependencies: + glob: "npm:^10.3.10" + typescript: "npm:^5.3.3" + checksum: 10/825342ad226109606c701ccd8cb6a874142c0e3369d64ebc7d5a2c3f380ea9008cf20f807634d7943e42c0caa54227381e702f1deed9bb3b8d4a3e3483535117 + languageName: node + linkType: hard + "console-control-strings@npm:^1.1.0": version: 1.1.0 resolution: "console-control-strings@npm:1.1.0" @@ -4313,6 +4778,13 @@ __metadata: languageName: node linkType: hard +"core-util-is@npm:1.0.2": + version: 1.0.2 + resolution: "core-util-is@npm:1.0.2" + checksum: 10/d0f7587346b44a1fe6c269267e037dd34b4787191e473c3e685f507229d88561c40eb18872fabfff02977301815d474300b7bfbd15396c13c5377393f7e87ec3 + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -4333,6 +4805,34 @@ __metadata: languageName: node linkType: hard +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: 10/824f696a5baaf617809aa9cd033313c8f94f12d15ebffa69f10202480396be44aef9831d900ab291638a8022ed91c360696dd5b1ba691eb3f34e60be8835b7c3 + languageName: node + linkType: hard + +"crc32-stream@npm:^6.0.0": + version: 6.0.0 + resolution: "crc32-stream@npm:6.0.0" + dependencies: + crc-32: "npm:^1.2.0" + readable-stream: "npm:^4.0.0" + checksum: 10/e6edc2f81bc387daef6d18b2ac18c2ffcb01b554d3b5c7d8d29b177505aafffba574658fdd23922767e8dab1183d1962026c98c17e17fb272794c33293ef607c + languageName: node + linkType: hard + +"crc@npm:^3.8.0": + version: 3.8.0 + resolution: "crc@npm:3.8.0" + dependencies: + buffer: "npm:^5.1.0" + checksum: 10/3a43061e692113d60fbaf5e438c5f6aa3374fe2368244a75cc083ecee6762513bcee8583f67c2c56feea0b0c72b41b7304fbd3c1e26cfcfaec310b9a18543fa8 + languageName: node + linkType: hard + "create-jest@npm:^29.7.0": version: 29.7.0 resolution: "create-jest@npm:29.7.0" @@ -4350,7 +4850,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -4432,7 +4932,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^3.1.0": +"debug@npm:^3.1.0, debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" dependencies: @@ -4551,6 +5051,17 @@ __metadata: languageName: node linkType: hard +"define-data-property@npm:^1.0.1": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" + dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.0.1" + checksum: 10/abdcb2505d80a53524ba871273e5da75e77e52af9e15b3aa65d8aad82b8a3a424dad7aee2cc0b71470ac7acf501e08defac362e8b6a73cdb4309f028061df4ae + languageName: node + linkType: hard + "define-lazy-prop@npm:^2.0.0": version: 2.0.0 resolution: "define-lazy-prop@npm:2.0.0" @@ -4558,6 +5069,17 @@ __metadata: languageName: node linkType: hard +"define-properties@npm:^1.2.1": + version: 1.2.1 + resolution: "define-properties@npm:1.2.1" + dependencies: + define-data-property: "npm:^1.0.1" + has-property-descriptors: "npm:^1.0.0" + object-keys: "npm:^1.1.1" + checksum: 10/b4ccd00597dd46cb2d4a379398f5b19fca84a16f3374e2249201992f36b30f6835949a9429669ee6b41b6e837205a163eadd745e472069e70dfc10f03e5fcc12 + languageName: node + linkType: hard + "del@npm:^6.0.0": version: 6.1.1 resolution: "del@npm:6.1.1" @@ -4653,6 +5175,13 @@ __metadata: languageName: node linkType: hard +"detect-node@npm:^2.0.4": + version: 2.1.0 + resolution: "detect-node@npm:2.1.0" + checksum: 10/832184ec458353e41533ac9c622f16c19f7c02d8b10c303dfd3a756f56be93e903616c0bb2d4226183c9351c15fc0b3dba41a17a2308262afabcfa3776e6ae6e + languageName: node + linkType: hard + "dezalgo@npm:^1.0.0": version: 1.0.4 resolution: "dezalgo@npm:1.0.4" @@ -4670,6 +5199,16 @@ __metadata: languageName: node linkType: hard +"dir-compare@npm:^3.0.0": + version: 3.3.0 + resolution: "dir-compare@npm:3.3.0" + dependencies: + buffer-equal: "npm:^1.0.0" + minimatch: "npm:^3.0.4" + checksum: 10/4e4ca87564bd1fe86d5b704842e1ba069b172ae507e0420e5cef68dbbc9c5a42753416be38488587bc2c7944a4b4a580af724a686e9ad79150012d1d02efe769 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -4679,6 +5218,42 @@ __metadata: languageName: node linkType: hard +"dmg-builder@npm:24.13.3": + version: 24.13.3 + resolution: "dmg-builder@npm:24.13.3" + dependencies: + app-builder-lib: "npm:24.13.3" + builder-util: "npm:24.13.1" + builder-util-runtime: "npm:9.2.4" + dmg-license: "npm:^1.0.11" + fs-extra: "npm:^10.1.0" + iconv-lite: "npm:^0.6.2" + js-yaml: "npm:^4.1.0" + dependenciesMeta: + dmg-license: + optional: true + checksum: 10/091914493a94a63fd6ba6359c1c1b6b74b42c923b9bc89560d2685e8095e08399ea204ab9abb0bfbfa842d3c14b258e5a60391f3482920348edc57c59da0299f + languageName: node + linkType: hard + +"dmg-license@npm:^1.0.11": + version: 1.0.11 + resolution: "dmg-license@npm:1.0.11" + dependencies: + "@types/plist": "npm:^3.0.1" + "@types/verror": "npm:^1.10.3" + ajv: "npm:^6.10.0" + crc: "npm:^3.8.0" + iconv-corefoundation: "npm:^1.1.7" + plist: "npm:^3.0.4" + smart-buffer: "npm:^4.0.2" + verror: "npm:^1.10.0" + bin: + dmg-license: bin/dmg-license.js + conditions: os=darwin + languageName: node + linkType: hard + "doctrine@npm:^3.0.0": version: 3.0.0 resolution: "doctrine@npm:3.0.0" @@ -4706,6 +5281,20 @@ __metadata: languageName: node linkType: hard +"dotenv-expand@npm:^5.1.0": + version: 5.1.0 + resolution: "dotenv-expand@npm:5.1.0" + checksum: 10/d52af2a6e4642979ae4221408f1b75102508dbe4f5bac1c0613f92a3cf3880d5c31f86b2f5cff3273f7c23e10421e75028546e8b6cd0376fcd20e3803b374e15 + languageName: node + linkType: hard + +"dotenv@npm:^9.0.2": + version: 9.0.2 + resolution: "dotenv@npm:9.0.2" + checksum: 10/8a31ab90b097907a42932b972228e10470e8f0de9aea58c7f2134582e7810f229a2ce5861af40efeb7751c7bf2aae0c8347d72ba3935b72c834fbbac30f80f08 + languageName: node + linkType: hard + "dotenv@npm:~10.0.0": version: 10.0.0 resolution: "dotenv@npm:10.0.0" @@ -4745,6 +5334,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.8": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 + languageName: node + linkType: hard + "ejson@npm:^2.2.3": version: 2.2.3 resolution: "ejson@npm:2.2.3" @@ -4752,6 +5352,43 @@ __metadata: languageName: node linkType: hard +"electron-builder@npm:^24.13.3": + version: 24.13.3 + resolution: "electron-builder@npm:24.13.3" + dependencies: + app-builder-lib: "npm:24.13.3" + builder-util: "npm:24.13.1" + builder-util-runtime: "npm:9.2.4" + chalk: "npm:^4.1.2" + dmg-builder: "npm:24.13.3" + fs-extra: "npm:^10.1.0" + is-ci: "npm:^3.0.0" + lazy-val: "npm:^1.0.5" + read-config-file: "npm:6.3.2" + simple-update-notifier: "npm:2.0.0" + yargs: "npm:^17.6.2" + bin: + electron-builder: cli.js + install-app-deps: install-app-deps.js + checksum: 10/d210e787cd1763c108f4600184a02860a3d00553278ef7c9d3a23b46d1646cdbcfa7514f774effb917de17f037a53773b5a6159819fc19da764ad2a3235b1c0f + languageName: node + linkType: hard + +"electron-publish@npm:24.13.1": + version: 24.13.1 + resolution: "electron-publish@npm:24.13.1" + dependencies: + "@types/fs-extra": "npm:^9.0.11" + builder-util: "npm:24.13.1" + builder-util-runtime: "npm:9.2.4" + chalk: "npm:^4.1.2" + fs-extra: "npm:^10.1.0" + lazy-val: "npm:^1.0.5" + mime: "npm:^2.5.2" + checksum: 10/60133b51bf186a70f710d6f656901d0ec358bcd688294c675711107d947fe961ebaf99fee7108f3a48270b09e0ef71568b0148df363de2dfb09a3c7bb1475c62 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.4.648": version: 1.4.665 resolution: "electron-to-chromium@npm:1.4.665" @@ -4759,6 +5396,19 @@ __metadata: languageName: node linkType: hard +"electron@npm:^30.0.6": + version: 30.0.6 + resolution: "electron@npm:30.0.6" + dependencies: + "@electron/get": "npm:^2.0.0" + "@types/node": "npm:^20.9.0" + extract-zip: "npm:^2.0.1" + bin: + electron: cli.js + checksum: 10/3e48725701c348d6152cbed53266bb349b93687ba241a051235af6425e509413d1430a3b50e9be9dd153996eee2c2e9ab529cc16f58b28589db679effb69a2f8 + languageName: node + linkType: hard + "emittery@npm:^0.13.1": version: 0.13.1 resolution: "emittery@npm:0.13.1" @@ -4867,6 +5517,22 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0": + version: 1.0.0 + resolution: "es-define-property@npm:1.0.0" + dependencies: + get-intrinsic: "npm:^1.2.4" + checksum: 10/f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10/96e65d640156f91b707517e8cdc454dd7d47c32833aa3e85d79f24f9eb7ea85f39b63e36216ef0114996581969b59fe609a94e30316b08f5f4df1d44134cf8d5 + languageName: node + linkType: hard + "es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.46, es5-ext@npm:^0.10.50, es5-ext@npm:^0.10.53, es5-ext@npm:^0.10.61, es5-ext@npm:^0.10.62, es5-ext@npm:~0.10.14, es5-ext@npm:~0.10.2, es5-ext@npm:~0.10.46": version: 0.10.64 resolution: "es5-ext@npm:0.10.64" @@ -4879,6 +5545,13 @@ __metadata: languageName: node linkType: hard +"es6-error@npm:^4.1.1": + version: 4.1.1 + resolution: "es6-error@npm:4.1.1" + checksum: 10/48483c25701dc5a6376f39bbe2eaf5da0b505607ec5a98cd3ade472c1939242156660636e2e508b33211e48e88b132d245341595c067bd4a95ac79fa7134da06 + languageName: node + linkType: hard + "es6-iterator@npm:^2.0.3": version: 2.0.3 resolution: "es6-iterator@npm:2.0.3" @@ -5348,6 +6021,30 @@ __metadata: languageName: node linkType: hard +"extract-zip@npm:^2.0.1": + version: 2.0.1 + resolution: "extract-zip@npm:2.0.1" + dependencies: + "@types/yauzl": "npm:^2.9.1" + debug: "npm:^4.1.1" + get-stream: "npm:^5.1.0" + yauzl: "npm:^2.10.0" + dependenciesMeta: + "@types/yauzl": + optional: true + bin: + extract-zip: cli.js + checksum: 10/8cbda9debdd6d6980819cc69734d874ddd71051c9fe5bde1ef307ebcedfe949ba57b004894b585f758b7c9eeeea0e3d87f2dda89b7d25320459c2c9643ebb635 + languageName: node + linkType: hard + +"extsprintf@npm:^1.2.0": + version: 1.4.1 + resolution: "extsprintf@npm:1.4.1" + checksum: 10/bfd6d55f3c0c04d826fe0213264b383c03f32825af6b1ff777f3f2dc49467e599361993568d75b7b19a8ea1bb08c8e7cd8c3d87d179ced91bb0dcf81ca6938e0 + languageName: node + linkType: hard + "fast-clone@npm:^1.5.13": version: 1.5.13 resolution: "fast-clone@npm:1.5.13" @@ -5369,6 +6066,13 @@ __metadata: languageName: node linkType: hard +"fast-fifo@npm:^1.1.0, fast-fifo@npm:^1.2.0": + version: 1.3.2 + resolution: "fast-fifo@npm:1.3.2" + checksum: 10/6bfcba3e4df5af7be3332703b69a7898a8ed7020837ec4395bb341bd96cc3a6d86c3f6071dd98da289618cf2234c70d84b2a6f09a33dd6f988b1ff60d8e54275 + languageName: node + linkType: hard + "fast-glob@npm:3.2.7": version: 3.2.7 resolution: "fast-glob@npm:3.2.7" @@ -5436,6 +6140,15 @@ __metadata: languageName: node linkType: hard +"fd-slicer@npm:~1.1.0": + version: 1.1.0 + resolution: "fd-slicer@npm:1.1.0" + dependencies: + pend: "npm:~1.2.0" + checksum: 10/db3e34fa483b5873b73f248e818f8a8b59a6427fd8b1436cd439c195fdf11e8659419404826059a642b57d18075c856d06d6a50a1413b714f12f833a9341ead3 + languageName: node + linkType: hard + "fecha@npm:^4.2.0": version: 4.2.3 resolution: "fecha@npm:4.2.3" @@ -5633,7 +6346,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:9.1.0, fs-extra@npm:^9.1.0": +"fs-extra@npm:9.1.0, fs-extra@npm:^9.0.0, fs-extra@npm:^9.0.1, fs-extra@npm:^9.1.0": version: 9.1.0 resolution: "fs-extra@npm:9.1.0" dependencies: @@ -5645,6 +6358,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0": + version: 10.1.0 + resolution: "fs-extra@npm:10.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10/05ce2c3b59049bcb7b52001acd000e44b3c4af4ec1f8839f383ef41ec0048e3cfa7fd8a637b1bddfefad319145db89be91f4b7c1db2908205d38bf91e7d1d3b7 + languageName: node + linkType: hard + "fs-extra@npm:^11.1.0": version: 11.2.0 resolution: "fs-extra@npm:11.2.0" @@ -5656,6 +6380,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:^8.1.0": + version: 8.1.0 + resolution: "fs-extra@npm:8.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^4.0.0" + universalify: "npm:^0.1.0" + checksum: 10/6fb12449f5349be724a138b4a7b45fe6a317d2972054517f5971959c26fbd17c0e145731a11c7324460262baa33e0a799b183ceace98f7a372c95fbb6f20f5de + languageName: node + linkType: hard + "fs-minipass@npm:^2.0.0, fs-minipass@npm:^2.1.0": version: 2.1.0 resolution: "fs-minipass@npm:2.1.0" @@ -5707,6 +6442,13 @@ __metadata: languageName: node linkType: hard +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10/185e20d20f10c8d661d59aac0f3b63b31132d492e1b11fcc2a93cb2c47257ebaee7407c38513efd2b35cafdf972d9beb2ea4593c1e0f3bf8f2744836928d7454 + languageName: node + linkType: hard + "gauge@npm:^4.0.3": version: 4.0.4 resolution: "gauge@npm:4.0.4" @@ -5771,6 +6513,19 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4": + version: 1.2.4 + resolution: "get-intrinsic@npm:1.2.4" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + has-proto: "npm:^1.0.1" + has-symbols: "npm:^1.0.3" + hasown: "npm:^2.0.0" + checksum: 10/85bbf4b234c3940edf8a41f4ecbd4e25ce78e5e6ad4e24ca2f77037d983b9ef943fd72f00f3ee97a49ec622a506b67db49c36246150377efcda1c9eb03e5f06d + languageName: node + linkType: hard + "get-package-type@npm:^0.1.0": version: 0.1.0 resolution: "get-package-type@npm:0.1.0" @@ -5960,6 +6715,21 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.0.0": + version: 10.3.15 + resolution: "glob@npm:10.3.15" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^2.3.6" + minimatch: "npm:^9.0.1" + minipass: "npm:^7.0.4" + path-scurry: "npm:^1.11.0" + bin: + glob: dist/esm/bin.mjs + checksum: 10/b2b1c74309979b34fd6010afb50418a12525def32f1d3758d5827fc75d6143fc3ee5d1f3180a43111f6386c9e297c314f208d9d09955a6c6b69f22e92ee97635 + languageName: node + linkType: hard + "glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": version: 10.3.10 resolution: "glob@npm:10.3.10" @@ -6014,6 +6784,20 @@ __metadata: languageName: node linkType: hard +"global-agent@npm:^3.0.0": + version: 3.0.0 + resolution: "global-agent@npm:3.0.0" + dependencies: + boolean: "npm:^3.0.1" + es6-error: "npm:^4.1.1" + matcher: "npm:^3.0.0" + roarr: "npm:^2.15.3" + semver: "npm:^7.3.2" + serialize-error: "npm:^7.0.1" + checksum: 10/a26d96d1d79af57a8ef957f66cef6f3889a8fa55131f0bbd72b8e1bc340a9b7ed7b627b96eaf5eb14aee08a8b4ad44395090e2cf77146e993f1d2df7abaa0a0d + languageName: node + linkType: hard + "globals@npm:^11.1.0": version: 11.12.0 resolution: "globals@npm:11.12.0" @@ -6030,6 +6814,16 @@ __metadata: languageName: node linkType: hard +"globalthis@npm:^1.0.1": + version: 1.0.4 + resolution: "globalthis@npm:1.0.4" + dependencies: + define-properties: "npm:^1.2.1" + gopd: "npm:^1.0.1" + checksum: 10/1f1fd078fb2f7296306ef9dd51019491044ccf17a59ed49d375b576ca108ff37e47f3d29aead7add40763574a992f16a5367dd1e2173b8634ef18556ab719ac4 + languageName: node + linkType: hard + "globby@npm:11.1.0, globby@npm:^11.0.1, globby@npm:^11.1.0": version: 11.1.0 resolution: "globby@npm:11.1.0" @@ -6044,7 +6838,16 @@ __metadata: languageName: node linkType: hard -"got@npm:^11.8.6": +"gopd@npm:^1.0.1": + version: 1.0.1 + resolution: "gopd@npm:1.0.1" + dependencies: + get-intrinsic: "npm:^1.1.3" + checksum: 10/5fbc7ad57b368ae4cd2f41214bd947b045c1a4be2f194a7be1778d71f8af9dbf4004221f3b6f23e30820eb0d052b4f819fe6ebe8221e2a3c6f0ee4ef173421ca + languageName: node + linkType: hard + +"got@npm:^11.8.5, got@npm:^11.8.6": version: 11.8.6 resolution: "got@npm:11.8.6" dependencies: @@ -6123,6 +6926,22 @@ __metadata: languageName: node linkType: hard +"has-property-descriptors@npm:^1.0.0": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" + dependencies: + es-define-property: "npm:^1.0.0" + checksum: 10/2d8c9ab8cebb572e3362f7d06139a4592105983d4317e68f7adba320fe6ddfc8874581e0971e899e633fd5f72e262830edce36d5a0bc863dad17ad20572484b2 + languageName: node + linkType: hard + +"has-proto@npm:^1.0.1": + version: 1.0.3 + resolution: "has-proto@npm:1.0.3" + checksum: 10/0b67c2c94e3bea37db3e412e3c41f79d59259875e636ba471e94c009cdfb1fa82bf045deeffafc7dbb9c148e36cae6b467055aaa5d9fad4316e11b41e3ba551a + languageName: node + linkType: hard + "has-symbols@npm:^1.0.2, has-symbols@npm:^1.0.3": version: 1.0.3 resolution: "has-symbols@npm:1.0.3" @@ -6155,6 +6974,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.0": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10/7898a9c1788b2862cf0f9c345a6bec77ba4a0c0983c7f19d610c382343d4f98fa260686b225dfb1f88393a66679d2ec58ee310c1d6868c081eda7918f32cc70a + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -6171,7 +6999,7 @@ __metadata: languageName: node linkType: hard -"hosted-git-info@npm:^4.0.0, hosted-git-info@npm:^4.0.1": +"hosted-git-info@npm:^4.0.0, hosted-git-info@npm:^4.0.1, hosted-git-info@npm:^4.1.0": version: 4.1.0 resolution: "hosted-git-info@npm:4.1.0" dependencies: @@ -6286,7 +7114,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0": +"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -6345,6 +7173,16 @@ __metadata: languageName: node linkType: hard +"iconv-corefoundation@npm:^1.1.7": + version: 1.1.7 + resolution: "iconv-corefoundation@npm:1.1.7" + dependencies: + cli-truncate: "npm:^2.1.0" + node-addon-api: "npm:^1.6.3" + conditions: os=darwin + languageName: node + linkType: hard + "iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -6620,6 +7458,17 @@ __metadata: languageName: node linkType: hard +"is-ci@npm:^3.0.0": + version: 3.0.1 + resolution: "is-ci@npm:3.0.1" + dependencies: + ci-info: "npm:^3.2.0" + bin: + is-ci: bin.js + checksum: 10/192c66dc7826d58f803ecae624860dccf1899fc1f3ac5505284c0a5cf5f889046ffeb958fa651e5725d5705c5bcb14f055b79150ea5fcad7456a9569de60260e + languageName: node + linkType: hard + "is-core-module@npm:2.9.0": version: 2.9.0 resolution: "is-core-module@npm:2.9.0" @@ -6797,7 +7646,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0": +"is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: 10/b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 @@ -6850,6 +7699,20 @@ __metadata: languageName: node linkType: hard +"isbinaryfile@npm:^4.0.8": + version: 4.0.10 + resolution: "isbinaryfile@npm:4.0.10" + checksum: 10/7f9dbf3e992a020cd3e6845ba49b47de93cda19edadf338bbf82f1453d7a14a73c390ea7f18a1940f09324089e470cce9ea001bd544aea52df641a658ed51c54 + languageName: node + linkType: hard + +"isbinaryfile@npm:^5.0.0": + version: 5.0.2 + resolution: "isbinaryfile@npm:5.0.2" + checksum: 10/515d7c963b35c2c443457d18c9152d1f655f3a0e2dceb548448e482145c1897e57a92fc024dece7de98c85c2909f5528e34e3d720c307887529cd689d7a7cd36 + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -6936,7 +7799,7 @@ __metadata: languageName: node linkType: hard -"jackspeak@npm:^2.3.5": +"jackspeak@npm:^2.3.5, jackspeak@npm:^2.3.6": version: 2.3.6 resolution: "jackspeak@npm:2.3.6" dependencies: @@ -7550,7 +8413,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.2.2, json5@npm:^2.2.3": +"json5@npm:^2.2.0, json5@npm:^2.2.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -7566,6 +8429,18 @@ __metadata: languageName: node linkType: hard +"jsonfile@npm:^4.0.0": + version: 4.0.0 + resolution: "jsonfile@npm:4.0.0" + dependencies: + graceful-fs: "npm:^4.1.6" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10/17796f0ab1be8479827d3683433f97ebe0a1c6932c3360fa40348eac36904d69269aab26f8b16da311882d94b42e9208e8b28e490bf926364f3ac9bff134c226 + languageName: node + linkType: hard + "jsonfile@npm:^6.0.1": version: 6.1.0 resolution: "jsonfile@npm:6.1.0" @@ -7744,6 +8619,22 @@ __metadata: languageName: node linkType: hard +"lazy-val@npm:^1.0.4, lazy-val@npm:^1.0.5": + version: 1.0.5 + resolution: "lazy-val@npm:1.0.5" + checksum: 10/31e12e0b118826dfae74f8f3ff8ebcddfe4200ff88d0d448db175c7265ee537e0ba55488d411728246337f3ed3c9ec68416f10889f632a2ce28fb7a970909fb5 + languageName: node + linkType: hard + +"lazystream@npm:^1.0.0": + version: 1.0.1 + resolution: "lazystream@npm:1.0.1" + dependencies: + readable-stream: "npm:^2.0.5" + checksum: 10/35f8cf8b5799c76570b211b079d4d706a20cbf13a4936d44cc7dbdacab1de6b346ab339ed3e3805f4693155ee5bbebbda4050fa2b666d61956e89a573089e3d4 + languageName: node + linkType: hard + "lerna@npm:^6.6.1": version: 6.6.2 resolution: "lerna@npm:6.6.2" @@ -8097,7 +8988,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21": +"lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: 10/c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 @@ -8166,6 +9057,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^10.2.0": + version: 10.2.2 + resolution: "lru-cache@npm:10.2.2" + checksum: 10/ff1a496d30b5eaec2c9079080965bb0cede203cf878371f7033a007f1e54cd4aa13cc8abf7ccec4c994a83a22ed5476e83a55bb57cc07e6c1547a42937e42c37 + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -8324,6 +9222,15 @@ __metadata: languageName: node linkType: hard +"matcher@npm:^3.0.0": + version: 3.0.0 + resolution: "matcher@npm:3.0.0" + dependencies: + escape-string-regexp: "npm:^4.0.0" + checksum: 10/8bee1a7ab7609c2c21d9c9254b6785fa708eadf289032b556d57a34e98fcd4c537659a004dafee6ce80ab157099e645c199dc52678dff1e7fb0a6684e0da4dbe + languageName: node + linkType: hard + "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" @@ -8433,6 +9340,15 @@ __metadata: languageName: node linkType: hard +"mime@npm:^2.5.2": + version: 2.6.0 + resolution: "mime@npm:2.6.0" + bin: + mime: cli.js + checksum: 10/7da117808b5cd0203bb1b5e33445c330fe213f4d8ee2402a84d62adbde9716ca4fb90dd6d9ab4e77a4128c6c5c24a9c4c9f6a4d720b095b1b342132d02dba58d + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -8486,7 +9402,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^5.0.1": +"minimatch@npm:^5.0.1, minimatch@npm:^5.1.0, minimatch@npm:^5.1.1": version: 5.1.6 resolution: "minimatch@npm:5.1.6" dependencies: @@ -8664,6 +9580,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^7.0.4": + version: 7.1.1 + resolution: "minipass@npm:7.1.1" + checksum: 10/6f4f920f1b5ea585d08fa3739b9bd81726cd85a0c972fb371c0fa6c1544d468813fb1694c7bc64ad81f138fd8abf665e2af0f406de9ba5741d8e4a377ed346b1 + languageName: node + linkType: hard + "minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -8692,7 +9615,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.1": +"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.6": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -8832,6 +9755,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^1.6.3": + version: 1.7.2 + resolution: "node-addon-api@npm:1.7.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10/6bf8217a8cd8148f4bbfd319b46d33587e9fb2e63e3c856ded67a76715167f7a6b17e1d9b8bbf3b8508befeb6a4adb10d92b8998ed5c19ca8448343f4cea11d6 + languageName: node + linkType: hard + "node-addon-api@npm:^3.2.1": version: 3.2.1 resolution: "node-addon-api@npm:3.2.1" @@ -9348,6 +10280,13 @@ __metadata: languageName: node linkType: hard +"object-keys@npm:^1.1.1": + version: 1.1.1 + resolution: "object-keys@npm:1.1.1" + checksum: 10/3d81d02674115973df0b7117628ea4110d56042e5326413e4b4313f0bcdf7dd78d4a3acef2c831463fa3796a66762c49daef306f4a0ea1af44877d7086d73bde + languageName: node + linkType: hard + "on-finished@npm:^2.3.0": version: 2.4.1 resolution: "on-finished@npm:2.4.1" @@ -9846,6 +10785,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^1.11.0": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" + dependencies: + lru-cache: "npm:^10.2.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: 10/5e8845c159261adda6f09814d7725683257fcc85a18f329880ab4d7cc1d12830967eae5d5894e453f341710d5484b8fdbbd4d75181b4d6e1eb2f4dc7aeadc434 + languageName: node + linkType: hard + "path-to-regexp@npm:1.x": version: 1.8.0 resolution: "path-to-regexp@npm:1.8.0" @@ -9887,6 +10836,13 @@ __metadata: languageName: node linkType: hard +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 10/6c72f5243303d9c60bd98e6446ba7d30ae29e3d56fdb6fae8767e8ba6386f33ee284c97efe3230a0d0217e2b1723b8ab490b1bbf34fcbb2180dbc8a9de47850d + languageName: node + linkType: hard + "picocolors@npm:^1.0.0": version: 1.0.0 resolution: "picocolors@npm:1.0.0" @@ -9966,6 +10922,28 @@ __metadata: languageName: node linkType: hard +"plist@npm:^3.0.4, plist@npm:^3.0.5": + version: 3.1.0 + resolution: "plist@npm:3.1.0" + dependencies: + "@xmldom/xmldom": "npm:^0.8.8" + base64-js: "npm:^1.5.1" + xmlbuilder: "npm:^15.1.1" + checksum: 10/f513beecc01a021b4913d4e5816894580b284335ae437e7ed2d5e78f8b6f0d2e0f874ec57bab9c9d424cc49e77b8347efa75abcfa8ac138dbfb63a045e1ce559 + languageName: node + linkType: hard + +"portfinder@npm:^1.0.32": + version: 1.0.32 + resolution: "portfinder@npm:1.0.32" + dependencies: + async: "npm:^2.6.4" + debug: "npm:^3.2.7" + mkdirp: "npm:^0.5.6" + checksum: 10/842058052fb3c3da829589f3f44b13369cf504b16f6ab72fedec78a9438ac3fc53047f5c88a771511b17d6a94f50f83a94cef5fa625027b675d8f7241f7f2185 + languageName: node + linkType: hard + "postcss-selector-parser@npm:^6.0.10": version: 6.0.12 resolution: "postcss-selector-parser@npm:6.0.12" @@ -10216,6 +11194,13 @@ __metadata: languageName: node linkType: hard +"queue-tick@npm:^1.0.1": + version: 1.0.1 + resolution: "queue-tick@npm:1.0.1" + checksum: 10/f447926c513b64a857906f017a3b350f7d11277e3c8d2a21a42b7998fa1a613d7a829091e12d142bb668905c8f68d8103416c7197856efb0c72fa835b8e254b5 + languageName: node + linkType: hard + "quick-lru@npm:^4.0.1": version: 4.0.1 resolution: "quick-lru@npm:4.0.1" @@ -10284,6 +11269,20 @@ __metadata: languageName: node linkType: hard +"read-config-file@npm:6.3.2": + version: 6.3.2 + resolution: "read-config-file@npm:6.3.2" + dependencies: + config-file-ts: "npm:^0.2.4" + dotenv: "npm:^9.0.2" + dotenv-expand: "npm:^5.1.0" + js-yaml: "npm:^4.1.0" + json5: "npm:^2.2.0" + lazy-val: "npm:^1.0.4" + checksum: 10/c3a6444105fc1736d6fa15979d1d18e9f0a1165bf3966f1751af676d153f92df9fc7c07158162b62d222919e561e135bdd6155c6fff79f1ed8b78a5a394a579b + languageName: node + linkType: hard + "read-installed@npm:~4.0.3": version: 4.0.3 resolution: "read-installed@npm:4.0.3" @@ -10472,6 +11471,34 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^2.0.5": + version: 2.3.8 + resolution: "readable-stream@npm:2.3.8" + dependencies: + core-util-is: "npm:~1.0.0" + inherits: "npm:~2.0.3" + isarray: "npm:~1.0.0" + process-nextick-args: "npm:~2.0.0" + safe-buffer: "npm:~5.1.1" + string_decoder: "npm:~1.1.1" + util-deprecate: "npm:~1.0.1" + checksum: 10/8500dd3a90e391d6c5d889256d50ec6026c059fadee98ae9aa9b86757d60ac46fff24fafb7a39fa41d54cb39d8be56cc77be202ebd4cd8ffcf4cb226cbaa40d4 + languageName: node + linkType: hard + +"readable-stream@npm:^4.0.0": + version: 4.5.2 + resolution: "readable-stream@npm:4.5.2" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10/01b128a559c5fd76a898495f858cf0a8839f135e6a69e3409f986e88460134791657eb46a2ff16826f331682a3c4d0c5a75cef5e52ef259711021ba52b1c2e82 + languageName: node + linkType: hard + "readable-stream@npm:^4.1.0": version: 4.3.0 resolution: "readable-stream@npm:4.3.0" @@ -10484,6 +11511,15 @@ __metadata: languageName: node linkType: hard +"readdir-glob@npm:^1.1.2": + version: 1.1.3 + resolution: "readdir-glob@npm:1.1.3" + dependencies: + minimatch: "npm:^5.1.0" + checksum: 10/ca3a20aa1e715d671302d4ec785a32bf08e59d6d0dd25d5fc03e9e5a39f8c612cdf809ab3e638a79973db7ad6868492edf38504701e313328e767693671447d6 + languageName: node + linkType: hard + "readdir-scoped-modules@npm:^1.0.0": version: 1.1.0 resolution: "readdir-scoped-modules@npm:1.1.0" @@ -10699,6 +11735,20 @@ __metadata: languageName: node linkType: hard +"roarr@npm:^2.15.3": + version: 2.15.4 + resolution: "roarr@npm:2.15.4" + dependencies: + boolean: "npm:^3.0.1" + detect-node: "npm:^2.0.4" + globalthis: "npm:^1.0.1" + json-stringify-safe: "npm:^5.0.1" + semver-compare: "npm:^1.0.0" + sprintf-js: "npm:^1.1.2" + checksum: 10/baaa5ad91468bf1b7f0263c4132a40865c8638a3d0916b44dd0d42980a77fb53085a3792e3edf16fc4eea9e31c719793c88bd45b1623b760763c4dc59df97619 + languageName: node + linkType: hard + "run-async@npm:^2.4.0": version: 2.4.1 resolution: "run-async@npm:2.4.1" @@ -10752,6 +11802,15 @@ __metadata: languageName: node linkType: hard +"sanitize-filename@npm:^1.6.3": + version: 1.6.3 + resolution: "sanitize-filename@npm:1.6.3" + dependencies: + truncate-utf8-bytes: "npm:^1.0.0" + checksum: 10/1c162e2cffa797571221c3ed9fe796fa8c6eabb0812418b52a839e4fc63ab130093eb546ec39e1b94b8d3511c0d7de81db3e67906a7e76d7a7bcb6fbab4ed961 + languageName: node + linkType: hard + "sax@npm:>=0.6.0, sax@npm:^1.2.4": version: 1.2.4 resolution: "sax@npm:1.2.4" @@ -10759,6 +11818,13 @@ __metadata: languageName: node linkType: hard +"semver-compare@npm:^1.0.0": + version: 1.0.0 + resolution: "semver-compare@npm:1.0.0" + checksum: 10/75f9c7a7786d1756f64b1429017746721e07bd7691bdad6368f7643885d3a98a27586777e9699456564f4844b407e9f186cc1d588a3f9c0be71310e517e942c3 + languageName: node + linkType: hard + "semver@npm:2 || 3 || 4 || 5, semver@npm:^5.5.0, semver@npm:^5.6.0": version: 5.7.2 resolution: "semver@npm:5.7.2" @@ -10790,7 +11856,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.0.0, semver@npm:^6.1.0, semver@npm:^6.3.0, semver@npm:^6.3.1": +"semver@npm:^6.0.0, semver@npm:^6.1.0, semver@npm:^6.2.0, semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -10810,6 +11876,24 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.3.2": + version: 7.6.2 + resolution: "semver@npm:7.6.2" + bin: + semver: bin/semver.js + checksum: 10/296b17d027f57a87ef645e9c725bff4865a38dfc9caf29b26aa084b85820972fbe7372caea1ba6857162fa990702c6d9c1d82297cecb72d56c78ab29070d2ca2 + languageName: node + linkType: hard + +"serialize-error@npm:^7.0.1": + version: 7.0.1 + resolution: "serialize-error@npm:7.0.1" + dependencies: + type-fest: "npm:^0.13.1" + checksum: 10/e0aba4dca2fc9fe74ae1baf38dbd99190e1945445a241ba646290f2176cdb2032281a76443b02ccf0caf30da5657d510746506368889a593b9835a497fc0732e + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -10930,6 +12014,15 @@ __metadata: languageName: node linkType: hard +"simple-update-notifier@npm:2.0.0": + version: 2.0.0 + resolution: "simple-update-notifier@npm:2.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10/40bd4f96aa89aedbf717ae9f4ab8fca70e8f7511e8b766feb15471cca3f6fe4fe673743309b08b4ba8abfe0965c9cd927e1de46550a757b819b70fc7430cc85d + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -10993,7 +12086,7 @@ __metadata: languageName: node linkType: hard -"smart-buffer@npm:^4.2.0": +"smart-buffer@npm:^4.0.2, smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" checksum: 10/927484aa0b1640fd9473cee3e0a0bcad6fce93fd7bbc18bac9ad0c33686f5d2e2c422fba24b5899c184524af01e11dd2bd051c2bf2b07e47aff8ca72cbfc60d2 @@ -11061,6 +12154,16 @@ __metadata: languageName: node linkType: hard +"source-map-support@npm:^0.5.19": + version: 0.5.21 + resolution: "source-map-support@npm:0.5.21" + dependencies: + buffer-from: "npm:^1.0.0" + source-map: "npm:^0.6.0" + checksum: 10/8317e12d84019b31e34b86d483dd41d6f832f389f7417faf8fc5c75a66a12d9686e47f589a0554a868b8482f037e23df9d040d29387eb16fa14cb85f091ba207 + languageName: node + linkType: hard + "source-map@npm:^0.6.0, source-map@npm:^0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" @@ -11149,7 +12252,7 @@ __metadata: languageName: node linkType: hard -"sprintf-js@npm:^1.1.3": +"sprintf-js@npm:^1.1.2, sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" checksum: 10/e7587128c423f7e43cc625fe2f87e6affdf5ca51c1cc468e910d8aaca46bb44a7fbcfa552f787b1d3987f7043aeb4527d1b99559e6621e01b42b3f45e5a24cbb @@ -11204,6 +12307,13 @@ __metadata: languageName: node linkType: hard +"stat-mode@npm:^1.0.0": + version: 1.0.0 + resolution: "stat-mode@npm:1.0.0" + checksum: 10/a7eac989332f4d057997225af77be14428789821bfbcadd9bdd67e40c73b9d0f9e0fead7171a0e4a8c4366564adcf1d463b16e71c68af8694af3d3ee1b5f88ed + languageName: node + linkType: hard + "statuses@npm:2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1" @@ -11241,6 +12351,20 @@ __metadata: languageName: node linkType: hard +"streamx@npm:^2.15.0": + version: 2.16.1 + resolution: "streamx@npm:2.16.1" + dependencies: + bare-events: "npm:^2.2.0" + fast-fifo: "npm:^1.1.0" + queue-tick: "npm:^1.0.1" + dependenciesMeta: + bare-events: + optional: true + checksum: 10/f6d0899adf089385d9c58a630fc705dc6c3931b18181c32860e5013955a339a3b763a4df62168f37c7fc56b1f7bb2a38db989fa9df487995278cb5d46f248da6 + languageName: node + linkType: hard + "string-argv@npm:0.3.2": version: 0.3.2 resolution: "string-argv@npm:0.3.2" @@ -11298,7 +12422,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -11416,6 +12540,15 @@ __metadata: languageName: node linkType: hard +"sumchecker@npm:^3.0.1": + version: 3.0.1 + resolution: "sumchecker@npm:3.0.1" + dependencies: + debug: "npm:^4.1.0" + checksum: 10/5c69776ce2b53040c952cfbca0f7b487b1ee993c55b6d5523f674ec075f30e031fd84b6706dc8ccc4deb9761b58f9925be8806a316e5eedff2286bb48cb75044 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -11475,6 +12608,17 @@ __metadata: languageName: node linkType: hard +"tar-stream@npm:^3.0.0": + version: 3.1.7 + resolution: "tar-stream@npm:3.1.7" + dependencies: + b4a: "npm:^1.6.4" + fast-fifo: "npm:^1.2.0" + streamx: "npm:^2.15.0" + checksum: 10/b21a82705a72792544697c410451a4846af1f744176feb0ff11a7c3dd0896961552e3def5e1c9a6bbee4f0ae298b8252a1f4c9381e9f991553b9e4847976f05c + languageName: node + linkType: hard + "tar@npm:6.1.11": version: 6.1.11 resolution: "tar@npm:6.1.11" @@ -11503,6 +12647,20 @@ __metadata: languageName: node linkType: hard +"tar@npm:^6.1.12": + version: 6.2.1 + resolution: "tar@npm:6.2.1" + dependencies: + chownr: "npm:^2.0.0" + fs-minipass: "npm:^2.0.0" + minipass: "npm:^5.0.0" + minizlib: "npm:^2.1.1" + mkdirp: "npm:^1.0.3" + yallist: "npm:^4.0.0" + checksum: 10/bfbfbb2861888077fc1130b84029cdc2721efb93d1d1fb80f22a7ac3a98ec6f8972f29e564103bbebf5e97be67ebc356d37fa48dbc4960600a1eb7230fbd1ea0 + languageName: node + linkType: hard + "temp-dir@npm:1.0.0": version: 1.0.0 resolution: "temp-dir@npm:1.0.0" @@ -11517,6 +12675,16 @@ __metadata: languageName: node linkType: hard +"temp-file@npm:^3.4.0": + version: 3.4.0 + resolution: "temp-file@npm:3.4.0" + dependencies: + async-exit-hook: "npm:^2.0.1" + fs-extra: "npm:^10.0.0" + checksum: 10/5b7132ff488e91ae928c3e81b25e308d8fc590c08a08fb37b0f1c1e8d186e65d69472abd1af1ea11e1162db2a2e6a7970214c827c92c7c6cebbc91bb7678b037 + languageName: node + linkType: hard + "tempy@npm:1.0.0": version: 1.0.0 resolution: "tempy@npm:1.0.0" @@ -11637,6 +12805,15 @@ __metadata: languageName: node linkType: hard +"tmp-promise@npm:^3.0.2": + version: 3.0.3 + resolution: "tmp-promise@npm:3.0.3" + dependencies: + tmp: "npm:^0.2.0" + checksum: 10/0ca65b4f233b1d2b01e17a7a62961d32923e4b27383a370bf4d8d52f1062d79c3250e6b6b706ec390e73c9c58c13dc130b3855eedc89c86c7d90beb28b8382e5 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -11646,7 +12823,7 @@ __metadata: languageName: node linkType: hard -"tmp@npm:~0.2.1": +"tmp@npm:^0.2.0, tmp@npm:~0.2.1": version: 0.2.3 resolution: "tmp@npm:0.2.3" checksum: 10/7b13696787f159c9754793a83aa79a24f1522d47b87462ddb57c18ee93ff26c74cbb2b8d9138f571d2e0e765c728fb2739863a672b280528512c6d83d511c6fa @@ -11732,6 +12909,15 @@ __metadata: languageName: node linkType: hard +"truncate-utf8-bytes@npm:^1.0.0": + version: 1.0.2 + resolution: "truncate-utf8-bytes@npm:1.0.2" + dependencies: + utf8-byte-length: "npm:^1.0.1" + checksum: 10/366e47a0e22cc271d37eb4e62820453fb877784b55b37218842758b7aa1d402eedd0f8833cfb5d6f7a6cae1535d84289bd5e32c4ee962d2a86962fb7038a6983 + languageName: node + linkType: hard + "ts-essentials@npm:^7.0.3": version: 7.0.3 resolution: "ts-essentials@npm:7.0.3" @@ -11877,6 +13063,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.13.1": + version: 0.13.1 + resolution: "type-fest@npm:0.13.1" + checksum: 10/11e9476dc85bf97a71f6844fb67ba8e64a4c7e445724c0f3bd37eb2ddf4bc97c1dc9337bd880b28bce158de1c0cb275c2d03259815a5bf64986727197126ab56 + languageName: node + linkType: hard + "type-fest@npm:^0.16.0": version: 0.16.0 resolution: "type-fest@npm:0.16.0" @@ -11974,6 +13167,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.3.3": + version: 5.4.5 + resolution: "typescript@npm:5.4.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/d04a9e27e6d83861f2126665aa8d84847e8ebabcea9125b9ebc30370b98cb38b5dff2508d74e2326a744938191a83a69aa9fddab41f193ffa43eabfdf3f190a5 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^3 || ^4#optional!builtin, typescript@patch:typescript@npm%3A~4.9#optional!builtin": version: 4.9.5 resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin::version=4.9.5&hash=289587" @@ -11984,6 +13187,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.3.3#optional!builtin": + version: 5.4.5 + resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/760f7d92fb383dbf7dee2443bf902f4365db2117f96f875cf809167f6103d55064de973db9f78fe8f31ec08fff52b2c969aee0d310939c0a3798ec75d0bca2e1 + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.16.2 resolution: "uglify-js@npm:3.16.2" @@ -12066,6 +13279,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.1.0": + version: 0.1.2 + resolution: "universalify@npm:0.1.2" + checksum: 10/40cdc60f6e61070fe658ca36016a8f4ec216b29bf04a55dce14e3710cc84c7448538ef4dad3728d0bfe29975ccd7bfb5f414c45e7b78883567fb31b246f02dff + languageName: node + linkType: hard + "universalify@npm:^2.0.0": version: 2.0.0 resolution: "universalify@npm:2.0.0" @@ -12124,6 +13344,13 @@ __metadata: languageName: node linkType: hard +"utf8-byte-length@npm:^1.0.1": + version: 1.0.5 + resolution: "utf8-byte-length@npm:1.0.5" + checksum: 10/168edff8f7baca974b5bfb5256cebd57deaef8fbf2d0390301dd1009da52de64774d62f088254c94021e372147b6c938aa82f2318a3a19f9ebd21e48b7f40029 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1, util-deprecate@npm:^1.0.2, util-deprecate@npm:~1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -12209,6 +13436,17 @@ __metadata: languageName: node linkType: hard +"verror@npm:^1.10.0": + version: 1.10.1 + resolution: "verror@npm:1.10.1" + dependencies: + assert-plus: "npm:^1.0.0" + core-util-is: "npm:1.0.2" + extsprintf: "npm:^1.2.0" + checksum: 10/2b24eb830ecee7be69ab84192946074d7e8a85a42b0784d9874a28f52616065953ab4297975c87045879505fe9a6ac38dedad29a7470bbe0324540e5b6e92f62 + languageName: node + linkType: hard + "walk-up-path@npm:^1.0.0": version: 1.0.0 resolution: "walk-up-path@npm:1.0.0" @@ -12534,6 +13772,13 @@ __metadata: languageName: node linkType: hard +"xmlbuilder@npm:>=11.0.1, xmlbuilder@npm:^15.1.1": + version: 15.1.1 + resolution: "xmlbuilder@npm:15.1.1" + checksum: 10/e6f4bab2504afdd5f80491bda948894d2146756532521dbe7db33ae0931cd3000e3b4da19b3f5b3f51bedbd9ee06582144d28136d68bd1df96579ecf4d4404a2 + languageName: node + linkType: hard + "xmlbuilder@npm:~11.0.0": version: 11.0.1 resolution: "xmlbuilder@npm:11.0.1" @@ -12641,6 +13886,16 @@ __metadata: languageName: node linkType: hard +"yauzl@npm:^2.10.0": + version: 2.10.0 + resolution: "yauzl@npm:2.10.0" + dependencies: + buffer-crc32: "npm:~0.2.3" + fd-slicer: "npm:~1.1.0" + checksum: 10/1e4c311050dc0cf2ee3dbe8854fe0a6cde50e420b3e561a8d97042526b4cf7a0718d6c8d89e9e526a152f4a9cec55bcea9c3617264115f48bd6704cf12a04445 + languageName: node + linkType: hard + "ylru@npm:^1.2.0": version: 1.3.2 resolution: "ylru@npm:1.3.2" @@ -12661,3 +13916,14 @@ __metadata: checksum: 10/2cac84540f65c64ccc1683c267edce396b26b1e931aa429660aefac8fbe0188167b7aee815a3c22fa59a28a58d898d1a2b1825048f834d8d629f4c2a5d443801 languageName: node linkType: hard + +"zip-stream@npm:^6.0.1": + version: 6.0.1 + resolution: "zip-stream@npm:6.0.1" + dependencies: + archiver-utils: "npm:^5.0.0" + compress-commons: "npm:^6.0.2" + readable-stream: "npm:^4.0.0" + checksum: 10/aa5abd6a89590eadeba040afbc375f53337f12637e5e98330012a12d9886cde7a3ccc28bd91aafab50576035bbb1de39a9a316eecf2411c8b9009c9f94f0db27 + languageName: node + linkType: hard From 7d36627323faa030d6bec0506ab4a416b3e49d5d Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 11 Jul 2024 14:54:13 +0200 Subject: [PATCH 11/46] fix: fix issue in HelpfulEventEmitter where it logged false errors --- .../packages/api/src/HelpfulEventEmitter.ts | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/shared/packages/api/src/HelpfulEventEmitter.ts b/shared/packages/api/src/HelpfulEventEmitter.ts index 56060659..57b7e6bf 100644 --- a/shared/packages/api/src/HelpfulEventEmitter.ts +++ b/shared/packages/api/src/HelpfulEventEmitter.ts @@ -10,28 +10,31 @@ export class HelpfulEventEmitter extends EventEmitter { // Ensure that the error event is listened for: const orgError = new Error('No error event listener registered') - setTimeout(() => { - for (const event of this._listenersToCheck) { - if (!this.listenerCount(event)) { - // If no event listener is registered, log a warning to let the developer - // know that they should do so: - console.error(`WARNING: No "${event}" event listener registered`) - console.error(`Stack: ${orgError.stack}`) - - // If we're running in Jest, it's better to make it a little more obvious that something is wrong: - if (process.env.JEST_WORKER_ID !== undefined) { - // Since no error listener is registered, this'll cause the process to exit and tests to fail: - this.emit('error', orgError) + + setImmediate(() => { + setImmediate(() => { + for (const event of this._listenersToCheck) { + if (!this.listenerCount(event)) { + // If no event listener is registered, log a warning to let the developer + // know that they should do so: + console.error(`WARNING: No "${event}" event listener registered`) + console.error(`Stack: ${orgError.stack}`) + + // If we're running in Jest, it's better to make it a little more obvious that something is wrong: + if (process.env.JEST_WORKER_ID !== undefined) { + // Since no error listener is registered, this'll cause the process to exit and tests to fail: + this.emit('error', orgError) + } } } - } - - // If we're running in Jest, it's better to make it a little more obvious that something is wrong: - if (process.env.JEST_WORKER_ID !== undefined && !this.listenerCount('error')) { - // Since no error listener is registered, this'll cause the process to exit and tests to fail: - this.emit('error', orgError) - } - }, 1) + + // If we're running in Jest, it's better to make it a little more obvious that something is wrong: + if (process.env.JEST_WORKER_ID !== undefined && !this.listenerCount('error')) { + // Since no error listener is registered, this'll cause the process to exit and tests to fail: + this.emit('error', orgError) + } + }) + }) } /** From 92c575fa767cb3adba649619c16ca312b0253065 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 11 Jul 2024 14:55:04 +0200 Subject: [PATCH 12/46] fix: allow baseUrl to be optional for HTTP Accessor --- .../api/src/__tests__/pathJoin.spec.ts | 11 ++++++++ shared/packages/api/src/inputApi.ts | 2 +- shared/packages/api/src/pathJoin.ts | 28 +++++++++++++++++++ .../src/worker/accessorHandlers/http.ts | 14 +++------- .../src/worker/accessorHandlers/httpProxy.ts | 2 +- .../accessorHandlers/lib/FileHandler.ts | 2 +- .../src/worker/accessorHandlers/quantel.ts | 2 +- 7 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 shared/packages/api/src/__tests__/pathJoin.spec.ts create mode 100644 shared/packages/api/src/pathJoin.ts diff --git a/shared/packages/api/src/__tests__/pathJoin.spec.ts b/shared/packages/api/src/__tests__/pathJoin.spec.ts new file mode 100644 index 00000000..7cd6eab3 --- /dev/null +++ b/shared/packages/api/src/__tests__/pathJoin.spec.ts @@ -0,0 +1,11 @@ +import { rebaseUrl } from '../pathJoin' +test('rebaseUrl', () => { + expect(rebaseUrl('https://a', 'b')).toBe('https://a/b') + expect(rebaseUrl('https://a/', 'b')).toBe('https://a/b') + expect(rebaseUrl('https://a/', '/b')).toBe('https://a/b') + expect(rebaseUrl('file://a/', '/b/')).toBe('file://a/b/') + expect(rebaseUrl('https:////a/b/', 'c')).toBe('https://a/b/c') + expect(rebaseUrl('https://a/', '//b/')).toBe('https://a/b/') + + expect(rebaseUrl('https://a/b//c/', '/d/e//f/')).toBe('https://a/b/c/d/e/f/') +}) diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index af9f8c99..8f363713 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -326,7 +326,7 @@ export namespace Accessor { allowWrite: false /** Base url (url to the host), for example http://myhost.com/fileShare/ */ - baseUrl: string + baseUrl?: string /** Name/Id of the network the share exists on. Used to differ between different local networks. Leave empty if globally accessible. */ networkId?: string diff --git a/shared/packages/api/src/pathJoin.ts b/shared/packages/api/src/pathJoin.ts new file mode 100644 index 00000000..e6e71fb1 --- /dev/null +++ b/shared/packages/api/src/pathJoin.ts @@ -0,0 +1,28 @@ +export function removeBasePath(basePath: string, addPath: string): string { + addPath = addPath.replace(/\\/g, '/') + basePath = basePath.replace(/\\/g, '/') + + return addPath.replace(new RegExp('^' + escapeRegExp(basePath)), '') +} +function escapeRegExp(text: string): string { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') +} +export function rebaseUrl(baseUrl: string, relativeUrl: string): string { + try { + if (!baseUrl) return new URL(relativeUrl).toString() + const base = new URL(baseUrl) + const relative = new URL(baseUrl) + // relative path may contain URL-unsafe characters, this will encode them but leave any path elements intact + relative.pathname = relativeUrl + // at this point, relativeUrl.pathname will already include the leading `/` + base.pathname = base.pathname + relative.pathname + base.pathname = base.pathname.replace(/\/{2,}/g, '/') // Remove double slashes + return base.toString() + } catch (e) { + // Probably a malformed URL + if (e instanceof Error) { + e.message = `URL: "${baseUrl}" + "${relativeUrl}": ${e.message}` + } + throw e + } +} diff --git a/shared/packages/worker/src/worker/accessorHandlers/http.ts b/shared/packages/worker/src/worker/accessorHandlers/http.ts index 047629ac..2b752230 100644 --- a/shared/packages/worker/src/worker/accessorHandlers/http.ts +++ b/shared/packages/worker/src/worker/accessorHandlers/http.ts @@ -215,14 +215,9 @@ export class HTTPAccessorHandle extends GenericAccessorHandle extends GenericAccessorHandle Date: Thu, 11 Jul 2024 14:56:15 +0200 Subject: [PATCH 13/46] fix: add option to HTTP Accessor to either send a HEAD or GET to retrieve initial info --- shared/packages/api/src/inputApi.ts | 9 +- .../src/worker/accessorHandlers/http.ts | 86 ++++++++++++------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index 8f363713..6a3ac8b8 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -330,6 +330,12 @@ export namespace Accessor { /** Name/Id of the network the share exists on. Used to differ between different local networks. Leave empty if globally accessible. */ networkId?: string + + /** If true, assumes that a source never changes once it has been fetched. */ + isImmutable?: boolean + + /** If true, assumes that the source supports HEAD requests. Otherwise, GET requests will be sent to check availability. */ + supportHEAD?: boolean } /** Definition of access to the HTTP-proxy server that comes with Package Manager. */ export interface HTTPProxy extends Base { @@ -409,9 +415,6 @@ export namespace AccessorOnPackage { export interface HTTP extends Partial { /** URL path to resource (combined with .baseUrl gives the full URL), for example: /folder/myFile */ url?: string - - /** If true, assumes that a source never changes once it has been fetched. */ - isImmutable?: boolean } export interface Quantel extends Partial { guid?: string diff --git a/shared/packages/worker/src/worker/accessorHandlers/http.ts b/shared/packages/worker/src/worker/accessorHandlers/http.ts index 2b752230..763fb04d 100644 --- a/shared/packages/worker/src/worker/accessorHandlers/http.ts +++ b/shared/packages/worker/src/worker/accessorHandlers/http.ts @@ -21,12 +21,12 @@ import { Reason, AccessorId, MonitorId, + rebaseUrl, } from '@sofie-package-manager/api' import { BaseWorker } from '../worker' import { fetchWithController, fetchWithTimeout } from './lib/fetch' import FormData from 'form-data' import { MonitorInProgress } from '../lib/monitorInProgress' -import { rebaseUrl } from './lib/pathJoin' import { defaultCheckHandleRead, defaultCheckHandleWrite } from './lib/lib' /** Accessor handle for accessing files in a local folder */ @@ -253,36 +253,64 @@ export class HTTPAccessorHandle extends GenericAccessorHandle { - const response = await fetchWithController(this.fullUrl, { - method: 'HEAD', - }).response - response.body.on('error', () => { - // Swallow the error. Since we're aborting the request, we're not interested in the body anyway. - }) - const headers: HTTPHeaders = { - contentType: response.headers.get('content-type'), - contentLength: response.headers.get('content-length'), - lastModified: response.headers.get('last-modified'), - etags: response.headers.get('etag'), - } - return { - headers, - status: response.status, - statusText: response.statusText, - } - }, - ttl - ) + if (this.accessor.supportHEAD) { + return await this.worker.cacheData( + this.type, + `HEAD ${this.fullUrl}`, + async () => { + const r = fetchWithController(this.fullUrl, { + method: 'HEAD', + }) + const response = await r.response + response.body.on('error', (e) => { + this.worker.logger.warn(`fetchHeader: Error ${e}`) + }) - return { - status: status, - statusText: statusText, - headers: headers, + const headers: HTTPHeaders = { + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + lastModified: response.headers.get('last-modified'), + etags: response.headers.get('etag'), + } + return { + headers, + status: response.status, + statusText: response.statusText, + } + }, + ttl + ) + } else { + // The source does NOT support HEAD requests, send a GET instead and abort the response: + return await this.worker.cacheData( + this.type, + `GET ${this.fullUrl}`, + async () => { + const r = fetchWithController(this.fullUrl, { + method: 'GET', + }) + const response = await r.response + response.body.on('error', () => { + // Swallow the error. Since we're aborting the request, we're not interested in the body anyway. + }) + + const headers: HTTPHeaders = { + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + lastModified: response.headers.get('last-modified'), + etags: response.headers.get('etag'), + } + // We're not interested in the actual body, so abort the request: + r.controller.abort() + return { + headers, + status: response.status, + statusText: response.statusText, + } + }, + ttl + ) } } From 7ee0aa7c33fe4b6b454e72773e49d50717826e87 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 11 Jul 2024 14:59:26 +0200 Subject: [PATCH 14/46] chore: use prerelease R50.4 Core types --- apps/html-renderer/app/package.json | 2 +- .../packages/generic/src/coreHandler.ts | 4 +-- .../packages/generic/src/packageManager.ts | 2 +- package.json | 4 +-- yarn.lock | 34 +++++++++---------- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/html-renderer/app/package.json b/apps/html-renderer/app/package.json index 7275a727..f03ff85d 100644 --- a/apps/html-renderer/app/package.json +++ b/apps/html-renderer/app/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@html-renderer/generic": "1.50.5", - "@sofie-automation/shared-lib": "1.50.0", + "@sofie-automation/shared-lib": "1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0", "@sofie-package-manager/api": "1.50.5", "portfinder": "^1.0.32", "tslib": "^2.1.0", diff --git a/apps/package-manager/packages/generic/src/coreHandler.ts b/apps/package-manager/packages/generic/src/coreHandler.ts index 4a717b6c..c1f89d84 100644 --- a/apps/package-manager/packages/generic/src/coreHandler.ts +++ b/apps/package-manager/packages/generic/src/coreHandler.ts @@ -63,7 +63,7 @@ export interface CoreConfig { */ export class CoreHandler { private logger: LoggerInstance - public _observers: Array = [] + public _observers: Array> = [] public deviceSettings: { [key: string]: any } = {} public delayRemoval = 0 @@ -332,7 +332,7 @@ export class CoreHandler { retireExecuteFunction(cmdId: string): void { delete this._executedFunctions[cmdId] } - observe(collectionName: string): Observer { + observe(collectionName: string): Observer { if (!this.core && this.notUsingCore) throw new Error('core.observe called, even though notUsingCore is true.') if (!this.core) throw new Error('Core not initialized!') return this.core.observe(collectionName) diff --git a/apps/package-manager/packages/generic/src/packageManager.ts b/apps/package-manager/packages/generic/src/packageManager.ts index ae1530cf..094e7116 100644 --- a/apps/package-manager/packages/generic/src/packageManager.ts +++ b/apps/package-manager/packages/generic/src/packageManager.ts @@ -198,7 +198,7 @@ export class PackageManagerHandler { if (this.coreHandler.notUsingCore) return // Abort if we are not using core this.logger.debug('Renewing observers') - const triggerUpdateOnAnyChange = (observer: Observer) => { + const triggerUpdateOnAnyChange = (observer: Observer) => { observer.added = () => { this.triggerUpdatedExpectedPackages() } diff --git a/package.json b/package.json index 7d3e6544..c3483ccb 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,8 @@ "node": ">=18" }, "dependencies": { - "@sofie-automation/server-core-integration": "1.50.0", - "@sofie-automation/shared-lib": "1.50.0" + "@sofie-automation/server-core-integration": "1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0", + "@sofie-automation/shared-lib": "1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" }, "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", "lint-staged": { diff --git a/yarn.lock b/yarn.lock index 3cca4507..1d95118a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -685,7 +685,7 @@ __metadata: resolution: "@html-renderer/app@workspace:apps/html-renderer/app" dependencies: "@html-renderer/generic": "npm:1.50.5" - "@sofie-automation/shared-lib": "npm:1.50.0" + "@sofie-automation/shared-lib": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" "@sofie-package-manager/api": "npm:1.50.5" "@types/ws": "npm:^8.5.4" archiver: "npm:^7.0.1" @@ -2100,30 +2100,30 @@ __metadata: languageName: node linkType: hard -"@sofie-automation/server-core-integration@npm:1.50.0": - version: 1.50.0 - resolution: "@sofie-automation/server-core-integration@npm:1.50.0" +"@sofie-automation/server-core-integration@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0": + version: 1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0 + resolution: "@sofie-automation/server-core-integration@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" dependencies: - "@sofie-automation/shared-lib": "npm:1.50.0" + "@sofie-automation/shared-lib": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" ejson: "npm:^2.2.3" eventemitter3: "npm:^4.0.7" faye-websocket: "npm:^0.11.4" got: "npm:^11.8.6" tslib: "npm:^2.4.0" underscore: "npm:^1.13.4" - checksum: 10/b0699de75e871f30684cb0056922cb2955725c7759dc3888cd83d6a7c260c4227d5cc79a16d2d0288eae6e6acfb8cd9dec7080248daf003b31baceb2f99cf7b9 + checksum: 10/85a4b2d8acbf36f6dbc72c0dc0f3b05e9268c3cd10e89d1eaf5a1bc70ea44651cbb070b9af335f692520a0fbbd062d5747f7a271b9130179581b20c0bf8a7c99 languageName: node linkType: hard -"@sofie-automation/shared-lib@npm:1.50.0": - version: 1.50.0 - resolution: "@sofie-automation/shared-lib@npm:1.50.0" +"@sofie-automation/shared-lib@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0": + version: 1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0 + resolution: "@sofie-automation/shared-lib@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" dependencies: "@mos-connection/model": "npm:^3.0.4" - timeline-state-resolver-types: "npm:9.0.0" + timeline-state-resolver-types: "npm:9.0.1" tslib: "npm:^2.4.0" type-fest: "npm:^2.19.0" - checksum: 10/69036c85e5bf0adca0d997db7138de6fb8398a12cd48c5fa1c9be1139baf9e09f098edaf77572588425fbebf9ff9c6d5f0634acf50e41444f9b593f2e60814ff + checksum: 10/4ff4f24fc87c4b79bfb592312bfcfc782bcf02e21b25dcffb04edd1de0a3f0fd4ddae005f198d8729fb37894e111233f04f712f83e443f7a310bc5e2b4a81e24 languageName: node linkType: hard @@ -10582,8 +10582,8 @@ __metadata: resolution: "package-manager-monorepo@workspace:." dependencies: "@sofie-automation/code-standard-preset": "npm:^2.5.1" - "@sofie-automation/server-core-integration": "npm:1.50.0" - "@sofie-automation/shared-lib": "npm:1.50.0" + "@sofie-automation/server-core-integration": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" + "@sofie-automation/shared-lib": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" "@types/jest": "npm:^29.2.5" "@types/rimraf": "npm:^3.0.0" "@yao-pkg/pkg": "npm:^5.11.5" @@ -12786,12 +12786,12 @@ __metadata: languageName: node linkType: hard -"timeline-state-resolver-types@npm:9.0.0": - version: 9.0.0 - resolution: "timeline-state-resolver-types@npm:9.0.0" +"timeline-state-resolver-types@npm:9.0.1": + version: 9.0.1 + resolution: "timeline-state-resolver-types@npm:9.0.1" dependencies: tslib: "npm:^2.5.1" - checksum: 10/a3886a9f480d0c01a6470a55163c8262a11ac17084d1bacb4e078ea321ad2854969ef57cc0907de4162853037738ff0d6124c809c1a0ee3b012e7723edca4086 + checksum: 10/17bfd8cd52069fb3571bc9cf9c8d711a1c9569ca2cf61ee9a429be8fac4035cf3b6f146d31c68dda6d8fdd5a880f72c8dbe65985d0385e3128407b08c303361a languageName: node linkType: hard From 3f144434b419fce0c68c3fcaccbfdaf920c83a05 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Thu, 11 Jul 2024 15:20:24 +0200 Subject: [PATCH 15/46] chore: bug fix in regex --- apps/html-renderer/app/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/html-renderer/app/src/index.ts b/apps/html-renderer/app/src/index.ts index 4158d3ca..f17259ae 100644 --- a/apps/html-renderer/app/src/index.ts +++ b/apps/html-renderer/app/src/index.ts @@ -32,7 +32,7 @@ async function main(): Promise { } url = url.trim() // Is a url - if (url.match(/^(https?)|(file)/)) { + if (url.match(/^((https?)|(file))/)) { // Do nothing } else { // Assume it's a file path From c66b32fac5802963a4ca58752c5627cd9938346d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Jul 2024 09:35:04 +0000 Subject: [PATCH 16/46] chore(deps): bump nrkno/github-workflow-docker-build-push Bumps [nrkno/github-workflow-docker-build-push](https://github.com/nrkno/github-workflow-docker-build-push) from 4.0.1 to 4.1.0. - [Release notes](https://github.com/nrkno/github-workflow-docker-build-push/releases) - [Commits](https://github.com/nrkno/github-workflow-docker-build-push/compare/v4.0.1...v4.1.0) --- updated-dependencies: - dependency-name: nrkno/github-workflow-docker-build-push dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/publish-prerelease-docker.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-prerelease-docker.yaml b/.github/workflows/publish-prerelease-docker.yaml index b76767df..a58fb951 100644 --- a/.github/workflows/publish-prerelease-docker.yaml +++ b/.github/workflows/publish-prerelease-docker.yaml @@ -168,7 +168,7 @@ jobs: tags: "${{ steps.quantel-dockerhub-tag.outputs.tags }}" trivy-scanning-http-server: - uses: nrkno/github-workflow-docker-build-push/.github/workflows/workflow.yaml@v4.0.1 + uses: nrkno/github-workflow-docker-build-push/.github/workflows/workflow.yaml@v4.1.0 with: runs-on: "['ubuntu-latest']" registry-url: ghcr.io @@ -185,7 +185,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} trivy-scanning-quantel-http-transformer-proxy: - uses: nrkno/github-workflow-docker-build-push/.github/workflows/workflow.yaml@v4.0.1 + uses: nrkno/github-workflow-docker-build-push/.github/workflows/workflow.yaml@v4.1.0 with: runs-on: "['ubuntu-latest']" registry-url: ghcr.io From 0546c72ae4edf63fe285cb049b22fdb149261991 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 09:51:46 +0200 Subject: [PATCH 17/46] chore: minor fixes after code review --- apps/html-renderer/app/README.md | 2 +- apps/html-renderer/app/src/config.ts | 6 +++++- .../packages/generic/src/renderHTML.ts | 18 +++++++++--------- shared/packages/api/src/expectationApi.ts | 9 +++++++-- .../expectationHandlers/RenderHTML.ts | 4 +++- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/html-renderer/app/README.md b/apps/html-renderer/app/README.md index 975f654c..9943297f 100644 --- a/apps/html-renderer/app/README.md +++ b/apps/html-renderer/app/README.md @@ -10,7 +10,7 @@ _This is used for a HTML file that follows the typical CasparCG lifespan (`updat html-renderer.exe -- --url=file://C:/templates/mytemplate.html outputPath="C:\\rendered" --screenshots=true --recording=true --recording-cropped=true --casparData='{"name":"John Doe"}' --casparDelay=1000 ``` -### Standalone HTML template +### Generic HTML template _This is used for a HTML file that doesn't require any additional input during its run._ diff --git a/apps/html-renderer/app/src/config.ts b/apps/html-renderer/app/src/config.ts index 7121b8c2..5f2c7083 100644 --- a/apps/html-renderer/app/src/config.ts +++ b/apps/html-renderer/app/src/config.ts @@ -25,7 +25,11 @@ const htmlRendererOptions = defineArguments({ type: 'boolean', describe: 'When true, will capture a recording cropped to the non-black area', }, - casparData: { type: 'string', describe: '(JSON) data to send into the update() function of a CasparCG Template' }, + casparData: { + type: 'string', + describe: + '(JSON) data to send into the update() function of a CasparCG Template. This needs to be a stringified JSON object, a string (in quotes) etc..', + }, casparDelay: { type: 'number', describe: 'How long to wait between each action in a CasparCG template (default: 1000ms)', diff --git a/apps/html-renderer/packages/generic/src/renderHTML.ts b/apps/html-renderer/packages/generic/src/renderHTML.ts index c25bda4d..62b80f25 100644 --- a/apps/html-renderer/packages/generic/src/renderHTML.ts +++ b/apps/html-renderer/packages/generic/src/renderHTML.ts @@ -276,20 +276,20 @@ export async function renderHTML(options: RenderHTMLOptions): Promise<{ ) if ( - boundingBox.x1 === Infinity || - boundingBox.x2 === -Infinity || - boundingBox.y1 === Infinity || - boundingBox.y2 === -Infinity + !Number.isFinite(boundingBox.x1) || + !Number.isFinite(boundingBox.x2) || + !Number.isFinite(boundingBox.y1) || + !Number.isFinite(boundingBox.y2) ) { logger.warn(`Could not determine bounding box`) // Just copy the full video await fs.promises.copyFile(recording.fileName, croppedFilename) } else { - // Add margins: - boundingBox.x1 -= 10 + (boundingBox.x1 > width * 0.65 ? 10 : 0) - boundingBox.x2 += 10 + (boundingBox.x2 < width * 0.65 ? 10 : 0) - boundingBox.y1 -= 10 + (boundingBox.y1 > height * 0.65 ? 10 : 0) - boundingBox.y2 += 10 + (boundingBox.y2 < height * 0.65 ? 10 : 0) + // Add margins:, to account for things like drop shadows + boundingBox.x1 -= 10 + boundingBox.x2 += 10 + boundingBox.y1 -= 10 + boundingBox.y2 += 10 logger.verbose(`Saving cropped recording to ${croppedFilename}`) // Generate a cropped video as well: diff --git a/shared/packages/api/src/expectationApi.ts b/shared/packages/api/src/expectationApi.ts index e27bb8ee..835a5087 100644 --- a/shared/packages/api/src/expectationApi.ts +++ b/shared/packages/api/src/expectationApi.ts @@ -396,8 +396,13 @@ export namespace Expectation { | { do: 'storeObject'; key: string; value: Record } // Modify an object in memory. Path is a dot-separated string | { do: 'modifyObject'; key: string; path: string; value: any } - // Send an object to the renderer as a postMessage - | { do: 'injectObject'; key: string } + // Send an object to the renderer as a postMessage (so basically does a executeJs: window.postMessage(memory[key])) + | { + do: 'injectObject' + key: string + /** The method to receive the value. Defaults to window.postMessage */ + receivingFunction: string + } )[] } } diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts index 6c2b5beb..57cc73ac 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts @@ -902,10 +902,12 @@ class HTMLRenderer { const obj = this.storedDataObjects[currentStep.step.key] if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) + const receivingFunction = currentStep.step.receivingFunction ?? 'window.postMessage' + // Execute javascript in the renderer, to simulate a postMessage event: const cmd: InteractiveMessage = { do: 'executeJs', - js: `window.postMessage(${JSON.stringify(obj)})`, + js: `${receivingFunction}(${JSON.stringify(obj)})`, } // Send command to the renderer: this.setCommandToRenderer(cmd) From 468e3f522fae6cb53251cf9ba493b0206705b83d Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 09:52:49 +0200 Subject: [PATCH 18/46] chore: rename RenderHTML.ts --- .../expectationHandlers/RenderHTML.ts | 983 ------------------ 1 file changed, 983 deletions(-) delete mode 100644 shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts deleted file mode 100644 index 57cc73ac..00000000 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/RenderHTML.ts +++ /dev/null @@ -1,983 +0,0 @@ -import { ChildProcessWithoutNullStreams, spawn } from 'child_process' -import * as fs from 'fs/promises' -import * as path from 'path' -import WebSocket from 'ws' -import { BaseWorker } from '../../../worker' -import { UniversalVersion, getStandardCost, makeUniversalVersion } from '../lib/lib' -import { - Accessor, - Expectation, - ReturnTypeDoYouSupportExpectation, - ReturnTypeGetCostFortExpectation, - ReturnTypeIsExpectationFulfilled, - ReturnTypeIsExpectationReadyToStartWorkingOn, - ReturnTypeRemoveExpectation, - stringifyError, - AccessorId, - startTimer, - hashObj, - getHtmlRendererExecutable, - assertNever, - hash, - protectString, - InteractiveStdOut, - InteractiveReply, - InteractiveMessage, - literal, -} from '@sofie-package-manager/api' - -import { IWorkInProgress, WorkInProgress } from '../../../lib/workInProgress' -import { checkWorkerHasAccessToPackageContainersOnPackage, lookupAccessorHandles, LookupPackageContainer } from './lib' -import { isFileFulfilled, isFileReadyToStartWorkingOn } from './lib/file' -import { ExpectationHandlerGenericWorker, GenericWorker } from '../genericWorker' -import { - isFileShareAccessorHandle, - isHTTPAccessorHandle, - isHTTPProxyAccessorHandle, - isLocalFolderAccessorHandle, -} from '../../../accessorHandlers/accessor' -import { LocalFolderAccessorHandle } from '../../../accessorHandlers/localFolder' -import { PackageReadStream, PutPackageHandler } from '../../../accessorHandlers/genericHandle' -import { ByteCounter } from '../../../lib/streamByteCounter' -import { fetchWithTimeout } from '../../../accessorHandlers/lib/fetch' - -/** - * Copies a file from one of the sources and into the target PackageContainer - */ -export const RenderHTML: ExpectationHandlerGenericWorker = { - doYouSupportExpectation(exp: Expectation.Any, genericWorker: GenericWorker): ReturnTypeDoYouSupportExpectation { - if (genericWorker.testHTMLRenderer) - return { - support: false, - reason: { - user: 'There is an issue with the Worker (HTMLRenderer)', - tech: `Cannot access HTMLRenderer executable: ${genericWorker.testHTMLRenderer}`, - }, - } - return checkWorkerHasAccessToPackageContainersOnPackage(genericWorker, { - sources: exp.startRequirement.sources, - targets: exp.endRequirement.targets, - }) - }, - getCostForExpectation: async ( - exp: Expectation.Any, - worker: BaseWorker - ): Promise => { - if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) - return getStandardCost(exp, worker) - }, - isExpectationReadyToStartWorkingOn: async ( - exp: Expectation.Any, - worker: BaseWorker - ): Promise => { - if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) - - const steps = getSteps(exp) - const { mainFileName } = getFileNames(steps) - if (!mainFileName) - return { - ready: false, - reason: { - user: 'No output (this is a configuration issue)', - tech: `No output filename (${steps.length} steps)`, - }, - } - - const lookupSource = await lookupSources(worker, exp) - const lookupTarget = await lookupTargets(worker, exp, mainFileName) - - return isFileReadyToStartWorkingOn(worker, lookupSource, lookupTarget) - }, - isExpectationFulfilled: async ( - exp: Expectation.Any, - _wasFulfilled: boolean, - worker: BaseWorker - ): Promise => { - if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) - - const steps = getSteps(exp) - const { fileNames, mainFileName } = getFileNames(steps) - if (!mainFileName) - return { - fulfilled: false, - reason: { - user: 'No output (this is a configuration issue)', - tech: `No output filename (${steps.length} steps)`, - }, - } - const lookupSource = await lookupSources(worker, exp) - - // First, check metadata - const mainLookupTarget = await lookupTargets(worker, exp, mainFileName) - - // Do a full check on the main file: - const mainFulfilledStatus = await isFileFulfilled(worker, lookupSource, mainLookupTarget) - if (!mainFulfilledStatus.fulfilled) return mainFulfilledStatus - - // Go through the other files, just check that they exist: - for (const fileName of fileNames) { - if (fileName === mainFileName) continue // already checked - - const lookupTarget = await lookupTargets(worker, exp, fileName) - - if (!lookupTarget.ready) - return { - fulfilled: false, - reason: { - user: `Not able to access target, due to: ${lookupTarget.reason.user} `, - tech: `Not able to access target: ${lookupTarget.reason.tech}`, - }, - } - - const issuePackage = await lookupTarget.handle.checkPackageReadAccess() - if (!issuePackage.success) { - return { - fulfilled: false, - reason: { - user: `Target package: ${issuePackage.reason.user}`, - tech: `Target package: ${issuePackage.reason.tech}`, - }, - } - } - } - return { - fulfilled: true, - } - }, - workOnExpectation: async (exp: Expectation.Any, worker: BaseWorker): Promise => { - if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) - // Render the HTML file - - /* - * What this one does is: - * 1. Spin up the HtmlRenderer executable and send it commands - to render the HTML file, take screenshots, record video etc, - according to the steps defined in the expectation. - 2. The output from HtmlRenderer executable are files in a temporary folder. - 3. Copy the files from the temporary folder to the target PackageContainer. - 4. Clean up the temporary files. - */ - const htmlRenderHandler = new HTMLRenderHandler(exp, worker) - - const lookupSource = await lookupSources(worker, exp) - if (!lookupSource.ready) throw new Error(`Can't start working due to source: ${lookupSource.reason.tech}`) - - const mainLookupTarget = await lookupTargets(worker, exp, htmlRenderHandler.mainFileName) - if (!mainLookupTarget.ready) - throw new Error(`Can't start working due to target: ${mainLookupTarget.reason.tech}`) - - const sourceHandle = lookupSource.handle - const mainTargetHandle = mainLookupTarget.handle - - if ( - (lookupSource.accessor.type === Accessor.AccessType.LOCAL_FOLDER || - lookupSource.accessor.type === Accessor.AccessType.FILE_SHARE || - lookupSource.accessor.type === Accessor.AccessType.HTTP || - lookupSource.accessor.type === Accessor.AccessType.HTTP_PROXY) && - (mainLookupTarget.accessor.type === Accessor.AccessType.LOCAL_FOLDER || - mainLookupTarget.accessor.type === Accessor.AccessType.FILE_SHARE || - mainLookupTarget.accessor.type === Accessor.AccessType.HTTP_PROXY) - ) { - if ( - !isLocalFolderAccessorHandle(sourceHandle) && - !isFileShareAccessorHandle(sourceHandle) && - !isHTTPAccessorHandle(sourceHandle) && - !isHTTPProxyAccessorHandle(sourceHandle) - ) - throw new Error(`Source AccessHandler type is wrong`) - if ( - !isLocalFolderAccessorHandle(mainTargetHandle) && - !isFileShareAccessorHandle(mainTargetHandle) && - !isHTTPProxyAccessorHandle(mainTargetHandle) - ) - throw new Error(`Target AccessHandler type is wrong`) - - let url: string - if (isLocalFolderAccessorHandle(sourceHandle)) { - url = `file://${sourceHandle.fullPath}` - } else if (isFileShareAccessorHandle(sourceHandle)) { - url = `file://${sourceHandle.fullPath}` - } else if (isHTTPAccessorHandle(sourceHandle)) { - url = `${sourceHandle.fullUrl}` - } else if (isHTTPProxyAccessorHandle(sourceHandle)) { - url = `${sourceHandle.fullUrl}` - } else { - assertNever(sourceHandle) - throw new Error(`Unsupported Source AccessHandler`) - } - - const workInProgress = new WorkInProgress({ workLabel: `Generating preview of "${url}"` }, async () => { - // On cancel - htmlRenderHandler.cancel() - }).do(async () => { - await lookupSource.handle.getPackageActualVersion() - - await htmlRenderHandler.run({ - workInProgress, - lookupSource, - mainLookupTarget, - url, - }) - }) - - return workInProgress - } else { - throw new Error( - `RenderHTML.workOnExpectation: Unsupported accessor source-target pair "${lookupSource.accessor.type}"-"${mainLookupTarget.accessor.type}"` - ) - } - }, - removeExpectation: async (exp: Expectation.Any, worker: BaseWorker): Promise => { - if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) - // Remove the files on the location - - const steps = getSteps(exp) - const { fileNames, mainFileName } = getFileNames(steps) - if (!mainFileName) - throw new Error( - `Can't start working due to no mainFileName (${steps.length} steps). This is a configuration issue.` - ) - - const lookupSource = await lookupSources(worker, exp) - if (!lookupSource.ready) throw new Error(`Can't start working due to source: ${lookupSource.reason.tech}`) - - const mainLookupTarget = await lookupTargets(worker, exp, mainFileName) - if (!mainLookupTarget.ready) { - return { - removed: false, - reason: { - user: `Can't access target, due to: ${mainLookupTarget.reason.user}`, - tech: `No access to target: ${mainLookupTarget.reason.tech}`, - }, - } - } - - for (const fileName of fileNames) { - if (fileName === mainFileName) continue // remove this last - const lookupTarget = await lookupTargets(worker, exp, fileName) - if (!lookupTarget.ready) { - throw new Error(`Cannot remove files due to target: ${lookupTarget.reason.tech}`) - } - - try { - await lookupTarget.handle.removePackage('expectation removed') - } catch (err) { - return { - removed: false, - reason: { - user: `Cannot remove file due to an internal error`, - tech: `Cannot remove file: ${stringifyError(err)}`, - }, - } - } - } - // Remove the main one last: - try { - await mainLookupTarget.handle.removePackage('expectation removed') - } catch (err) { - return { - removed: false, - reason: { - user: `Cannot remove file due to an internal error`, - tech: `Cannot remove file: ${stringifyError(err)}`, - }, - } - } - - return { - removed: true, - // reason: `Removed file "${exp.endRequirement.content.path}" from target` - } - }, -} -function isHTMLRender(exp: Expectation.Any): exp is Expectation.RenderHTML { - return exp.type === Expectation.Type.RENDER_HTML -} - -async function lookupSources( - worker: BaseWorker, - exp: Expectation.RenderHTML -): Promise> { - return lookupAccessorHandles( - worker, - exp.startRequirement.sources, - exp.startRequirement.content, - exp.workOptions, - { - read: true, - readPackage: true, - packageVersion: exp.startRequirement.version, - } - ) -} -async function lookupTargets( - worker: BaseWorker, - exp: Expectation.RenderHTML, - filePath: string -): Promise> { - return lookupAccessorHandles( - worker, - exp.endRequirement.targets, - { - filePath, - }, - exp.workOptions, - { - write: true, - writePackageContainer: true, - } - ) -} -type Steps = Required['steps'] -function getSteps(exp: Expectation.RenderHTML): Steps { - let steps: Steps - if (exp.endRequirement.version.casparCG) { - // Generate a set of steps for standard CasparCG templates - const casparData = exp.endRequirement.version.casparCG.data - const casparDataJSON = typeof casparData === 'string' ? casparData : JSON.stringify(casparData) - steps = [ - { do: 'waitForLoad' }, - { do: 'takeScreenshot', fileName: 'idle.png' }, - { do: 'startRecording', fileName: 'preview.webm' }, - { do: 'executeJs', js: `update(${casparDataJSON})` }, - { do: 'executeJs', js: `play()` }, - { do: 'sleep', duration: 1000 }, - { do: 'takeScreenshot', fileName: 'play.png' }, - { do: 'executeJs', js: `stop()` }, - { do: 'sleep', duration: 1000 }, - { do: 'takeScreenshot', fileName: 'stop.png' }, - { do: 'stopRecording' }, - { do: 'cropRecording', fileName: 'preview-cropped.webm' }, - ] - } else { - steps = exp.endRequirement.version.steps || [] - } - // Add prefix to steps fileName: - return steps.map((org) => { - const step = { ...org } - if ('fileName' in step) { - step.fileName = `${exp.endRequirement.content.prefix ?? ''}${step.fileName}` - } - return step - }) -} -function getFileNames(steps: Steps) { - const fileNames: string[] = [] - let mainFileName: string | undefined = undefined - for (const step of steps) { - if (step.do === 'takeScreenshot') { - fileNames.push(step.fileName) - if (!mainFileName) mainFileName = step.fileName - } else if (step.do === 'startRecording') { - fileNames.push(step.fileName) - mainFileName = step.fileName - } else if (step.do === 'cropRecording') { - fileNames.push(step.fileName) - } - } - return { fileNames, mainFileName } -} -function compact(array: (T | undefined | null | false)[]): T[] { - return array.filter(Boolean) as T[] -} -async function unlinkIfExists(path: string) { - try { - await fs.unlink(path) - } catch { - // ignore errors - } -} - -class HTMLRenderHandler { - public readonly mainFileName: string - - private wasCancelled = false - private sourceStream: PackageReadStream | undefined = undefined - private writeStream: PutPackageHandler | undefined = undefined - private steps: Steps - private fileNames: string[] - private timer: { get: () => number } - private htmlRenderer: HTMLRenderer | undefined - - private outputPath: string - private executeSteps: { step: Steps[number]; duration: number }[] - private outputFileNames: string[] - - constructor(public readonly exp: Expectation.RenderHTML, public readonly worker: BaseWorker) { - this.steps = getSteps(exp) - const f = getFileNames(this.steps) - if (!f.mainFileName) - throw new Error( - `Can't start working due to no mainFileName (${this.steps.length} steps). This is a configuration issue.` - ) - this.fileNames = f.fileNames - this.mainFileName = f.mainFileName - - this.timer = startTimer() - - this.outputPath = path.resolve('./tmpRenderHTML') - // Prefix the work-in-progress artifacts with a unique identifier - const filePrefix = hash(`${process.pid}_${Math.random()}`) - this.executeSteps = this.steps.map((step) => { - let defaultDuration = 500 - if ( - step.do === 'storeObject' || - step.do === 'modifyObject' || - step.do === 'injectObject' || - step.do === 'executeJs' - ) - defaultDuration = 10 - - const executeStep = { - step, - duration: 'duration' in step ? step.duration : defaultDuration, // Used to calculate total duration - } - if ('fileName' in executeStep.step) { - executeStep.step.fileName = `${filePrefix}_${executeStep.step.fileName}` - } - return executeStep - }) - const { fileNames } = getFileNames(this.executeSteps.map((s) => s.step)) - this.outputFileNames = fileNames - } - - cancel = () => { - this.wasCancelled = true - - this.htmlRenderer?.cancel() - // ffProbeProcess?.cancel() - this.sourceStream?.cancel() - this.writeStream?.abort() - } - - async run(options: { - workInProgress: WorkInProgress - lookupSource: LookupPackageContainer - mainLookupTarget: LookupPackageContainer - url: string - }) { - if (!options.lookupSource.ready) - throw new Error(`Can't start working due to source: ${options.lookupSource.reason.tech}`) - if (!options.mainLookupTarget.ready) - throw new Error(`Can't start working due to target: ${options.mainLookupTarget.reason.tech}`) - - const workInProgress = options.workInProgress - - const actualSourceVersion = await options.lookupSource.handle.getPackageActualVersion() - const actualSourceVersionHash = hashObj(actualSourceVersion) - const actualSourceUVersion = makeUniversalVersion(actualSourceVersion) - - try { - // Remote old temp files, if they exist: - await this.cleanTempFiles() - workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.initialCleanTempFiles) - - // Render HTML file according to the steps: - this.htmlRenderer = new HTMLRenderer( - this.worker, - this.exp, - options.url, - workInProgress, - actualSourceVersionHash, - this.executeSteps, - this.outputPath - ) - await this.htmlRenderer.done - - // Move files to the target: - await this.moveOutputFilesToTarget({ - workInProgress, - actualSourceVersionHash, - }) - - // Write metadata - if (!this.wasCancelled) { - await options.mainLookupTarget.handle.updateMetadata(actualSourceUVersion) - workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.writeMetadata) - } - - // Clean temp files: - await this.cleanTempFiles() - workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.cleanTempFiles) - - // Clean other old files: - const files = await fs.readdir(this.outputPath) - await Promise.all( - files.map(async (file) => { - const fullPath = path.join(this.outputPath, file) - const lStat = await fs.lstat(fullPath) - if (Date.now() - lStat.mtimeMs > 1000 * 3600) { - await unlinkIfExists(fullPath) - } - }) - ) - - workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.cleanOutputFiles) - - const duration = this.timer.get() - workInProgress._reportComplete( - actualSourceVersionHash, - { - user: `HTML Rendering completed in ${Math.round(duration / 100) / 10}s`, - tech: `HTML Rendering completed at ${Date.now()}`, - }, - undefined - ) - } catch (e) { - // cleanup - this.cancel() - - throw e - } finally { - await this.cleanTempFiles() - } - } - async moveOutputFilesToTarget(options: { workInProgress: WorkInProgress; actualSourceVersionHash: string }) { - // Move all this.outputFileNames files to our target - - for (let i = 0; i < this.outputFileNames.length; i++) { - if (this.wasCancelled) break - const tempFileName = this.outputFileNames[i] - const fileName = this.fileNames[i] - const localFileSourceHandle = new LocalFolderAccessorHandle( - this.worker, - protectString('tmpLocalHTMLRenderer'), - { - type: Accessor.AccessType.LOCAL_FOLDER, - allowRead: true, - folderPath: this.outputPath, - filePath: tempFileName, - }, - {}, - {} - ) - const lookupTarget = await lookupTargets(this.worker, this.exp, fileName) - if (!lookupTarget.ready) throw new Error(`Can't start working due to target: ${lookupTarget.reason.tech}`) - - const fileOperation = await lookupTarget.handle.prepareForOperation( - 'Copy file, using streams', - localFileSourceHandle - ) - - options.workInProgress._reportProgress( - options.actualSourceVersionHash, - REPORT_PROGRESS.copyFilesToTarget + 0.29 * (i / this.outputFileNames.length) - ) - - const fileSize = (await fs.stat(localFileSourceHandle.fullPath)).size - const byteCounter = new ByteCounter() - byteCounter.on('progress', (bytes: number) => { - if (this.writeStream?.usingCustomProgressEvent) return // ignore this callback, we'll be listening to writeStream.on('progress') instead. - - if (fileSize) { - const progress = bytes / fileSize - options.workInProgress._reportProgress( - options.actualSourceVersionHash, - REPORT_PROGRESS.copyFilesToTarget + 0.29 * ((i + progress) / this.outputFileNames.length) - ) - } - }) - - this.sourceStream = await localFileSourceHandle.getPackageReadStream() - this.writeStream = await lookupTarget.handle.putPackageStream( - this.sourceStream.readStream.pipe(byteCounter) - ) - this.writeStream.on('error', (err) => options.workInProgress._reportError(err)) - - if (this.writeStream.usingCustomProgressEvent) { - this.writeStream.on('progress', (progress) => { - options.workInProgress._reportProgress( - options.actualSourceVersionHash, - REPORT_PROGRESS.copyFilesToTarget + 0.29 * ((i + progress) / this.outputFileNames.length) - ) - }) - } - - await new Promise((resolve, reject) => { - if (!this.sourceStream) throw new Error(`sourceStream missing`) - if (!this.writeStream) throw new Error(`writeStream missing`) - - this.sourceStream.readStream.on('error', (err) => reject(err)) - this.writeStream.once('error', (err) => reject(err)) - this.writeStream.once('close', () => resolve()) - }) - this.writeStream.removeAllListeners() - this.writeStream.removeAllListeners() - - await lookupTarget.handle.finalizePackage(fileOperation) - } - } - private async cleanTempFiles() { - // Clean temp files: - await Promise.all( - this.outputFileNames.map(async (fileName) => unlinkIfExists(path.join(this.outputPath, fileName))) - ) - } -} - -class HTMLRenderer { - public done: Promise - - // @ts-expect-error is set in constructor - private resolve: () => void - // @ts-expect-error is set in constructor - private reject: (err: unknown) => void - - private totalStepDuration: number - private htmlRendererProcess: ChildProcessWithoutNullStreams | undefined = undefined - private ws: WebSocket | undefined = undefined - - private commandStepIndex = 0 - private commandStepDuration = 0 - private waitingForCommand: string | null = null - private timeoutDoNextCommand: NodeJS.Timeout | undefined = undefined - private processLastFewLines: string[] = [] - - private storedDataObjects: { - [key: string]: Record - } = {} - - constructor( - private worker: BaseWorker, - private exp: Expectation.RenderHTML, - private url: string, - private workInProgress: WorkInProgress, - private actualSourceVersionHash: string, - private executeSteps: { step: Steps[number]; duration: number }[], - private outputPath: string - ) { - this.totalStepDuration = this.executeSteps.reduce((prev, cur) => prev + cur.duration, 0) || 1 - - this.done = new Promise((resolve, reject) => { - this.resolve = resolve - this.reject = reject - - this.spawnHTMLRendererProcess() - }) - } - cancel() { - // ensure this function doesn't throw, since it is called from various error event handlers - try { - this.htmlRendererProcess?.kill() - } catch (e) { - // This is probably OK, errors likely means that the process is already dead - } - this.htmlRendererProcess = undefined - } - private onError(err: unknown) { - if (err instanceof Error) { - err.message += `: ${this.processLastFewLines.join('\n')}` - } else if (typeof err === 'string') { - err += `: ${this.processLastFewLines.join('\n')}` - } - - this.reject(err) - this.cancel() - } - private onComplete() { - this.resolve() - } - - private spawnHTMLRendererProcess() { - this.htmlRendererProcess = spawn( - getHtmlRendererExecutable(), - compact([ - `--`, - `--url=${this.url}`, - this.exp.endRequirement.version.renderer?.width !== undefined && - `--width=${this.exp.endRequirement.version.renderer?.width}`, - this.exp.endRequirement.version.renderer?.height !== undefined && - `--height=${this.exp.endRequirement.version.renderer?.height}`, - this.exp.endRequirement.version.renderer?.zoom !== undefined && - `--zoom=${this.exp.endRequirement.version.renderer?.zoom}`, - `--outputPath=${this.outputPath}`, - `--interactive=true`, - ]), - { - windowsVerbatimArguments: true, // To fix an issue with arguments on Windows - } - ) - - const onClose = (code: number | null) => { - if (this.htmlRendererProcess) { - this.htmlRendererProcess.removeAllListeners() - this.htmlRendererProcess.stdin.removeAllListeners() - this.htmlRendererProcess.stdout.removeAllListeners() - - this.htmlRendererProcess = undefined - if (code === 0) { - // Do nothing - } else { - this.onError(new Error(`HTMLRenderer exit code ${code}`)) - } - } - } - this.htmlRendererProcess.on('close', (code) => onClose(code)) - this.htmlRendererProcess.on('exit', (code) => onClose(code)) - this.htmlRendererProcess.on('error', (err) => { - this.onError(new Error(`HTMLRenderer error: ${stringifyError(err)}`)) - }) - - let waitingForReady = true - this.htmlRendererProcess.stderr.on('data', (data) => { - const str = data.toString() - this.worker.logger.debug(`HTMLRenderer: stderr: ${str}`) - this.processLastFewLines.push(str) - if (this.processLastFewLines.length > 10) this.processLastFewLines.shift() - }) - this.htmlRendererProcess.stdout.on('data', (data) => { - try { - const str = data.toString() - this.worker.logger.debug(`HTMLRenderer: stdout: ${str}`) - this.processLastFewLines.push(str) - if (this.processLastFewLines.length > 10) this.processLastFewLines.shift() - - let message: InteractiveStdOut - try { - message = JSON.parse(str) - } catch { - // ignore parse errors - return - } - if (message.status === 'listening') { - // This message indicates that the HTML renderer is ready to accept interactive commands on the websocket server - if (waitingForReady) { - waitingForReady = false - - this.workInProgress._reportProgress( - this.actualSourceVersionHash, - REPORT_PROGRESS.setupWebSocketConnection - ) - this.setupWebSocketConnection(message.port).catch((e) => this.onError(e)) - } else { - this.onError( - new Error(`Unexpected reply from HTMLRenderer: ${message} (not waiting for 'listening')`) - ) - this.cancel() - } - } else { - assertNever(message.status) - } - } catch (e) { - this.onError(e) - } - }) - } - - private async setupWebSocketConnection(port: number) { - if (this.ws) throw new Error(`WebSocket already set up`) - - const ws = new WebSocket(`ws://127.0.0.1:${port}`) - this.ws = ws - ws.once('close', () => { - ws.removeAllListeners() - delete this.ws - }) - ws.on('message', (data) => { - try { - const str = data.toString() - - this.worker.logger.debug(`HTMLRenderer: Received reply: ${str}`) - - let message: InteractiveReply | undefined = undefined - try { - message = JSON.parse(str) - } catch { - // ignore parse errors - } - - if (!message) { - // Other output, log and ignore: - this.worker.logger.debug(`HTMLRenderer: ${str}`) - } else if (message.error) { - this.onError(new Error(`Error reply from HTMLRenderer: ${message.error}`)) - } else if (message.reply) { - if (!this.waitingForCommand) { - this.onError( - new Error(`Unexpected reply from HTMLRenderer: ${message.reply} (not waiting for command)`) - ) - } else if (message.reply === this.waitingForCommand) { - // This message indicates that the HTML renderer has completed the previous command - this.waitingForCommand = null - - this.doNextCommand() - } else { - this.onError( - new Error(`Unexpected reply from HTMLRenderer: ${message.reply} (not waiting for command)`) - ) - } - } else { - assertNever(message) - // Other output, log and ignore: - this.worker.logger.debug(`HTMLRenderer: ${str}`) - } - } catch (e) { - this.onError(e) - } - }) - await new Promise((resolve, reject) => { - ws.once('open', resolve) - ws.once('error', reject) - }) - - this.worker.logger.debug(`HTMLRenderer: WebSocket connected`) - this.doNextCommand() - } - - private doNextCommand() { - if (!this.ws) throw new Error(`WebSocket not set up`) - if (this.timeoutDoNextCommand) { - clearTimeout(this.timeoutDoNextCommand) - this.timeoutDoNextCommand = undefined - } - if (this.waitingForCommand) throw new Error('Already waiting for command') - - const currentStep = this.executeSteps[this.commandStepIndex] - this.commandStepIndex++ - - if (currentStep) { - const stepStartDuration = this.commandStepDuration - this.commandStepDuration += currentStep.duration - - const reportStepProgress = ( - /** Progress within the step [0-1] */ - progress: number - ) => { - this.workInProgress._reportProgress( - this.actualSourceVersionHash, - REPORT_PROGRESS.sendCommands + - 0.49 * ((stepStartDuration + progress * currentStep.duration) / this.totalStepDuration) - ) - } - reportStepProgress(0) - if (currentStep.step.do === 'sleep') { - this.worker.logger.debug(`HTMLRenderer: Sleeping for ${currentStep.step.duration}ms`) - - // While sleeping, continuously report the progress - let reportTime = 0 - const reportInterval = setInterval(() => { - reportTime += 500 - reportStepProgress(reportTime / currentStep.duration) - }, 500) - setTimeout(() => { - clearInterval(reportInterval) - this.doNextCommand() - }, currentStep.step.duration) - } else if (currentStep.step.do === 'sendHTTPCommand') { - this.worker.logger.debug(`HTMLRenderer: Send HTTP command: ${JSON.stringify(currentStep.step)}`) - const step = currentStep.step - - fetchWithTimeout(step.url, { - method: step.method, - headers: step.headers, - body: step.body, - }) - .catch((err) => { - this.onError( - new Error( - `HTMLRenderer: Error when sending "${step.method}" to "${step.url}": ${stringifyError( - err - )}` - ) - ) - }) - .finally(() => { - this.doNextCommand() - }) - } else if (currentStep.step.do === 'storeObject') { - this.worker.logger.debug(`HTMLRenderer: Store object "${currentStep.step.key}"`) - this.storedDataObjects[currentStep.step.key] = currentStep.step.value - this.doNextCommand() - } else if (currentStep.step.do === 'modifyObject') { - this.worker.logger.debug(`HTMLRenderer: Modify object "${currentStep.step.key}"`) - - const obj = this.storedDataObjects[currentStep.step.key] - if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) - - modifyObject(obj, currentStep.step.path, currentStep.step.value) - this.doNextCommand() - } else if (currentStep.step.do === 'injectObject') { - this.worker.logger.debug(`HTMLRenderer: Inject object "${currentStep.step.key}"`) - - const obj = this.storedDataObjects[currentStep.step.key] - if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) - - const receivingFunction = currentStep.step.receivingFunction ?? 'window.postMessage' - - // Execute javascript in the renderer, to simulate a postMessage event: - const cmd: InteractiveMessage = { - do: 'executeJs', - js: `${receivingFunction}(${JSON.stringify(obj)})`, - } - // Send command to the renderer: - this.setCommandToRenderer(cmd) - } else { - this.worker.logger.debug(`HTMLRenderer: Send command: ${JSON.stringify(currentStep.step)}`) - - // Send command to the renderer: - this.setCommandToRenderer(currentStep.step) - } - } else { - // Done, no more commands to send. - - this.workInProgress._reportProgress(this.actualSourceVersionHash, REPORT_PROGRESS.sendCommands + 0.49) - - // Send a close command to the renderer - this.ws.send(JSON.stringify(literal({ do: 'close' }))) - - // Wait a little bit before completion - setTimeout(() => { - this.ws?.close() - this.onComplete() - }, 500) - } - } - private setCommandToRenderer(cmd: InteractiveMessage) { - if (!this.ws) throw new Error(`WebSocket not set up`) - - this.ws.send(JSON.stringify(cmd) + '\n') - this.waitingForCommand = cmd.do - - this.timeoutDoNextCommand = setTimeout(() => { - this.onError(new Error(`Timeout waiting for command "${cmd.do}" after ${COMMAND_TIMEOUT} ms`)) - }, COMMAND_TIMEOUT) - } -} - -const COMMAND_TIMEOUT = 10000 -const REPORT_PROGRESS = { - initialCleanTempFiles: 0.05, - setupWebSocketConnection: 0.08, - sendCommands: 0.1, - copyFilesToTarget: 0.6, - writeMetadata: 0.9, - cleanTempFiles: 0.95, - cleanOutputFiles: 0.97, -} - -/** Modify an property inside an object */ -function modifyObject(obj: Record | Record[], objPath: string | string[], value: unknown) { - if (typeof objPath === 'string') objPath = objPath.split('.') - - if (typeof obj === 'object' && obj !== null) { - if (Array.isArray(obj)) { - const index = parseInt(objPath[0], 10) - if (isNaN(index)) throw new Error(`Invalid array key: ${objPath[0]}`) - - if (objPath.length === 1) { - obj[index] = value - } else { - modifyObject(obj[index], objPath.slice(1), value) - } - } else { - const key = objPath[0] - if (objPath.length === 1) { - obj[key] = value - } else { - modifyObject(obj[key], objPath.slice(1), value) - } - } - } else { - throw new Error(`Invalid object path: ${objPath.join('.')}`) - } -} From 56c1b1bbfd0eeb246132785c84533dba1de3c40a Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 09:53:16 +0200 Subject: [PATCH 19/46] chore: rename renderHTML.ts --- .../expectationHandlers/renderHTML.ts | 983 ++++++++++++++++++ .../workers/genericWorker/genericWorker.ts | 2 +- 2 files changed, 984 insertions(+), 1 deletion(-) create mode 100644 shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts new file mode 100644 index 00000000..57cc73ac --- /dev/null +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -0,0 +1,983 @@ +import { ChildProcessWithoutNullStreams, spawn } from 'child_process' +import * as fs from 'fs/promises' +import * as path from 'path' +import WebSocket from 'ws' +import { BaseWorker } from '../../../worker' +import { UniversalVersion, getStandardCost, makeUniversalVersion } from '../lib/lib' +import { + Accessor, + Expectation, + ReturnTypeDoYouSupportExpectation, + ReturnTypeGetCostFortExpectation, + ReturnTypeIsExpectationFulfilled, + ReturnTypeIsExpectationReadyToStartWorkingOn, + ReturnTypeRemoveExpectation, + stringifyError, + AccessorId, + startTimer, + hashObj, + getHtmlRendererExecutable, + assertNever, + hash, + protectString, + InteractiveStdOut, + InteractiveReply, + InteractiveMessage, + literal, +} from '@sofie-package-manager/api' + +import { IWorkInProgress, WorkInProgress } from '../../../lib/workInProgress' +import { checkWorkerHasAccessToPackageContainersOnPackage, lookupAccessorHandles, LookupPackageContainer } from './lib' +import { isFileFulfilled, isFileReadyToStartWorkingOn } from './lib/file' +import { ExpectationHandlerGenericWorker, GenericWorker } from '../genericWorker' +import { + isFileShareAccessorHandle, + isHTTPAccessorHandle, + isHTTPProxyAccessorHandle, + isLocalFolderAccessorHandle, +} from '../../../accessorHandlers/accessor' +import { LocalFolderAccessorHandle } from '../../../accessorHandlers/localFolder' +import { PackageReadStream, PutPackageHandler } from '../../../accessorHandlers/genericHandle' +import { ByteCounter } from '../../../lib/streamByteCounter' +import { fetchWithTimeout } from '../../../accessorHandlers/lib/fetch' + +/** + * Copies a file from one of the sources and into the target PackageContainer + */ +export const RenderHTML: ExpectationHandlerGenericWorker = { + doYouSupportExpectation(exp: Expectation.Any, genericWorker: GenericWorker): ReturnTypeDoYouSupportExpectation { + if (genericWorker.testHTMLRenderer) + return { + support: false, + reason: { + user: 'There is an issue with the Worker (HTMLRenderer)', + tech: `Cannot access HTMLRenderer executable: ${genericWorker.testHTMLRenderer}`, + }, + } + return checkWorkerHasAccessToPackageContainersOnPackage(genericWorker, { + sources: exp.startRequirement.sources, + targets: exp.endRequirement.targets, + }) + }, + getCostForExpectation: async ( + exp: Expectation.Any, + worker: BaseWorker + ): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + return getStandardCost(exp, worker) + }, + isExpectationReadyToStartWorkingOn: async ( + exp: Expectation.Any, + worker: BaseWorker + ): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + + const steps = getSteps(exp) + const { mainFileName } = getFileNames(steps) + if (!mainFileName) + return { + ready: false, + reason: { + user: 'No output (this is a configuration issue)', + tech: `No output filename (${steps.length} steps)`, + }, + } + + const lookupSource = await lookupSources(worker, exp) + const lookupTarget = await lookupTargets(worker, exp, mainFileName) + + return isFileReadyToStartWorkingOn(worker, lookupSource, lookupTarget) + }, + isExpectationFulfilled: async ( + exp: Expectation.Any, + _wasFulfilled: boolean, + worker: BaseWorker + ): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + + const steps = getSteps(exp) + const { fileNames, mainFileName } = getFileNames(steps) + if (!mainFileName) + return { + fulfilled: false, + reason: { + user: 'No output (this is a configuration issue)', + tech: `No output filename (${steps.length} steps)`, + }, + } + const lookupSource = await lookupSources(worker, exp) + + // First, check metadata + const mainLookupTarget = await lookupTargets(worker, exp, mainFileName) + + // Do a full check on the main file: + const mainFulfilledStatus = await isFileFulfilled(worker, lookupSource, mainLookupTarget) + if (!mainFulfilledStatus.fulfilled) return mainFulfilledStatus + + // Go through the other files, just check that they exist: + for (const fileName of fileNames) { + if (fileName === mainFileName) continue // already checked + + const lookupTarget = await lookupTargets(worker, exp, fileName) + + if (!lookupTarget.ready) + return { + fulfilled: false, + reason: { + user: `Not able to access target, due to: ${lookupTarget.reason.user} `, + tech: `Not able to access target: ${lookupTarget.reason.tech}`, + }, + } + + const issuePackage = await lookupTarget.handle.checkPackageReadAccess() + if (!issuePackage.success) { + return { + fulfilled: false, + reason: { + user: `Target package: ${issuePackage.reason.user}`, + tech: `Target package: ${issuePackage.reason.tech}`, + }, + } + } + } + return { + fulfilled: true, + } + }, + workOnExpectation: async (exp: Expectation.Any, worker: BaseWorker): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + // Render the HTML file + + /* + * What this one does is: + * 1. Spin up the HtmlRenderer executable and send it commands + to render the HTML file, take screenshots, record video etc, + according to the steps defined in the expectation. + 2. The output from HtmlRenderer executable are files in a temporary folder. + 3. Copy the files from the temporary folder to the target PackageContainer. + 4. Clean up the temporary files. + */ + const htmlRenderHandler = new HTMLRenderHandler(exp, worker) + + const lookupSource = await lookupSources(worker, exp) + if (!lookupSource.ready) throw new Error(`Can't start working due to source: ${lookupSource.reason.tech}`) + + const mainLookupTarget = await lookupTargets(worker, exp, htmlRenderHandler.mainFileName) + if (!mainLookupTarget.ready) + throw new Error(`Can't start working due to target: ${mainLookupTarget.reason.tech}`) + + const sourceHandle = lookupSource.handle + const mainTargetHandle = mainLookupTarget.handle + + if ( + (lookupSource.accessor.type === Accessor.AccessType.LOCAL_FOLDER || + lookupSource.accessor.type === Accessor.AccessType.FILE_SHARE || + lookupSource.accessor.type === Accessor.AccessType.HTTP || + lookupSource.accessor.type === Accessor.AccessType.HTTP_PROXY) && + (mainLookupTarget.accessor.type === Accessor.AccessType.LOCAL_FOLDER || + mainLookupTarget.accessor.type === Accessor.AccessType.FILE_SHARE || + mainLookupTarget.accessor.type === Accessor.AccessType.HTTP_PROXY) + ) { + if ( + !isLocalFolderAccessorHandle(sourceHandle) && + !isFileShareAccessorHandle(sourceHandle) && + !isHTTPAccessorHandle(sourceHandle) && + !isHTTPProxyAccessorHandle(sourceHandle) + ) + throw new Error(`Source AccessHandler type is wrong`) + if ( + !isLocalFolderAccessorHandle(mainTargetHandle) && + !isFileShareAccessorHandle(mainTargetHandle) && + !isHTTPProxyAccessorHandle(mainTargetHandle) + ) + throw new Error(`Target AccessHandler type is wrong`) + + let url: string + if (isLocalFolderAccessorHandle(sourceHandle)) { + url = `file://${sourceHandle.fullPath}` + } else if (isFileShareAccessorHandle(sourceHandle)) { + url = `file://${sourceHandle.fullPath}` + } else if (isHTTPAccessorHandle(sourceHandle)) { + url = `${sourceHandle.fullUrl}` + } else if (isHTTPProxyAccessorHandle(sourceHandle)) { + url = `${sourceHandle.fullUrl}` + } else { + assertNever(sourceHandle) + throw new Error(`Unsupported Source AccessHandler`) + } + + const workInProgress = new WorkInProgress({ workLabel: `Generating preview of "${url}"` }, async () => { + // On cancel + htmlRenderHandler.cancel() + }).do(async () => { + await lookupSource.handle.getPackageActualVersion() + + await htmlRenderHandler.run({ + workInProgress, + lookupSource, + mainLookupTarget, + url, + }) + }) + + return workInProgress + } else { + throw new Error( + `RenderHTML.workOnExpectation: Unsupported accessor source-target pair "${lookupSource.accessor.type}"-"${mainLookupTarget.accessor.type}"` + ) + } + }, + removeExpectation: async (exp: Expectation.Any, worker: BaseWorker): Promise => { + if (!isHTMLRender(exp)) throw new Error(`Wrong exp.type: "${exp.type}"`) + // Remove the files on the location + + const steps = getSteps(exp) + const { fileNames, mainFileName } = getFileNames(steps) + if (!mainFileName) + throw new Error( + `Can't start working due to no mainFileName (${steps.length} steps). This is a configuration issue.` + ) + + const lookupSource = await lookupSources(worker, exp) + if (!lookupSource.ready) throw new Error(`Can't start working due to source: ${lookupSource.reason.tech}`) + + const mainLookupTarget = await lookupTargets(worker, exp, mainFileName) + if (!mainLookupTarget.ready) { + return { + removed: false, + reason: { + user: `Can't access target, due to: ${mainLookupTarget.reason.user}`, + tech: `No access to target: ${mainLookupTarget.reason.tech}`, + }, + } + } + + for (const fileName of fileNames) { + if (fileName === mainFileName) continue // remove this last + const lookupTarget = await lookupTargets(worker, exp, fileName) + if (!lookupTarget.ready) { + throw new Error(`Cannot remove files due to target: ${lookupTarget.reason.tech}`) + } + + try { + await lookupTarget.handle.removePackage('expectation removed') + } catch (err) { + return { + removed: false, + reason: { + user: `Cannot remove file due to an internal error`, + tech: `Cannot remove file: ${stringifyError(err)}`, + }, + } + } + } + // Remove the main one last: + try { + await mainLookupTarget.handle.removePackage('expectation removed') + } catch (err) { + return { + removed: false, + reason: { + user: `Cannot remove file due to an internal error`, + tech: `Cannot remove file: ${stringifyError(err)}`, + }, + } + } + + return { + removed: true, + // reason: `Removed file "${exp.endRequirement.content.path}" from target` + } + }, +} +function isHTMLRender(exp: Expectation.Any): exp is Expectation.RenderHTML { + return exp.type === Expectation.Type.RENDER_HTML +} + +async function lookupSources( + worker: BaseWorker, + exp: Expectation.RenderHTML +): Promise> { + return lookupAccessorHandles( + worker, + exp.startRequirement.sources, + exp.startRequirement.content, + exp.workOptions, + { + read: true, + readPackage: true, + packageVersion: exp.startRequirement.version, + } + ) +} +async function lookupTargets( + worker: BaseWorker, + exp: Expectation.RenderHTML, + filePath: string +): Promise> { + return lookupAccessorHandles( + worker, + exp.endRequirement.targets, + { + filePath, + }, + exp.workOptions, + { + write: true, + writePackageContainer: true, + } + ) +} +type Steps = Required['steps'] +function getSteps(exp: Expectation.RenderHTML): Steps { + let steps: Steps + if (exp.endRequirement.version.casparCG) { + // Generate a set of steps for standard CasparCG templates + const casparData = exp.endRequirement.version.casparCG.data + const casparDataJSON = typeof casparData === 'string' ? casparData : JSON.stringify(casparData) + steps = [ + { do: 'waitForLoad' }, + { do: 'takeScreenshot', fileName: 'idle.png' }, + { do: 'startRecording', fileName: 'preview.webm' }, + { do: 'executeJs', js: `update(${casparDataJSON})` }, + { do: 'executeJs', js: `play()` }, + { do: 'sleep', duration: 1000 }, + { do: 'takeScreenshot', fileName: 'play.png' }, + { do: 'executeJs', js: `stop()` }, + { do: 'sleep', duration: 1000 }, + { do: 'takeScreenshot', fileName: 'stop.png' }, + { do: 'stopRecording' }, + { do: 'cropRecording', fileName: 'preview-cropped.webm' }, + ] + } else { + steps = exp.endRequirement.version.steps || [] + } + // Add prefix to steps fileName: + return steps.map((org) => { + const step = { ...org } + if ('fileName' in step) { + step.fileName = `${exp.endRequirement.content.prefix ?? ''}${step.fileName}` + } + return step + }) +} +function getFileNames(steps: Steps) { + const fileNames: string[] = [] + let mainFileName: string | undefined = undefined + for (const step of steps) { + if (step.do === 'takeScreenshot') { + fileNames.push(step.fileName) + if (!mainFileName) mainFileName = step.fileName + } else if (step.do === 'startRecording') { + fileNames.push(step.fileName) + mainFileName = step.fileName + } else if (step.do === 'cropRecording') { + fileNames.push(step.fileName) + } + } + return { fileNames, mainFileName } +} +function compact(array: (T | undefined | null | false)[]): T[] { + return array.filter(Boolean) as T[] +} +async function unlinkIfExists(path: string) { + try { + await fs.unlink(path) + } catch { + // ignore errors + } +} + +class HTMLRenderHandler { + public readonly mainFileName: string + + private wasCancelled = false + private sourceStream: PackageReadStream | undefined = undefined + private writeStream: PutPackageHandler | undefined = undefined + private steps: Steps + private fileNames: string[] + private timer: { get: () => number } + private htmlRenderer: HTMLRenderer | undefined + + private outputPath: string + private executeSteps: { step: Steps[number]; duration: number }[] + private outputFileNames: string[] + + constructor(public readonly exp: Expectation.RenderHTML, public readonly worker: BaseWorker) { + this.steps = getSteps(exp) + const f = getFileNames(this.steps) + if (!f.mainFileName) + throw new Error( + `Can't start working due to no mainFileName (${this.steps.length} steps). This is a configuration issue.` + ) + this.fileNames = f.fileNames + this.mainFileName = f.mainFileName + + this.timer = startTimer() + + this.outputPath = path.resolve('./tmpRenderHTML') + // Prefix the work-in-progress artifacts with a unique identifier + const filePrefix = hash(`${process.pid}_${Math.random()}`) + this.executeSteps = this.steps.map((step) => { + let defaultDuration = 500 + if ( + step.do === 'storeObject' || + step.do === 'modifyObject' || + step.do === 'injectObject' || + step.do === 'executeJs' + ) + defaultDuration = 10 + + const executeStep = { + step, + duration: 'duration' in step ? step.duration : defaultDuration, // Used to calculate total duration + } + if ('fileName' in executeStep.step) { + executeStep.step.fileName = `${filePrefix}_${executeStep.step.fileName}` + } + return executeStep + }) + const { fileNames } = getFileNames(this.executeSteps.map((s) => s.step)) + this.outputFileNames = fileNames + } + + cancel = () => { + this.wasCancelled = true + + this.htmlRenderer?.cancel() + // ffProbeProcess?.cancel() + this.sourceStream?.cancel() + this.writeStream?.abort() + } + + async run(options: { + workInProgress: WorkInProgress + lookupSource: LookupPackageContainer + mainLookupTarget: LookupPackageContainer + url: string + }) { + if (!options.lookupSource.ready) + throw new Error(`Can't start working due to source: ${options.lookupSource.reason.tech}`) + if (!options.mainLookupTarget.ready) + throw new Error(`Can't start working due to target: ${options.mainLookupTarget.reason.tech}`) + + const workInProgress = options.workInProgress + + const actualSourceVersion = await options.lookupSource.handle.getPackageActualVersion() + const actualSourceVersionHash = hashObj(actualSourceVersion) + const actualSourceUVersion = makeUniversalVersion(actualSourceVersion) + + try { + // Remote old temp files, if they exist: + await this.cleanTempFiles() + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.initialCleanTempFiles) + + // Render HTML file according to the steps: + this.htmlRenderer = new HTMLRenderer( + this.worker, + this.exp, + options.url, + workInProgress, + actualSourceVersionHash, + this.executeSteps, + this.outputPath + ) + await this.htmlRenderer.done + + // Move files to the target: + await this.moveOutputFilesToTarget({ + workInProgress, + actualSourceVersionHash, + }) + + // Write metadata + if (!this.wasCancelled) { + await options.mainLookupTarget.handle.updateMetadata(actualSourceUVersion) + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.writeMetadata) + } + + // Clean temp files: + await this.cleanTempFiles() + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.cleanTempFiles) + + // Clean other old files: + const files = await fs.readdir(this.outputPath) + await Promise.all( + files.map(async (file) => { + const fullPath = path.join(this.outputPath, file) + const lStat = await fs.lstat(fullPath) + if (Date.now() - lStat.mtimeMs > 1000 * 3600) { + await unlinkIfExists(fullPath) + } + }) + ) + + workInProgress._reportProgress(actualSourceVersionHash, REPORT_PROGRESS.cleanOutputFiles) + + const duration = this.timer.get() + workInProgress._reportComplete( + actualSourceVersionHash, + { + user: `HTML Rendering completed in ${Math.round(duration / 100) / 10}s`, + tech: `HTML Rendering completed at ${Date.now()}`, + }, + undefined + ) + } catch (e) { + // cleanup + this.cancel() + + throw e + } finally { + await this.cleanTempFiles() + } + } + async moveOutputFilesToTarget(options: { workInProgress: WorkInProgress; actualSourceVersionHash: string }) { + // Move all this.outputFileNames files to our target + + for (let i = 0; i < this.outputFileNames.length; i++) { + if (this.wasCancelled) break + const tempFileName = this.outputFileNames[i] + const fileName = this.fileNames[i] + const localFileSourceHandle = new LocalFolderAccessorHandle( + this.worker, + protectString('tmpLocalHTMLRenderer'), + { + type: Accessor.AccessType.LOCAL_FOLDER, + allowRead: true, + folderPath: this.outputPath, + filePath: tempFileName, + }, + {}, + {} + ) + const lookupTarget = await lookupTargets(this.worker, this.exp, fileName) + if (!lookupTarget.ready) throw new Error(`Can't start working due to target: ${lookupTarget.reason.tech}`) + + const fileOperation = await lookupTarget.handle.prepareForOperation( + 'Copy file, using streams', + localFileSourceHandle + ) + + options.workInProgress._reportProgress( + options.actualSourceVersionHash, + REPORT_PROGRESS.copyFilesToTarget + 0.29 * (i / this.outputFileNames.length) + ) + + const fileSize = (await fs.stat(localFileSourceHandle.fullPath)).size + const byteCounter = new ByteCounter() + byteCounter.on('progress', (bytes: number) => { + if (this.writeStream?.usingCustomProgressEvent) return // ignore this callback, we'll be listening to writeStream.on('progress') instead. + + if (fileSize) { + const progress = bytes / fileSize + options.workInProgress._reportProgress( + options.actualSourceVersionHash, + REPORT_PROGRESS.copyFilesToTarget + 0.29 * ((i + progress) / this.outputFileNames.length) + ) + } + }) + + this.sourceStream = await localFileSourceHandle.getPackageReadStream() + this.writeStream = await lookupTarget.handle.putPackageStream( + this.sourceStream.readStream.pipe(byteCounter) + ) + this.writeStream.on('error', (err) => options.workInProgress._reportError(err)) + + if (this.writeStream.usingCustomProgressEvent) { + this.writeStream.on('progress', (progress) => { + options.workInProgress._reportProgress( + options.actualSourceVersionHash, + REPORT_PROGRESS.copyFilesToTarget + 0.29 * ((i + progress) / this.outputFileNames.length) + ) + }) + } + + await new Promise((resolve, reject) => { + if (!this.sourceStream) throw new Error(`sourceStream missing`) + if (!this.writeStream) throw new Error(`writeStream missing`) + + this.sourceStream.readStream.on('error', (err) => reject(err)) + this.writeStream.once('error', (err) => reject(err)) + this.writeStream.once('close', () => resolve()) + }) + this.writeStream.removeAllListeners() + this.writeStream.removeAllListeners() + + await lookupTarget.handle.finalizePackage(fileOperation) + } + } + private async cleanTempFiles() { + // Clean temp files: + await Promise.all( + this.outputFileNames.map(async (fileName) => unlinkIfExists(path.join(this.outputPath, fileName))) + ) + } +} + +class HTMLRenderer { + public done: Promise + + // @ts-expect-error is set in constructor + private resolve: () => void + // @ts-expect-error is set in constructor + private reject: (err: unknown) => void + + private totalStepDuration: number + private htmlRendererProcess: ChildProcessWithoutNullStreams | undefined = undefined + private ws: WebSocket | undefined = undefined + + private commandStepIndex = 0 + private commandStepDuration = 0 + private waitingForCommand: string | null = null + private timeoutDoNextCommand: NodeJS.Timeout | undefined = undefined + private processLastFewLines: string[] = [] + + private storedDataObjects: { + [key: string]: Record + } = {} + + constructor( + private worker: BaseWorker, + private exp: Expectation.RenderHTML, + private url: string, + private workInProgress: WorkInProgress, + private actualSourceVersionHash: string, + private executeSteps: { step: Steps[number]; duration: number }[], + private outputPath: string + ) { + this.totalStepDuration = this.executeSteps.reduce((prev, cur) => prev + cur.duration, 0) || 1 + + this.done = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + + this.spawnHTMLRendererProcess() + }) + } + cancel() { + // ensure this function doesn't throw, since it is called from various error event handlers + try { + this.htmlRendererProcess?.kill() + } catch (e) { + // This is probably OK, errors likely means that the process is already dead + } + this.htmlRendererProcess = undefined + } + private onError(err: unknown) { + if (err instanceof Error) { + err.message += `: ${this.processLastFewLines.join('\n')}` + } else if (typeof err === 'string') { + err += `: ${this.processLastFewLines.join('\n')}` + } + + this.reject(err) + this.cancel() + } + private onComplete() { + this.resolve() + } + + private spawnHTMLRendererProcess() { + this.htmlRendererProcess = spawn( + getHtmlRendererExecutable(), + compact([ + `--`, + `--url=${this.url}`, + this.exp.endRequirement.version.renderer?.width !== undefined && + `--width=${this.exp.endRequirement.version.renderer?.width}`, + this.exp.endRequirement.version.renderer?.height !== undefined && + `--height=${this.exp.endRequirement.version.renderer?.height}`, + this.exp.endRequirement.version.renderer?.zoom !== undefined && + `--zoom=${this.exp.endRequirement.version.renderer?.zoom}`, + `--outputPath=${this.outputPath}`, + `--interactive=true`, + ]), + { + windowsVerbatimArguments: true, // To fix an issue with arguments on Windows + } + ) + + const onClose = (code: number | null) => { + if (this.htmlRendererProcess) { + this.htmlRendererProcess.removeAllListeners() + this.htmlRendererProcess.stdin.removeAllListeners() + this.htmlRendererProcess.stdout.removeAllListeners() + + this.htmlRendererProcess = undefined + if (code === 0) { + // Do nothing + } else { + this.onError(new Error(`HTMLRenderer exit code ${code}`)) + } + } + } + this.htmlRendererProcess.on('close', (code) => onClose(code)) + this.htmlRendererProcess.on('exit', (code) => onClose(code)) + this.htmlRendererProcess.on('error', (err) => { + this.onError(new Error(`HTMLRenderer error: ${stringifyError(err)}`)) + }) + + let waitingForReady = true + this.htmlRendererProcess.stderr.on('data', (data) => { + const str = data.toString() + this.worker.logger.debug(`HTMLRenderer: stderr: ${str}`) + this.processLastFewLines.push(str) + if (this.processLastFewLines.length > 10) this.processLastFewLines.shift() + }) + this.htmlRendererProcess.stdout.on('data', (data) => { + try { + const str = data.toString() + this.worker.logger.debug(`HTMLRenderer: stdout: ${str}`) + this.processLastFewLines.push(str) + if (this.processLastFewLines.length > 10) this.processLastFewLines.shift() + + let message: InteractiveStdOut + try { + message = JSON.parse(str) + } catch { + // ignore parse errors + return + } + if (message.status === 'listening') { + // This message indicates that the HTML renderer is ready to accept interactive commands on the websocket server + if (waitingForReady) { + waitingForReady = false + + this.workInProgress._reportProgress( + this.actualSourceVersionHash, + REPORT_PROGRESS.setupWebSocketConnection + ) + this.setupWebSocketConnection(message.port).catch((e) => this.onError(e)) + } else { + this.onError( + new Error(`Unexpected reply from HTMLRenderer: ${message} (not waiting for 'listening')`) + ) + this.cancel() + } + } else { + assertNever(message.status) + } + } catch (e) { + this.onError(e) + } + }) + } + + private async setupWebSocketConnection(port: number) { + if (this.ws) throw new Error(`WebSocket already set up`) + + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + this.ws = ws + ws.once('close', () => { + ws.removeAllListeners() + delete this.ws + }) + ws.on('message', (data) => { + try { + const str = data.toString() + + this.worker.logger.debug(`HTMLRenderer: Received reply: ${str}`) + + let message: InteractiveReply | undefined = undefined + try { + message = JSON.parse(str) + } catch { + // ignore parse errors + } + + if (!message) { + // Other output, log and ignore: + this.worker.logger.debug(`HTMLRenderer: ${str}`) + } else if (message.error) { + this.onError(new Error(`Error reply from HTMLRenderer: ${message.error}`)) + } else if (message.reply) { + if (!this.waitingForCommand) { + this.onError( + new Error(`Unexpected reply from HTMLRenderer: ${message.reply} (not waiting for command)`) + ) + } else if (message.reply === this.waitingForCommand) { + // This message indicates that the HTML renderer has completed the previous command + this.waitingForCommand = null + + this.doNextCommand() + } else { + this.onError( + new Error(`Unexpected reply from HTMLRenderer: ${message.reply} (not waiting for command)`) + ) + } + } else { + assertNever(message) + // Other output, log and ignore: + this.worker.logger.debug(`HTMLRenderer: ${str}`) + } + } catch (e) { + this.onError(e) + } + }) + await new Promise((resolve, reject) => { + ws.once('open', resolve) + ws.once('error', reject) + }) + + this.worker.logger.debug(`HTMLRenderer: WebSocket connected`) + this.doNextCommand() + } + + private doNextCommand() { + if (!this.ws) throw new Error(`WebSocket not set up`) + if (this.timeoutDoNextCommand) { + clearTimeout(this.timeoutDoNextCommand) + this.timeoutDoNextCommand = undefined + } + if (this.waitingForCommand) throw new Error('Already waiting for command') + + const currentStep = this.executeSteps[this.commandStepIndex] + this.commandStepIndex++ + + if (currentStep) { + const stepStartDuration = this.commandStepDuration + this.commandStepDuration += currentStep.duration + + const reportStepProgress = ( + /** Progress within the step [0-1] */ + progress: number + ) => { + this.workInProgress._reportProgress( + this.actualSourceVersionHash, + REPORT_PROGRESS.sendCommands + + 0.49 * ((stepStartDuration + progress * currentStep.duration) / this.totalStepDuration) + ) + } + reportStepProgress(0) + if (currentStep.step.do === 'sleep') { + this.worker.logger.debug(`HTMLRenderer: Sleeping for ${currentStep.step.duration}ms`) + + // While sleeping, continuously report the progress + let reportTime = 0 + const reportInterval = setInterval(() => { + reportTime += 500 + reportStepProgress(reportTime / currentStep.duration) + }, 500) + setTimeout(() => { + clearInterval(reportInterval) + this.doNextCommand() + }, currentStep.step.duration) + } else if (currentStep.step.do === 'sendHTTPCommand') { + this.worker.logger.debug(`HTMLRenderer: Send HTTP command: ${JSON.stringify(currentStep.step)}`) + const step = currentStep.step + + fetchWithTimeout(step.url, { + method: step.method, + headers: step.headers, + body: step.body, + }) + .catch((err) => { + this.onError( + new Error( + `HTMLRenderer: Error when sending "${step.method}" to "${step.url}": ${stringifyError( + err + )}` + ) + ) + }) + .finally(() => { + this.doNextCommand() + }) + } else if (currentStep.step.do === 'storeObject') { + this.worker.logger.debug(`HTMLRenderer: Store object "${currentStep.step.key}"`) + this.storedDataObjects[currentStep.step.key] = currentStep.step.value + this.doNextCommand() + } else if (currentStep.step.do === 'modifyObject') { + this.worker.logger.debug(`HTMLRenderer: Modify object "${currentStep.step.key}"`) + + const obj = this.storedDataObjects[currentStep.step.key] + if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) + + modifyObject(obj, currentStep.step.path, currentStep.step.value) + this.doNextCommand() + } else if (currentStep.step.do === 'injectObject') { + this.worker.logger.debug(`HTMLRenderer: Inject object "${currentStep.step.key}"`) + + const obj = this.storedDataObjects[currentStep.step.key] + if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) + + const receivingFunction = currentStep.step.receivingFunction ?? 'window.postMessage' + + // Execute javascript in the renderer, to simulate a postMessage event: + const cmd: InteractiveMessage = { + do: 'executeJs', + js: `${receivingFunction}(${JSON.stringify(obj)})`, + } + // Send command to the renderer: + this.setCommandToRenderer(cmd) + } else { + this.worker.logger.debug(`HTMLRenderer: Send command: ${JSON.stringify(currentStep.step)}`) + + // Send command to the renderer: + this.setCommandToRenderer(currentStep.step) + } + } else { + // Done, no more commands to send. + + this.workInProgress._reportProgress(this.actualSourceVersionHash, REPORT_PROGRESS.sendCommands + 0.49) + + // Send a close command to the renderer + this.ws.send(JSON.stringify(literal({ do: 'close' }))) + + // Wait a little bit before completion + setTimeout(() => { + this.ws?.close() + this.onComplete() + }, 500) + } + } + private setCommandToRenderer(cmd: InteractiveMessage) { + if (!this.ws) throw new Error(`WebSocket not set up`) + + this.ws.send(JSON.stringify(cmd) + '\n') + this.waitingForCommand = cmd.do + + this.timeoutDoNextCommand = setTimeout(() => { + this.onError(new Error(`Timeout waiting for command "${cmd.do}" after ${COMMAND_TIMEOUT} ms`)) + }, COMMAND_TIMEOUT) + } +} + +const COMMAND_TIMEOUT = 10000 +const REPORT_PROGRESS = { + initialCleanTempFiles: 0.05, + setupWebSocketConnection: 0.08, + sendCommands: 0.1, + copyFilesToTarget: 0.6, + writeMetadata: 0.9, + cleanTempFiles: 0.95, + cleanOutputFiles: 0.97, +} + +/** Modify an property inside an object */ +function modifyObject(obj: Record | Record[], objPath: string | string[], value: unknown) { + if (typeof objPath === 'string') objPath = objPath.split('.') + + if (typeof obj === 'object' && obj !== null) { + if (Array.isArray(obj)) { + const index = parseInt(objPath[0], 10) + if (isNaN(index)) throw new Error(`Invalid array key: ${objPath[0]}`) + + if (objPath.length === 1) { + obj[index] = value + } else { + modifyObject(obj[index], objPath.slice(1), value) + } + } else { + const key = objPath[0] + if (objPath.length === 1) { + obj[key] = value + } else { + modifyObject(obj[key], objPath.slice(1), value) + } + } + } else { + throw new Error(`Invalid object path: ${objPath.join('.')}`) + } +} diff --git a/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts b/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts index 57abf7c2..637888c9 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/genericWorker.ts @@ -32,7 +32,7 @@ import { testFFMpeg, testFFProbe } from './expectationHandlers/lib/ffmpeg' import { JsonDataCopy } from './expectationHandlers/jsonDataCopy' import { SetupPackageContainerMonitorsResult } from '../../accessorHandlers/genericHandle' import { FileVerify } from './expectationHandlers/fileVerify' -import { RenderHTML } from './expectationHandlers/RenderHTML' +import { RenderHTML } from './expectationHandlers/renderHTML' export type ExpectationHandlerGenericWorker = ExpectationHandler From c3775a71783e3ca915d950cb44055ed7e3f4f60e Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 10:02:37 +0200 Subject: [PATCH 20/46] fix: html-renderer: add support for transparent backgrounds --- apps/html-renderer/app/src/config.ts | 6 ++++++ apps/html-renderer/app/src/index.ts | 1 + .../packages/generic/src/renderHTML.ts | 19 +++++++++++++------ shared/packages/api/src/expectationApi.ts | 4 ++-- shared/packages/api/src/inputApi.ts | 4 ++-- .../expectationHandlers/renderHTML.ts | 1 + 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/html-renderer/app/src/config.ts b/apps/html-renderer/app/src/config.ts index 5f2c7083..8877e46d 100644 --- a/apps/html-renderer/app/src/config.ts +++ b/apps/html-renderer/app/src/config.ts @@ -17,6 +17,10 @@ const htmlRendererOptions = defineArguments({ width: { type: 'number', describe: 'Width of the HTML renderer (default: 1920)' }, height: { type: 'number', describe: 'Width of the HTML renderer (default: 1080)' }, zoom: { type: 'number', describe: 'Zoom factor of the HTML renderer (default: 1)' }, + background: { + type: 'string', + describe: 'Background color, #RRGGBB, CSS-string or "transparent" or "default" (defaults: "default")', + }, outputPath: { type: 'string', describe: 'File path to where the output files will be saved' }, tempPath: { type: 'string', describe: 'File path to where temporary files will be saved (default: "tmp")' }, screenshots: { type: 'boolean', describe: 'When true, will capture screenshots' }, @@ -61,6 +65,7 @@ export interface HTMLRendererOptionsConfig { width: number | undefined height: number | undefined zoom: number | undefined + background: string | undefined outputPath: string | undefined tempPath: string | undefined screenshots: boolean | undefined @@ -92,6 +97,7 @@ export async function getHTMLRendererConfig(): Promise<{ width: argv.width, height: argv.height, zoom: argv.zoom, + background: argv.background, outputPath: argv.outputPath, tempPath: argv.tempPath, screenshots: argv.screenshots, diff --git a/apps/html-renderer/app/src/index.ts b/apps/html-renderer/app/src/index.ts index f17259ae..0c8247e6 100644 --- a/apps/html-renderer/app/src/index.ts +++ b/apps/html-renderer/app/src/index.ts @@ -225,6 +225,7 @@ async function main(): Promise { width: config.htmlRenderer.width ?? 1920, height: config.htmlRenderer.height ?? 1080, zoom: config.htmlRenderer.zoom ?? 1, + background: config.htmlRenderer.background, outputFolder: config.htmlRenderer.outputPath ?? '', tempFolder: config.htmlRenderer.tempPath ?? 'tmp', url, diff --git a/apps/html-renderer/packages/generic/src/renderHTML.ts b/apps/html-renderer/packages/generic/src/renderHTML.ts index 62b80f25..3d6b95eb 100644 --- a/apps/html-renderer/packages/generic/src/renderHTML.ts +++ b/apps/html-renderer/packages/generic/src/renderHTML.ts @@ -15,8 +15,8 @@ export interface RenderHTMLOptions { height?: number /** Zoom factor */ zoom?: number - /** Background color, default to black */ - backgroundColor?: string + /** Background color, default to "default" */ + background?: string tempFolder?: string outputFolder?: string @@ -66,6 +66,9 @@ export async function renderHTML(options: RenderHTMLOptions): Promise<{ // preload: join(__dirname, 'preload.js'), nodeIntegration: false, }, + transparent: true, + backgroundColor: '#00000000', // This is needed to be able to capture transparent screenshots + frame: false, height, width, }) @@ -106,10 +109,14 @@ export async function renderHTML(options: RenderHTMLOptions): Promise<{ // logger.verbose(`Loading done`) win.title = `HTML Renderer ${process.pid}` - - await win.webContents.insertCSS( - `html,body{ background-color: #${options.backgroundColor ?? '000000'} !important;}` - ) + let backgroundColor = options.background ?? 'default' + if (backgroundColor.match(/^[0-f]+$/)) { + // "RRGGBB" format + backgroundColor = '#' + backgroundColor + } + if (backgroundColor !== 'default') { + await win.webContents.insertCSS(`html,body{ background: ${backgroundColor} !important;}`) + } let exitCode = 0 diff --git a/shared/packages/api/src/expectationApi.ts b/shared/packages/api/src/expectationApi.ts index 835a5087..ce2c86e9 100644 --- a/shared/packages/api/src/expectationApi.ts +++ b/shared/packages/api/src/expectationApi.ts @@ -355,8 +355,8 @@ export namespace Expectation { width?: number height?: number zoom?: number - /** (defaults to black) */ - backgroundColor?: string + /** Background color, #RRGGBB, CSS-string, "transparent" or "default" (defaults to "default") */ + background?: string userAgent?: string } diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index 6a3ac8b8..3ed56973 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -212,8 +212,8 @@ export namespace ExpectedPackage { height?: number /** Zoom level, defaults to 1 */ zoom?: number - /** (defaults to black) */ - backgroundColor?: string + /** Background color, #RRGGBB, CSS-string, "transparent" or "default" (defaults to "default") */ + background?: string userAgent?: string } diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts index 57cc73ac..7d13334d 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -691,6 +691,7 @@ class HTMLRenderer { this.exp.endRequirement.version.renderer?.zoom !== undefined && `--zoom=${this.exp.endRequirement.version.renderer?.zoom}`, `--outputPath=${this.outputPath}`, + `--background=${this.exp.endRequirement.version.renderer?.background ?? 'default'}`, `--interactive=true`, ]), { From b7031b2d20c71042a50b9950c25e2c05dc3a8aeb Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 10:07:13 +0200 Subject: [PATCH 21/46] chore: doc --- shared/packages/api/src/htmlRenderer.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shared/packages/api/src/htmlRenderer.ts b/shared/packages/api/src/htmlRenderer.ts index 4b0d2e60..b23034f9 100644 --- a/shared/packages/api/src/htmlRenderer.ts +++ b/shared/packages/api/src/htmlRenderer.ts @@ -96,12 +96,19 @@ export type InteractiveStdOut = { status: 'listening'; port: number } /** Messages sent into HTMLRenderer process over websocket */ export type InteractiveMessage = + // Tell the HTML Renderer to wait for the page to load, it'll then emit the waitForLoad reply when page has loaded | { do: 'waitForLoad' } + // Tell the HTML Renderer to take a screenshot, it'll then emit the takeScreenshot reply when done | { do: 'takeScreenshot'; fileName: string } + // Tell the HTML Renderer to start recording, it'll then emit the startRecording reply when the recording has started | { do: 'startRecording'; fileName: string } + // Tell the HTML Renderer to stop recording, it'll then emit the stopRecording reply when the recording has stopped | { do: 'stopRecording' } + // Tell the HTML Renderer to crop the recording, it'll then emit the cropRecording reply when the recording has been cropped | { do: 'cropRecording'; fileName: string } + // Tell the HTML Renderer to execute some JavaScript in the page, it'll then emit the executeJs reply when the script has been executed | { do: 'executeJs'; js: string } + // Tell the HTML Renderer to close and quit | { do: 'close' } /** Messages sent from HTMLRenderer process over websocket */ export type InteractiveReply = From 0ad92d194f9ef6a28309c3cbb72082f1f2df3f08 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 12:11:03 +0200 Subject: [PATCH 22/46] chore: refactor RengerHTML code --- .../packages/generic/src/BrowserRenderer.ts | 387 ++++++++++++++++ .../packages/generic/src/renderHTML.ts | 436 ++---------------- 2 files changed, 426 insertions(+), 397 deletions(-) create mode 100644 apps/html-renderer/packages/generic/src/BrowserRenderer.ts diff --git a/apps/html-renderer/packages/generic/src/BrowserRenderer.ts b/apps/html-renderer/packages/generic/src/BrowserRenderer.ts new file mode 100644 index 00000000..dc25a1bd --- /dev/null +++ b/apps/html-renderer/packages/generic/src/BrowserRenderer.ts @@ -0,0 +1,387 @@ +import { BrowserWindow } from 'electron' +import * as path from 'path' +import * as fs from 'fs' +import { spawn } from 'child_process' +import { InteractiveAPI, RenderHTMLOptions } from './renderHTML' +import { escapeFilePath, getFFMpegExecutable, LoggerInstance } from '@sofie-package-manager/api' + +export class BrowserRenderer implements InteractiveAPI { + private logger: LoggerInstance + private width: number + private height: number + private tempFolder: string + private outputFolder: string + private win: BrowserWindow + + private didFinishLoad = false + private didFailLoad: null | any = null + + /** Current recording */ + private recording: { + fileName: string + tmpFolder: string + writeFilePromises: Promise[] + stopped: boolean + } | null = null + + private onStoppedListeners: (() => void)[] = [] + + constructor(logger: LoggerInstance, private options: RenderHTMLOptions) { + this.logger = logger.category('BrowserRenderer') + + this.width = options.width || 1920 + this.height = options.height || 1080 + const zoom = options.zoom || 1 + this.tempFolder = options.tempFolder || 'tmp' + this.outputFolder = options.outputFolder || '' + + this.win = new BrowserWindow({ + show: false, + alwaysOnTop: true, + webPreferences: { + // preload: join(__dirname, 'preload.js'), + nodeIntegration: false, + }, + transparent: true, + backgroundColor: '#00000000', // This is needed to be able to capture transparent screenshots + frame: false, + height: this.height, + width: this.width, + }) + this.win.once('ready-to-show', () => { + // Wait for ready-to-show event to fire, + // otherwise zetZoomFactor will not work: + this.win.webContents.setZoomFactor(zoom) + }) + this.win.webContents.setAudioMuted(true) + if (options.userAgent) this.win.webContents.setUserAgent(options.userAgent) + } + get isRecording(): boolean { + return this.recording !== null + } + async init(): Promise { + this.win.webContents.on('did-finish-load', () => (this.didFinishLoad = true)) + this.win.webContents.on('did-fail-load', (e) => { + this.didFailLoad = e + }) + + this.logger.verbose(`Loading URL: ${this.options.url}`) + this.win.loadURL(this.options.url).catch((_e) => { + // ignore, instead rely on 'did-finish-load' and 'did-fail-load' later + }) + + // logger.verbose(`Loading done`) + + this.win.title = `HTML Renderer ${process.pid}` + + // Set the background color: + { + let backgroundColor = this.options.background ?? 'default' + if (backgroundColor.match(/^[0-f]+$/)) { + // "RRGGBB" format + backgroundColor = '#' + backgroundColor + } + if (backgroundColor !== 'default') { + await this.win.webContents.insertCSS(`html,body{ background: ${backgroundColor} !important;}`) + } + } + } + close(): void { + this.win.close() + } + async waitForLoad(): Promise { + if (this.didFinishLoad) return // It had already loaded + if (this.didFailLoad) throw new Error(`${this.didFailLoad}`) + else + await new Promise((resolve) => { + this.win.webContents.once('did-finish-load', () => resolve()) + }) + } + async takeScreenshot(fileName: string): Promise { + if (!fileName) throw new Error(`Invalid filename`) + + const image = await this.win.webContents.capturePage() + const filename = path.join(this.outputFolder, fileName) + + await fs.promises.mkdir(path.dirname(filename), { recursive: true }) + + if (fileName.endsWith('.png')) { + await fs.promises.writeFile(filename, image.toPNG()) + } else if (fileName.endsWith('.jpeg')) { + await fs.promises.writeFile(filename, image.toJPEG(90)) + } else { + throw new Error(`Unsupported file format: ${fileName}`) + } + this.logger.verbose(`Screenshot: ${fileName}`) + return filename + } + async executeJs(js: string): Promise { + this.logger.verbose(`Executing js: ${js}`) + await this.win.webContents.executeJavaScript(js) + } + /** Records a recording */ + async record( + fileName: string, + /** If set, will stop the recording if no frames arrive in this time */ + idleFrameTime: number + ): Promise<{ + fileName: string + /** A promise that will resolve when the recording has stoped */ + stopped: Promise + }> { + const startTime = Date.now() + const stopped = new Promise((resolve) => { + this.onStoppedListeners.push(() => { + clearTimeout(endRecordingTimeout) + resolve() + }) + }) + + const endRecording = () => { + this.stopRecording().catch((e) => this.logger.error(`Error stopping recording: ${e}`)) + } + + let endRecordingTimeout = setTimeout(() => { + endRecording() + }, idleFrameTime) + + const frameListener = (frameIndex: number) => { + // This callback is called on each frame + this.logger.debug(`Frame ${frameIndex}, ${Date.now() - startTime}`) + + // End recording when idle: + clearTimeout(endRecordingTimeout) + endRecordingTimeout = setTimeout(() => { + endRecording() + }, idleFrameTime) + } + + const recordFileName = await this.startRecording(fileName, frameListener) + return { + fileName: recordFileName, + stopped: stopped, + } + } + + /** Start a recording, returns when the recording has started */ + async startRecording(fileName: string, frameListener?: (frameIndex: number) => void): Promise { + if (this.recording?.stopped) { + await this.cleanupTemporaryFiles() + this.recording = null + } + if (this.recording) throw new Error(`Already recording`) + + const fullFilename = path.join(this.outputFolder, fileName) + await fs.promises.mkdir(path.dirname(fullFilename), { recursive: true }) + + let i = 0 + + const tmpFolder = path.resolve(path.join(this.tempFolder, `recording${process.pid}`)) + await fs.promises.mkdir(tmpFolder, { recursive: true }) + + this.recording = { + fileName: `${fullFilename}`, + tmpFolder, + writeFilePromises: [], + stopped: false, + } + + this.win.webContents.beginFrameSubscription(false, (image) => { + if (!this.recording) throw new Error(`(internal error) received frame, but has no recording`) + i++ + frameListener?.(i) + + const buffer = image + .resize({ + width: this.width, + height: this.height, + }) + .toPNG() + + const tmpFile = path.join(tmpFolder, `img${pad(i, 5)}.png`) + this.recording.writeFilePromises.push(fs.promises.writeFile(tmpFile, buffer)) + }) + + return fullFilename + } + async stopRecording(): Promise { + if (!this.recording) throw new Error(`No current recording`) + if (this.recording.stopped) throw new Error(`Recording already stopped`) + this.recording.stopped = true + + this.win.webContents.endFrameSubscription() + + // Wait for all current file writes to finish: + await Promise.all(this.recording.writeFilePromises) + + let format: string + if (this.recording.fileName.endsWith('.webm')) { + format = 'webm' + } else if (this.recording.fileName.endsWith('.mp4')) { + format = 'mp4' + } else if (this.recording.fileName.endsWith('.mov')) { + format = 'mov' + } else { + throw new Error(`Unsupported file format: ${this.recording.fileName}`) + } + + // Convert the pngs to a video: + await this.ffmpeg([ + '-hide_banner', + '-y', + '-framerate', + '30', + '-s', + `${this.width}x${this.height}`, + '-i', + `${this.recording.tmpFolder}/img%05d.png`, + '-f', + format, // format: webm + '-an', // blocks all audio streams + '-c:v', + 'libvpx-vp9', // encoder for video (use VP9) + '-auto-alt-ref', + '1', + escapeFilePath(this.recording.fileName), + ]) + + await this.cleanupTemporaryFiles() + + this.onStoppedListeners.forEach((cb) => cb()) + + this.logger.verbose(`Recording: ${this.recording.fileName}`) + return this.recording.fileName + } + async cropRecording(croppedFilename0: string): Promise { + if (!this.recording) throw new Error(`No recording`) + if (!this.recording.stopped) throw new Error(`Recording not stopped yet`) + + const croppedFilename = path.join(this.outputFolder, croppedFilename0) + await fs.promises.mkdir(path.dirname(croppedFilename), { recursive: true }) + + // Figure out the active bounding box + const boundingBox = { + x1: Infinity, + x2: -Infinity, + y1: Infinity, + y2: -Infinity, + } + await this.ffmpeg( + [ + '-hide_banner', + '-i', + escapeFilePath(this.recording.fileName), + '-vf', + 'bbox=min_val=50', + '-f', + 'null', + '-', + ], + { + onStderr: (data) => { + // [Parsed_bbox_0 @ 000002b6f5d474c0] n:25 pts:833 pts_time:0.833 x1:205 x2:236 y1:614 y2:650 w:32 h:37 crop=32:37:205:614 drawbox=205:614:32:37 + const m = data.match(/Parsed_bbox.*x1:(?\d+).*x2:(?\d+).*y1:(?\d+).*y2:(?\d+)/) + if (m && m.groups) { + boundingBox.x1 = Math.min(boundingBox.x1, parseInt(m.groups.x1, 10)) + boundingBox.x2 = Math.max(boundingBox.x2, parseInt(m.groups.x2, 10)) + boundingBox.y1 = Math.min(boundingBox.y1, parseInt(m.groups.y1, 10)) + boundingBox.y2 = Math.max(boundingBox.y2, parseInt(m.groups.y2, 10)) + } + }, + } + ) + + if ( + !Number.isFinite(boundingBox.x1) || + !Number.isFinite(boundingBox.x2) || + !Number.isFinite(boundingBox.y1) || + !Number.isFinite(boundingBox.y2) + ) { + this.logger.warn(`Could not determine bounding box`) + // Just copy the full video + await fs.promises.copyFile(this.recording.fileName, croppedFilename) + } else { + // Add margins:, to account for things like drop shadows + boundingBox.x1 -= 10 + boundingBox.x2 += 10 + boundingBox.y1 -= 10 + boundingBox.y2 += 10 + + this.logger.verbose(`Saving cropped recording to ${croppedFilename}`) + // Generate a cropped video as well: + await this.ffmpeg([ + '-hide_banner', + '-y', + '-i', + escapeFilePath(this.recording.fileName), + '-filter:v', + `crop=${boundingBox.x2 - boundingBox.x1}:${boundingBox.y2 - boundingBox.y1}:${boundingBox.x1}:${ + boundingBox.y1 + }`, + escapeFilePath(croppedFilename), + ]) + } + this.logger.verbose(`Cropped Recording: ${croppedFilename}`) + return croppedFilename + } + + private async cleanupTemporaryFiles() { + try { + if (this.recording) { + await fs.promises.rm(this.recording.tmpFolder, { recursive: true }) + } + // Look for old tmp files + const oldTmpFiles = await fs.promises.readdir(this.tempFolder) + for (const oldTmpFile of oldTmpFiles) { + const oldTmpFileath = path.join(this.tempFolder, oldTmpFile) + const stat = await fs.promises.stat(oldTmpFileath) + if (stat.ctimeMs < Date.now() - 60000) { + await fs.promises.rm(oldTmpFileath, { recursive: true }) + } + } + } catch (e) { + // Just log and continue... + this.logger.error(e) + } + } + async ffmpeg( + args: string[], + options?: { + onStdout?: (data: string) => void + onStderr?: (data: string) => void + } + ): Promise { + const logger = this.logger.category('FFMpeg') + await new Promise((resolve, reject) => { + let logTrace = '' + const child = spawn(getFFMpegExecutable(), args, { + windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows + }) + + child.stdout.on('data', (data) => { + options?.onStdout?.(data.toString()) + logTrace += data.toString() + '\n' + logger.debug(data.toString()) + }) + child.stderr.on('data', (data) => { + options?.onStderr?.(data.toString()) + logTrace += data.toString() + '\n' + logger.debug(data.toString()) + }) + child.on('close', (code) => { + if (code !== 0) { + // eslint-disable-next-line no-console + logger.error(logTrace) + reject(new Error(`ffmpeg process exited with code ${code}, args: ${args.join(' ')}`)) + } else resolve() + }) + }) + } +} +function pad(str: string | number, length: number, char = '0') { + str = str.toString() + while (str.length < length) { + str = char + str + } + return str +} diff --git a/apps/html-renderer/packages/generic/src/renderHTML.ts b/apps/html-renderer/packages/generic/src/renderHTML.ts index 3d6b95eb..8b9a9808 100644 --- a/apps/html-renderer/packages/generic/src/renderHTML.ts +++ b/apps/html-renderer/packages/generic/src/renderHTML.ts @@ -1,9 +1,8 @@ -import { BrowserWindow, app, ipcMain } from 'electron' -import { spawn } from 'child_process' -import * as path from 'path' +import { app, ipcMain } from 'electron' import * as fs from 'fs' -import { LoggerInstance, escapeFilePath, getFFMpegExecutable, testFFMpeg } from '@sofie-package-manager/api' +import { LoggerInstance, testFFMpeg } from '@sofie-package-manager/api' import { sleep } from '@sofie-automation/shared-lib/dist/lib/lib' +import { BrowserRenderer } from './BrowserRenderer' export interface RenderHTMLOptions { logger: LoggerInstance @@ -22,7 +21,6 @@ export interface RenderHTMLOptions { outputFolder?: string /** Scripts to execute */ scripts: { - fcn?: (options: { webContents: Electron.WebContents }) => void | Promise executeJs?: string logInfo?: string takeScreenshot?: { @@ -52,393 +50,77 @@ export async function renderHTML(options: RenderHTMLOptions): Promise<{ await app.whenReady() - const width = options.width || 1920 - const height = options.height || 1080 - const zoom = options.zoom || 1 - const tempFolder = options.tempFolder || 'tmp' - const outputFolder = options.outputFolder || '' const logger = options.logger.category('RenderHTML') - - const win = new BrowserWindow({ - show: false, - alwaysOnTop: true, - webPreferences: { - // preload: join(__dirname, 'preload.js'), - nodeIntegration: false, - }, - transparent: true, - backgroundColor: '#00000000', // This is needed to be able to capture transparent screenshots - frame: false, - height, - width, - }) - win.once('ready-to-show', () => { - // Wait for ready-to-show event to fire, - // otherwise zetZoomFactor will not work: - win.webContents.setZoomFactor(zoom) - }) - win.webContents.setAudioMuted(true) - if (options.userAgent) win.webContents.setUserAgent(options.userAgent) - ipcMain.on('console', function (sender, type, args) { logger.debug(`Electron: ${sender}, ${type}, ${args}`) }) - // win.webContents.on('did-finish-load', (e: unknown) => log('did-finish-load', e)) - // win.webContents.on('did-fail-load', (e: unknown) => log('did-fail-load', e)) - // win.webContents.on('did-fail-provisional-load', (e: unknown) => log('did-fail-provisional-load', e)) - // win.webContents.on('did-frame-finish-load', (e: unknown) => log('did-frame-finish-load', e)) - // win.webContents.on('did-start-loading', (e: unknown) => log('did-start-loading', e)) - // win.webContents.on('did-stop-loading', (e: unknown) => log('did-stop-loading', e)) - // win.webContents.on('dom-ready', (e: unknown) => log('dom-ready', e)) - // win.webContents.on('page-favicon-updated', (e: unknown) => log('page-favicon-updated', e)) - // win.webContents.on('will-navigate', (e: unknown) => log('will-navigate', e)) - // win.webContents.on('plugin-crashed', (e: unknown) => log('plugin-crashed', e)) - // win.webContents.on('destroyed', (e: unknown) => log('destroyed', e)) - let didFinishLoad = false - let didFailLoad: null | any = null - win.webContents.on('did-finish-load', () => (didFinishLoad = true)) - win.webContents.on('did-fail-load', (e) => { - didFailLoad = e - }) + const renderer = new BrowserRenderer(logger, options) - logger.verbose(`Loading URL: ${options.url}`) - win.loadURL(options.url).catch((_e) => { - // ignore, instead rely on 'did-finish-load' and 'did-fail-load' later - }) - // logger.verbose(`Loading done`) - - win.title = `HTML Renderer ${process.pid}` - let backgroundColor = options.background ?? 'default' - if (backgroundColor.match(/^[0-f]+$/)) { - // "RRGGBB" format - backgroundColor = '#' + backgroundColor - } - if (backgroundColor !== 'default') { - await win.webContents.insertCSS(`html,body{ background: ${backgroundColor} !important;}`) - } + await renderer.init() let exitCode = 0 - let recording: { - fileName: string - tmpFolder: string - writeFilePromises: Promise[] - stopped: boolean - } | null = null - const api: InteractiveAPI = { - waitForLoad: async () => { - if (didFinishLoad) return - if (didFailLoad) throw new Error(`${didFailLoad}`) - else - await new Promise((resolve) => { - win.webContents.once('did-finish-load', () => resolve()) - }) - }, - takeScreenshot: async (fileName: string) => { - if (!fileName) throw new Error(`Invalid filename`) - - const image = await win.webContents.capturePage() - const filename = path.join(outputFolder, fileName) - - await fs.promises.mkdir(path.dirname(filename), { recursive: true }) - - if (fileName.endsWith('.png')) { - await fs.promises.writeFile(filename, image.toPNG()) - } else if (fileName.endsWith('.jpeg')) { - await fs.promises.writeFile(filename, image.toJPEG(90)) - } else { - throw new Error(`Unsupported file format: ${fileName}`) - } - return filename - }, - executeJs: async (js: string) => { - await win.webContents.executeJavaScript(js) - }, - startRecording: async (fileName: string, frameListener?: (frameIndex: number) => void) => { - if (recording?.stopped) { - await cleanupTemporaryFiles() - recording = null - } - if (recording) throw new Error(`Already recording`) - - const filename = path.join(outputFolder, fileName) - await fs.promises.mkdir(path.dirname(filename), { recursive: true }) - - let i = 0 - - const tmpFolder = path.resolve(path.join(tempFolder, `recording${process.pid}`)) - await fs.promises.mkdir(tmpFolder, { recursive: true }) - - recording = { - fileName: `${filename}`, - tmpFolder, - writeFilePromises: [], - stopped: false, - } - - win.webContents.beginFrameSubscription(false, (image) => { - if (!recording) throw new Error(`(internal error) received frame, but has no recording`) - i++ - frameListener?.(i) - - const buffer = image - .resize({ - width, - height, - }) - .toPNG() - - const tmpFile = path.join(tmpFolder, `img${pad(i, 5)}.png`) - recording.writeFilePromises.push(fs.promises.writeFile(tmpFile, buffer)) - }) - - return filename - }, - stopRecording: async () => { - if (!recording) throw new Error(`No current recording`) - if (recording.stopped) throw new Error(`Recording already stopped`) - recording.stopped = true - - win.webContents.endFrameSubscription() - - // Wait for all current file writes to finish: - await Promise.all(recording.writeFilePromises) - - let format: string - if (recording.fileName.endsWith('.webm')) { - format = 'webm' - } else if (recording.fileName.endsWith('.mp4')) { - format = 'mp4' - } else if (recording.fileName.endsWith('.mov')) { - format = 'mov' - } else { - throw new Error(`Unsupported file format: ${recording.fileName}`) - } - - // Convert the pngs to a video: - await ffmpeg(logger, [ - '-hide_banner', - '-y', - '-framerate', - '30', - '-s', - `${width}x${height}`, - '-i', - `${recording.tmpFolder}/img%05d.png`, - '-f', - format, // format: webm - '-an', // blocks all audio streams - '-c:v', - 'libvpx-vp9', // encoder for video (use VP9) - '-auto-alt-ref', - '1', - escapeFilePath(recording.fileName), - ]) - - await cleanupTemporaryFiles() - - return recording.fileName - }, - cropRecording: async (croppedFilename0: string) => { - if (!recording) throw new Error(`No recording`) - if (!recording.stopped) throw new Error(`Recording not stopped yet`) - - const croppedFilename = path.join(outputFolder, croppedFilename0) - await fs.promises.mkdir(path.dirname(croppedFilename), { recursive: true }) - - // Figure out the active bounding box - const boundingBox = { - x1: Infinity, - x2: -Infinity, - y1: Infinity, - y2: -Infinity, - } - await ffmpeg( - logger, - [ - '-hide_banner', - '-i', - escapeFilePath(recording.fileName), - '-vf', - 'bbox=min_val=50', - '-f', - 'null', - '-', - ], - { - onStderr: (data) => { - // [Parsed_bbox_0 @ 000002b6f5d474c0] n:25 pts:833 pts_time:0.833 x1:205 x2:236 y1:614 y2:650 w:32 h:37 crop=32:37:205:614 drawbox=205:614:32:37 - const m = data.match( - /Parsed_bbox.*x1:(?\d+).*x2:(?\d+).*y1:(?\d+).*y2:(?\d+)/ - ) - if (m && m.groups) { - boundingBox.x1 = Math.min(boundingBox.x1, parseInt(m.groups.x1, 10)) - boundingBox.x2 = Math.max(boundingBox.x2, parseInt(m.groups.x2, 10)) - boundingBox.y1 = Math.min(boundingBox.y1, parseInt(m.groups.y1, 10)) - boundingBox.y2 = Math.max(boundingBox.y2, parseInt(m.groups.y2, 10)) - } - }, - } - ) - - if ( - !Number.isFinite(boundingBox.x1) || - !Number.isFinite(boundingBox.x2) || - !Number.isFinite(boundingBox.y1) || - !Number.isFinite(boundingBox.y2) - ) { - logger.warn(`Could not determine bounding box`) - // Just copy the full video - await fs.promises.copyFile(recording.fileName, croppedFilename) - } else { - // Add margins:, to account for things like drop shadows - boundingBox.x1 -= 10 - boundingBox.x2 += 10 - boundingBox.y1 -= 10 - boundingBox.y2 += 10 - - logger.verbose(`Saving cropped recording to ${croppedFilename}`) - // Generate a cropped video as well: - await ffmpeg(logger, [ - '-hide_banner', - '-y', - '-i', - escapeFilePath(recording.fileName), - '-filter:v', - `crop=${boundingBox.x2 - boundingBox.x1}:${boundingBox.y2 - boundingBox.y1}:${boundingBox.x1}:${ - boundingBox.y1 - }`, - escapeFilePath(croppedFilename), - ]) - logger.verbose(`Saved cropped recording`) - } - return croppedFilename - }, - } - const cleanupTemporaryFiles = async () => { - try { - if (recording) { - await fs.promises.rm(recording.tmpFolder, { recursive: true }) - } - // Look for old tmp files - const oldTmpFiles = await fs.promises.readdir(tempFolder) - for (const oldTmpFile of oldTmpFiles) { - const oldTmpFileath = path.join(tempFolder, oldTmpFile) - const stat = await fs.promises.stat(oldTmpFileath) - if (stat.ctimeMs < Date.now() - 60000) { - await fs.promises.rm(oldTmpFileath, { recursive: true }) - } - } - } catch (e) { - // Just log and continue... - // eslint-disable-next-line no-console - console.error(e) - } - } if (options.interactive) { - await options.interactive(api) + await options.interactive(renderer) } else { - await api.waitForLoad() + await renderer.waitForLoad() const waitForScripts: Promise[] = [] const maxDelay = Math.max(...options.scripts.map((s) => s.wait)) - const afterLoop: (() => void)[] = [] for (const script of options.scripts) { await sleep(script.wait) if (script.takeScreenshot) { - const fileName = await api.takeScreenshot(script.takeScreenshot.name) - logger.info(`Screenshot: ${fileName}`) - } - if (script.fcn) { - logger.verbose(`Executing fcn`) - await Promise.resolve(script.fcn({ webContents: win.webContents })) + await renderer.takeScreenshot(script.takeScreenshot.name) } if (script.executeJs) { - logger.verbose(`Executing js: ${script.executeJs}`) - await api.executeJs(script.executeJs) + await renderer.executeJs(script.executeJs) } if (script.logInfo) { logger.info(script.logInfo) } if (script.startRecording) { - let videoFilename = 'N/A' - const startRecording = async () => { - await new Promise((resolve, reject) => { - if (!script.startRecording) return - - const startTime = Date.now() - const idleFrameTime = Math.max(500, maxDelay) - let maxFrameIndex = 0 - const endRecording = () => { - logger.verbose(`Ending recording, got ${maxFrameIndex} frames`) - win.webContents.endFrameSubscription() - resolve() + const { fileName: videoFilename, stopped } = await renderer.record( + script.startRecording.name, + Math.max(500, maxDelay) + ) + + waitForScripts.push( + stopped.then(async () => { + try { + if (script.startRecording?.cropped) { + const croppedVideoFilename = `${videoFilename}-cropped.webm` + await renderer.cropRecording(croppedVideoFilename) + logger.info(`Cropped Video: ${videoFilename}`) + } + + if (!script.startRecording?.full) { + await fs.promises.rm(videoFilename) + } else { + logger.info(`Full Video: ${videoFilename}`) + } + } catch (e) { + logger.error(`Aborting due to an error: ${e}`) + exitCode = 1 + } finally { + logger.verbose(`Removing temporary files...`) + if (!script.startRecording?.full) { + await fs.promises.rm(videoFilename) + } } - - afterLoop.push(() => endRecording()) - - let endRecordingTimeout = setTimeout(() => { - endRecording() - }, idleFrameTime) - - api.startRecording(script.startRecording.name, (i) => { - // On Frame - maxFrameIndex = i - logger.verbose(`Frame ${i}, ${Date.now() - startTime}`) - - // End recording when idle - clearTimeout(endRecordingTimeout) - endRecordingTimeout = setTimeout(() => { - endRecording() - }, idleFrameTime) - }) - .then((fileName) => { - logger.verbose(`Start recording: ${fileName}`) - videoFilename = fileName - }) - .catch(reject) }) - - logger.verbose(`Saving recording to ${videoFilename}`) - await api.stopRecording() - - try { - if (script.startRecording?.cropped) { - const croppedVideoFilename = `${videoFilename}-cropped.webm` - - await api.cropRecording(croppedVideoFilename) - - logger.info(`Cropped video: ${croppedVideoFilename}`) - } - - if (!script.startRecording?.full) { - await fs.promises.rm(videoFilename) - } else { - logger.info(`Video: ${videoFilename}`) - } - } catch (e) { - logger.error(`Aborting due to an error: ${e}`) - exitCode = 1 - } finally { - logger.verbose(`Removing temporary files...`) - if (!script.startRecording?.full) { - await fs.promises.rm(videoFilename) - } - } - } - waitForScripts.push(startRecording()) + ) } } // End of loop - afterLoop.forEach((fcn) => fcn()) + if (renderer.isRecording) await renderer.stopRecording() await Promise.all(waitForScripts) } - win.close() + renderer.close() return { app: app, @@ -453,47 +135,7 @@ export async function renderHTML(options: RenderHTMLOptions): Promise<{ } } } -function pad(str: string | number, length: number, char = '0') { - str = str.toString() - while (str.length < length) { - str = char + str - } - return str -} -async function ffmpeg( - logger0: LoggerInstance, - args: string[], - options?: { - onStdout?: (data: string) => void - onStderr?: (data: string) => void - } -): Promise { - const logger = logger0.category('FFMpeg') - await new Promise((resolve, reject) => { - let logTrace = '' - const child = spawn(getFFMpegExecutable(), args, { - windowsVerbatimArguments: true, // To fix an issue with ffmpeg.exe on Windows - }) - child.stdout.on('data', (data) => { - options?.onStdout?.(data.toString()) - logTrace += data.toString() + '\n' - logger.debug(data.toString()) - }) - child.stderr.on('data', (data) => { - options?.onStderr?.(data.toString()) - logTrace += data.toString() + '\n' - logger.debug(data.toString()) - }) - child.on('close', (code) => { - if (code !== 0) { - // eslint-disable-next-line no-console - console.error(logTrace) - reject(new Error(`ffmpeg process exited with code ${code}, args: ${args.join(' ')}`)) - } else resolve() - }) - }) -} export type InteractiveAPI = { waitForLoad: () => Promise takeScreenshot: (fileName: string) => Promise From e3e024264f05e433dee6c6d3878201d4950536cd Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 12:52:59 +0200 Subject: [PATCH 23/46] fix: html-renderer: bug in background color and cropping --- .../packages/generic/src/BrowserRenderer.ts | 14 +++++++- .../expectationHandlers/renderHTML.ts | 36 +++++++++---------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/apps/html-renderer/packages/generic/src/BrowserRenderer.ts b/apps/html-renderer/packages/generic/src/BrowserRenderer.ts index dc25a1bd..7cd0d501 100644 --- a/apps/html-renderer/packages/generic/src/BrowserRenderer.ts +++ b/apps/html-renderer/packages/generic/src/BrowserRenderer.ts @@ -261,9 +261,13 @@ export class BrowserRenderer implements InteractiveAPI { // Figure out the active bounding box const boundingBox = { + /** left */ x1: Infinity, + /** right */ x2: -Infinity, + /** top */ y1: Infinity, + /** bottom */ y2: -Infinity, } await this.ffmpeg( @@ -295,7 +299,9 @@ export class BrowserRenderer implements InteractiveAPI { !Number.isFinite(boundingBox.x1) || !Number.isFinite(boundingBox.x2) || !Number.isFinite(boundingBox.y1) || - !Number.isFinite(boundingBox.y2) + !Number.isFinite(boundingBox.y2) || + boundingBox.x1 > boundingBox.x2 || + boundingBox.y1 > boundingBox.y2 ) { this.logger.warn(`Could not determine bounding box`) // Just copy the full video @@ -307,6 +313,12 @@ export class BrowserRenderer implements InteractiveAPI { boundingBox.y1 -= 10 boundingBox.y2 += 10 + // Cap to the video size: + boundingBox.x1 = Math.min(this.width, Math.max(0, boundingBox.x1)) + boundingBox.x2 = Math.min(this.width, Math.max(0, boundingBox.x2)) + boundingBox.y1 = Math.min(this.height, Math.max(0, boundingBox.y1)) + boundingBox.y2 = Math.min(this.height, Math.max(0, boundingBox.y2)) + this.logger.verbose(`Saving cropped recording to ${croppedFilename}`) // Generate a cropped video as well: await this.ffmpeg([ diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts index 7d13334d..a00a0ff9 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -679,25 +679,23 @@ class HTMLRenderer { } private spawnHTMLRendererProcess() { - this.htmlRendererProcess = spawn( - getHtmlRendererExecutable(), - compact([ - `--`, - `--url=${this.url}`, - this.exp.endRequirement.version.renderer?.width !== undefined && - `--width=${this.exp.endRequirement.version.renderer?.width}`, - this.exp.endRequirement.version.renderer?.height !== undefined && - `--height=${this.exp.endRequirement.version.renderer?.height}`, - this.exp.endRequirement.version.renderer?.zoom !== undefined && - `--zoom=${this.exp.endRequirement.version.renderer?.zoom}`, - `--outputPath=${this.outputPath}`, - `--background=${this.exp.endRequirement.version.renderer?.background ?? 'default'}`, - `--interactive=true`, - ]), - { - windowsVerbatimArguments: true, // To fix an issue with arguments on Windows - } - ) + const args = compact([ + `--`, + `--url=${this.url}`, + this.exp.endRequirement.version.renderer?.width !== undefined && + `--width=${this.exp.endRequirement.version.renderer?.width}`, + this.exp.endRequirement.version.renderer?.height !== undefined && + `--height=${this.exp.endRequirement.version.renderer?.height}`, + this.exp.endRequirement.version.renderer?.zoom !== undefined && + `--zoom=${this.exp.endRequirement.version.renderer?.zoom}`, + `--outputPath=${this.outputPath}`, + `--background=${this.exp.endRequirement.version.renderer?.background ?? 'default'}`, + `--interactive=true`, + ]) + this.worker.logger.info('AAAA ' + JSON.stringify(args)) + this.htmlRendererProcess = spawn(getHtmlRendererExecutable(), args, { + windowsVerbatimArguments: true, // To fix an issue with arguments on Windows + }) const onClose = (code: number | null) => { if (this.htmlRendererProcess) { From 8057950f6feabce305f6d03e3d4a2462e58ebb19 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Tue, 20 Aug 2024 12:53:36 +0200 Subject: [PATCH 24/46] chore: expectedPackage.json --- apps/single-app/app/expectedPackages.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/single-app/app/expectedPackages.json b/apps/single-app/app/expectedPackages.json index 1f5fc021..143d334f 100644 --- a/apps/single-app/app/expectedPackages.json +++ b/apps/single-app/app/expectedPackages.json @@ -161,7 +161,8 @@ "renderer": { "width": 480, "height": 320, - "zoom": 0.25 + "zoom": 0.25, + "background": "green" }, "steps": [ { @@ -181,10 +182,14 @@ }, { "do": "sleep", - "duration": 4000 + "duration": 2000 }, { "do": "stopRecording" + }, + { + "do": "cropRecording", + "fileName": "bouncingdvdlogo_recording-cropped.webm" } ] }, From c75757e9c65ec0babf080652c20d0af50895a767 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 21 Aug 2024 09:08:07 +0200 Subject: [PATCH 25/46] fix: rename supperHEAD to useGETinsteadOfHead --- shared/packages/api/src/inputApi.ts | 4 ++-- .../src/worker/accessorHandlers/http.ts | 24 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index 3ed56973..bc47af07 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -334,8 +334,8 @@ export namespace Accessor { /** If true, assumes that a source never changes once it has been fetched. */ isImmutable?: boolean - /** If true, assumes that the source supports HEAD requests. Otherwise, GET requests will be sent to check availability. */ - supportHEAD?: boolean + /** If true, assumes that the source doesn't support HEAD requests and will use GET instead. If false, HEAD requests will be sent to check availability. */ + useGETinsteadOfHEAD?: boolean } /** Definition of access to the HTTP-proxy server that comes with Package Manager. */ export interface HTTPProxy extends Base { diff --git a/shared/packages/worker/src/worker/accessorHandlers/http.ts b/shared/packages/worker/src/worker/accessorHandlers/http.ts index 763fb04d..212fadbc 100644 --- a/shared/packages/worker/src/worker/accessorHandlers/http.ts +++ b/shared/packages/worker/src/worker/accessorHandlers/http.ts @@ -254,17 +254,18 @@ export class HTTPAccessorHandle extends GenericAccessorHandle { const r = fetchWithController(this.fullUrl, { - method: 'HEAD', + method: 'GET', }) const response = await r.response - response.body.on('error', (e) => { - this.worker.logger.warn(`fetchHeader: Error ${e}`) + response.body.on('error', () => { + // Swallow the error. Since we're aborting the request, we're not interested in the body anyway. }) const headers: HTTPHeaders = { @@ -273,6 +274,8 @@ export class HTTPAccessorHandle extends GenericAccessorHandle extends GenericAccessorHandle { const r = fetchWithController(this.fullUrl, { - method: 'GET', + method: 'HEAD', }) const response = await r.response - response.body.on('error', () => { - // Swallow the error. Since we're aborting the request, we're not interested in the body anyway. + response.body.on('error', (e) => { + this.worker.logger.warn(`fetchHeader: Error ${e}`) }) const headers: HTTPHeaders = { @@ -301,8 +303,6 @@ export class HTTPAccessorHandle extends GenericAccessorHandle Date: Wed, 21 Aug 2024 09:09:53 +0200 Subject: [PATCH 26/46] fix: allow stringified JSON object as storeObject value. Also add some error handling --- shared/packages/api/src/expectationApi.ts | 7 +- .../expectationHandlers/renderHTML.ts | 76 +++++++++++++------ 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/shared/packages/api/src/expectationApi.ts b/shared/packages/api/src/expectationApi.ts index ce2c86e9..325a160f 100644 --- a/shared/packages/api/src/expectationApi.ts +++ b/shared/packages/api/src/expectationApi.ts @@ -393,7 +393,12 @@ export namespace Expectation { | { do: 'cropRecording'; fileName: string } | { do: 'executeJs'; js: string } // Store an object in memory - | { do: 'storeObject'; key: string; value: Record } + | { + do: 'storeObject' + key: string + /** The value to store into memory. Either an object, or a JSON-stringified object */ + value: Record | string + } // Modify an object in memory. Path is a dot-separated string | { do: 'modifyObject'; key: string; path: string; value: any } // Send an object to the renderer as a postMessage (so basically does a executeJs: window.postMessage(memory[key])) diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts index a00a0ff9..b9192a14 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -801,9 +801,7 @@ class HTMLRenderer { this.doNextCommand() } else { - this.onError( - new Error(`Unexpected reply from HTMLRenderer: ${message.reply} (not waiting for command)`) - ) + this.onError(new Error(`Unexpected reply from HTMLRenderer: ${message.reply}`)) } } else { assertNever(message) @@ -871,6 +869,9 @@ class HTMLRenderer { headers: step.headers, body: step.body, }) + .then(() => { + this.doNextCommand() + }) .catch((err) => { this.onError( new Error( @@ -880,36 +881,65 @@ class HTMLRenderer { ) ) }) - .finally(() => { - this.doNextCommand() - }) } else if (currentStep.step.do === 'storeObject') { this.worker.logger.debug(`HTMLRenderer: Store object "${currentStep.step.key}"`) - this.storedDataObjects[currentStep.step.key] = currentStep.step.value - this.doNextCommand() + try { + const value = + typeof currentStep.step.value === 'string' + ? JSON.parse(currentStep.step.value) + : currentStep.step.value + this.storedDataObjects[currentStep.step.key] = value + this.doNextCommand() + } catch (e) { + this.onError( + new Error( + `HTMLRenderer: Error when parsing value in storeObject for key "${ + currentStep.step.key + }": ${stringifyError(e)}` + ) + ) + } } else if (currentStep.step.do === 'modifyObject') { this.worker.logger.debug(`HTMLRenderer: Modify object "${currentStep.step.key}"`) - const obj = this.storedDataObjects[currentStep.step.key] - if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) - - modifyObject(obj, currentStep.step.path, currentStep.step.value) - this.doNextCommand() + try { + const obj = this.storedDataObjects[currentStep.step.key] + if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) + modifyObject(obj, currentStep.step.path, currentStep.step.value) + this.doNextCommand() + } catch (e) { + this.onError( + new Error( + `HTMLRenderer: Error when modifying object for key "${ + currentStep.step.key + }": ${stringifyError(e)}` + ) + ) + } } else if (currentStep.step.do === 'injectObject') { this.worker.logger.debug(`HTMLRenderer: Inject object "${currentStep.step.key}"`) + try { + const obj = this.storedDataObjects[currentStep.step.key] + if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) - const obj = this.storedDataObjects[currentStep.step.key] - if (!obj) throw new Error(`Object "${currentStep.step.key}" not found`) - - const receivingFunction = currentStep.step.receivingFunction ?? 'window.postMessage' + const receivingFunction = currentStep.step.receivingFunction ?? 'window.postMessage' - // Execute javascript in the renderer, to simulate a postMessage event: - const cmd: InteractiveMessage = { - do: 'executeJs', - js: `${receivingFunction}(${JSON.stringify(obj)})`, + // Execute javascript in the renderer, to simulate a postMessage event: + const cmd: InteractiveMessage = { + do: 'executeJs', + js: `${receivingFunction}(${JSON.stringify(obj)})`, + } + // Send command to the renderer: + this.setCommandToRenderer(cmd) + } catch (e) { + this.onError( + new Error( + `HTMLRenderer: Error when injecting object for key "${ + currentStep.step.key + }": ${stringifyError(e)}` + ) + ) } - // Send command to the renderer: - this.setCommandToRenderer(cmd) } else { this.worker.logger.debug(`HTMLRenderer: Send command: ${JSON.stringify(currentStep.step)}`) From 7fcdf700fda413b94f39e7fb6107e26ee7dd9713 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 21 Aug 2024 09:44:57 +0200 Subject: [PATCH 27/46] fix: change how render width, height and scale works for html_template expectedPackages --- shared/packages/api/src/expectationApi.ts | 7 ++++++- .../expectationHandlers/renderHTML.ts | 18 +++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/shared/packages/api/src/expectationApi.ts b/shared/packages/api/src/expectationApi.ts index 325a160f..db3ea700 100644 --- a/shared/packages/api/src/expectationApi.ts +++ b/shared/packages/api/src/expectationApi.ts @@ -354,7 +354,12 @@ export namespace Expectation { renderer?: { width?: number height?: number - zoom?: number + /** + * Scale the rendered width and height with this value, and also zoom the content accordingly. + * For example, if the width is 1920 and scale is 0.5, the width will be scaled to 960. + * (Defaults to 1) + */ + scale?: number /** Background color, #RRGGBB, CSS-string, "transparent" or "default" (defaults to "default") */ background?: string userAgent?: string diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts index b9192a14..a7bf51b7 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -679,20 +679,24 @@ class HTMLRenderer { } private spawnHTMLRendererProcess() { + let width = this.exp.endRequirement.version.renderer?.width + let height = this.exp.endRequirement.version.renderer?.height + const scale = this.exp.endRequirement.version.renderer?.scale + if (width !== undefined && height !== undefined && scale !== undefined) { + width = Math.floor(width * scale) + height = Math.floor(height * scale) + } + const args = compact([ `--`, `--url=${this.url}`, - this.exp.endRequirement.version.renderer?.width !== undefined && - `--width=${this.exp.endRequirement.version.renderer?.width}`, - this.exp.endRequirement.version.renderer?.height !== undefined && - `--height=${this.exp.endRequirement.version.renderer?.height}`, - this.exp.endRequirement.version.renderer?.zoom !== undefined && - `--zoom=${this.exp.endRequirement.version.renderer?.zoom}`, + width !== undefined && `--width=${width}`, + height !== undefined && `--height=${height}`, + scale !== undefined && `--zoom=${scale}`, `--outputPath=${this.outputPath}`, `--background=${this.exp.endRequirement.version.renderer?.background ?? 'default'}`, `--interactive=true`, ]) - this.worker.logger.info('AAAA ' + JSON.stringify(args)) this.htmlRendererProcess = spawn(getHtmlRendererExecutable(), args, { windowsVerbatimArguments: true, // To fix an issue with arguments on Windows }) From b40bf2d56c52b370638c8bd2af11313d60c81c9b Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Wed, 21 Aug 2024 10:17:59 +0200 Subject: [PATCH 28/46] chore: update inputAPI definitions --- .../src/generateExpectations/nrk/expectations-lib.ts | 8 ++++---- shared/packages/api/src/inputApi.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts index b0e1584b..92cbe22b 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts @@ -642,13 +642,13 @@ export function generateHTMLRender( if ( endRequirement.version.renderer?.width === undefined && endRequirement.version.renderer?.height === undefined && - endRequirement.version.renderer?.zoom === undefined + endRequirement.version.renderer?.scale === undefined ) { // Default: Render as thumbnails: if (!endRequirement.version.renderer) endRequirement.version.renderer = {} - endRequirement.version.renderer.width = 1920 / 4 - endRequirement.version.renderer.height = 1080 / 4 - endRequirement.version.renderer.zoom = 1 / 4 + endRequirement.version.renderer.width = 1920 + endRequirement.version.renderer.height = 1080 + endRequirement.version.renderer.scale = 1 / 4 } const exp: Expectation.RenderHTML = { diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index bc47af07..259bc1b5 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -210,8 +210,12 @@ export namespace ExpectedPackage { width?: number /** Renderer width, defaults to 1080 */ height?: number - /** Zoom level, defaults to 1 */ - zoom?: number + /** + * Scale the rendered width and height with this value, and also zoom the content accordingly. + * For example, if the width is 1920 and scale is 0.5, the width will be scaled to 960. + * (Defaults to 1) + */ + scale?: number /** Background color, #RRGGBB, CSS-string, "transparent" or "default" (defaults to "default") */ background?: string userAgent?: string From 43e9887445c117dc6935960a0d881fd19c3d34dd Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 23 Aug 2024 07:45:07 +0200 Subject: [PATCH 29/46] fix: remove outputPrefix for html-template expectations --- .../nrk/expectations-lib.ts | 12 +++---- apps/single-app/app/expectedPackages.json | 9 +++-- shared/packages/api/src/expectationApi.ts | 7 ++-- shared/packages/api/src/inputApi.ts | 33 ++++++++++++++++--- .../expectationHandlers/renderHTML.ts | 9 +---- 5 files changed, 44 insertions(+), 26 deletions(-) diff --git a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts index 92cbe22b..5de403bb 100644 --- a/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts +++ b/apps/package-manager/packages/generic/src/generateExpectations/nrk/expectations-lib.ts @@ -632,11 +632,9 @@ export function generateHTMLRender( const endRequirement: Expectation.RenderHTML['endRequirement'] = { targets: expWrapHTMLTemplate.targets as Expectation.SpecificPackageContainerOnPackage.FileTarget[], - content: { - prefix: expectedPackage.content.outputPrefix, - }, + content: {}, version: { - ...expWrapHTMLTemplate.expectedPackage.version, + ...expectedPackage.version, }, } if ( @@ -664,14 +662,16 @@ export function generateHTMLRender( type: Expectation.Type.RENDER_HTML, statusReport: { label: `Rendering HTML template`, - description: `Rendering HTML template "${expWrapHTMLTemplate.expectedPackage.content.path}"`, + description: `Rendering HTML template "${expectedPackage.content.path}"`, displayRank: 11, sendReport: !expWrap.external, }, startRequirement: { sources: expWrapHTMLTemplate.sources, - content: expWrapHTMLTemplate.expectedPackage.content, + content: { + path: expectedPackage.content.path, + }, version: { type: Expectation.Version.Type.FILE_ON_DISK, }, diff --git a/apps/single-app/app/expectedPackages.json b/apps/single-app/app/expectedPackages.json index 143d334f..65f55827 100644 --- a/apps/single-app/app/expectedPackages.json +++ b/apps/single-app/app/expectedPackages.json @@ -155,14 +155,13 @@ "contentVersionHash": "abc1234", "content": { "path": "https://www.bouncingdvdlogo.com/", - "outputPrefix": "" }, "version": { "renderer": { - "width": 480, - "height": 320, - "zoom": 0.25, - "background": "green" + "width": 1920, + "height": 1080, + "scale": 0.25, + "background": "red" }, "steps": [ { diff --git a/shared/packages/api/src/expectationApi.ts b/shared/packages/api/src/expectationApi.ts index db3ea700..b7136183 100644 --- a/shared/packages/api/src/expectationApi.ts +++ b/shared/packages/api/src/expectationApi.ts @@ -347,12 +347,13 @@ export namespace Expectation { endRequirement: { targets: SpecificPackageContainerOnPackage.FileTarget[] content: { - /** Prefix of output files */ - prefix?: string + // empty } version: { renderer?: { + /** Renderer width, defaults to 1920 */ width?: number + /** Renderer height, defaults to 1080 */ height?: number /** * Scale the rendered width and height with this value, and also zoom the content accordingly. @@ -411,7 +412,7 @@ export namespace Expectation { do: 'injectObject' key: string /** The method to receive the value. Defaults to window.postMessage */ - receivingFunction: string + receivingFunction?: string } )[] } diff --git a/shared/packages/api/src/inputApi.ts b/shared/packages/api/src/inputApi.ts index 259bc1b5..6f57bc9e 100644 --- a/shared/packages/api/src/inputApi.ts +++ b/shared/packages/api/src/inputApi.ts @@ -80,7 +80,9 @@ export namespace ExpectedPackage { /** Reference to a PackageContainer */ containerId: PackageContainerId /** Locally defined Accessors, these are combined (deep extended) with the PackageContainer (if it is found) Accessors */ - accessors: Record + accessors: { + [accessorId: AccessorId]: AccessorOnPackage.Any + } }[] /** The sideEffect is used by the Package Manager to generate extra artifacts, such as thumbnails & previews */ @@ -201,14 +203,12 @@ export namespace ExpectedPackage { content: { /** path to the HTML template */ path: string - /** Add prefix to output artifacts */ - outputPrefix: string } version: { renderer?: { /** Renderer width, defaults to 1920 */ width?: number - /** Renderer width, defaults to 1080 */ + /** Renderer height, defaults to 1080 */ height?: number /** * Scale the rendered width and height with this value, and also zoom the content accordingly. @@ -239,11 +239,36 @@ export namespace ExpectedPackage { steps?: ( | { do: 'waitForLoad' } | { do: 'sleep'; duration: number } + | { + do: 'sendHTTPCommand' + url: string + /** GET, POST, PUT etc.. */ + method: string + body?: ArrayBuffer | ArrayBufferView | NodeJS.ReadableStream | string | URLSearchParams + + headers?: Record + } | { do: 'takeScreenshot'; fileName: string } | { do: 'startRecording'; fileName: string } | { do: 'stopRecording' } | { do: 'cropRecording'; fileName: string } | { do: 'executeJs'; js: string } + // Store an object in memory + | { + do: 'storeObject' + key: string + /** The value to store into memory. Either an object, or a JSON-stringified object */ + value: Record | string + } + // Modify an object in memory. Path is a dot-separated string + | { do: 'modifyObject'; key: string; path: string; value: any } + // Send an object to the renderer as a postMessage (so basically does a executeJs: window.postMessage(memory[key])) + | { + do: 'injectObject' + key: string + /** The method to receive the value. Defaults to window.postMessage */ + receivingFunction?: string + } )[] } sources: { diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts index a7bf51b7..ae336240 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -352,14 +352,7 @@ function getSteps(exp: Expectation.RenderHTML): Steps { } else { steps = exp.endRequirement.version.steps || [] } - // Add prefix to steps fileName: - return steps.map((org) => { - const step = { ...org } - if ('fileName' in step) { - step.fileName = `${exp.endRequirement.content.prefix ?? ''}${step.fileName}` - } - return step - }) + return steps } function getFileNames(steps: Steps) { const fileNames: string[] = [] From 9a2d6fd406b9a2a321827f853583fc04d7207085 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 23 Aug 2024 07:45:43 +0200 Subject: [PATCH 30/46] chore: move some helper functions so that they can be shared with Sofie Core --- shared/packages/api/src/index.ts | 1 + shared/packages/api/src/inputHelpers.ts | 59 +++++++++++++++++++ .../expectationHandlers/renderHTML.ts | 43 ++------------ 3 files changed, 65 insertions(+), 38 deletions(-) create mode 100644 shared/packages/api/src/inputHelpers.ts diff --git a/shared/packages/api/src/index.ts b/shared/packages/api/src/index.ts index 13c83872..8d981571 100644 --- a/shared/packages/api/src/index.ts +++ b/shared/packages/api/src/index.ts @@ -7,6 +7,7 @@ export * from './filePath' export * from './htmlRenderer' export * from './expectationApi' export * from './inputApi' +export * from './inputHelpers' export * from './HelpfulEventEmitter' export * from './ids' export * from './dataStorage' diff --git a/shared/packages/api/src/inputHelpers.ts b/shared/packages/api/src/inputHelpers.ts new file mode 100644 index 00000000..635caef0 --- /dev/null +++ b/shared/packages/api/src/inputHelpers.ts @@ -0,0 +1,59 @@ +import { ExpectedPackage } from './inputApi' + +type Steps = Required['steps'] + +export function htmlTemplateGetSteps(version: ExpectedPackage.ExpectedPackageHtmlTemplate['version']): Steps { + let steps: Steps + if (version.casparCG) { + // Generate a set of steps for standard CasparCG templates + const casparData = version.casparCG.data + const casparDataJSON = typeof casparData === 'string' ? casparData : JSON.stringify(casparData) + steps = [ + { do: 'waitForLoad' }, + { do: 'takeScreenshot', fileName: 'idle.png' }, + { do: 'startRecording', fileName: 'preview.webm' }, + { do: 'executeJs', js: `update(${casparDataJSON})` }, + { do: 'executeJs', js: `play()` }, + { do: 'sleep', duration: 1000 }, + { do: 'takeScreenshot', fileName: 'play.png' }, + { do: 'executeJs', js: `stop()` }, + { do: 'sleep', duration: 1000 }, + { do: 'takeScreenshot', fileName: 'stop.png' }, + { do: 'stopRecording' }, + { do: 'cropRecording', fileName: 'preview-cropped.webm' }, + ] + } else { + steps = version.steps || [] + } + return steps +} +export function htmlTemplateGetFileNamesFromSteps(steps: Steps): { + /** List of all file names that will be output from in the steps */ + fileNames: string[] + /** The "main file", ie the file that will carry the main metadata */ + mainFileName: string | undefined + /** File name of the main (first) screenshot */ + mainScreenShot: string | undefined + /** File name of the main (first) recording */ + mainRecording: string | undefined +} { + const fileNames: string[] = [] + let mainFileName: string | undefined = undefined + let mainScreenShot: string | undefined = undefined + let mainRecording: string | undefined = undefined + + for (const step of steps) { + if (step.do === 'takeScreenshot') { + fileNames.push(step.fileName) + if (!mainFileName) mainFileName = step.fileName + if (!mainScreenShot) mainScreenShot = step.fileName + } else if (step.do === 'startRecording') { + fileNames.push(step.fileName) + mainFileName = step.fileName + if (!mainRecording) mainRecording = step.fileName + } else if (step.do === 'cropRecording') { + fileNames.push(step.fileName) + } + } + return { fileNames, mainFileName, mainScreenShot, mainRecording } +} diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts index ae336240..253458ac 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -24,6 +24,8 @@ import { InteractiveReply, InteractiveMessage, literal, + htmlTemplateGetSteps, + htmlTemplateGetFileNamesFromSteps, } from '@sofie-package-manager/api' import { IWorkInProgress, WorkInProgress } from '../../../lib/workInProgress' @@ -329,46 +331,11 @@ async function lookupTargets( ) } type Steps = Required['steps'] -function getSteps(exp: Expectation.RenderHTML): Steps { - let steps: Steps - if (exp.endRequirement.version.casparCG) { - // Generate a set of steps for standard CasparCG templates - const casparData = exp.endRequirement.version.casparCG.data - const casparDataJSON = typeof casparData === 'string' ? casparData : JSON.stringify(casparData) - steps = [ - { do: 'waitForLoad' }, - { do: 'takeScreenshot', fileName: 'idle.png' }, - { do: 'startRecording', fileName: 'preview.webm' }, - { do: 'executeJs', js: `update(${casparDataJSON})` }, - { do: 'executeJs', js: `play()` }, - { do: 'sleep', duration: 1000 }, - { do: 'takeScreenshot', fileName: 'play.png' }, - { do: 'executeJs', js: `stop()` }, - { do: 'sleep', duration: 1000 }, - { do: 'takeScreenshot', fileName: 'stop.png' }, - { do: 'stopRecording' }, - { do: 'cropRecording', fileName: 'preview-cropped.webm' }, - ] - } else { - steps = exp.endRequirement.version.steps || [] - } - return steps +function getSteps(exp: Expectation.RenderHTML) { + return htmlTemplateGetSteps(exp.endRequirement.version) } function getFileNames(steps: Steps) { - const fileNames: string[] = [] - let mainFileName: string | undefined = undefined - for (const step of steps) { - if (step.do === 'takeScreenshot') { - fileNames.push(step.fileName) - if (!mainFileName) mainFileName = step.fileName - } else if (step.do === 'startRecording') { - fileNames.push(step.fileName) - mainFileName = step.fileName - } else if (step.do === 'cropRecording') { - fileNames.push(step.fileName) - } - } - return { fileNames, mainFileName } + return htmlTemplateGetFileNamesFromSteps(steps) } function compact(array: (T | undefined | null | false)[]): T[] { return array.filter(Boolean) as T[] From d342bc703c9c38227d4cb0c4e4689e398623c67a Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 23 Aug 2024 08:03:25 +0200 Subject: [PATCH 31/46] chore: lint --- apps/html-renderer/app/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/html-renderer/app/src/index.ts b/apps/html-renderer/app/src/index.ts index 0c8247e6..c3187799 100644 --- a/apps/html-renderer/app/src/index.ts +++ b/apps/html-renderer/app/src/index.ts @@ -63,7 +63,7 @@ async function main(): Promise { interactiveLogStdOut({ status: 'listening', port }) wss.on('connection', (ws) => { - console.log('client connected') + logger.info('client connected') const interactiveLog = (message: InteractiveReply) => { ws.send(JSON.stringify(message)) @@ -238,6 +238,7 @@ async function main(): Promise { } main().catch((e) => { + // eslint-disable-next-line no-console console.error(e) // eslint-disable-next-line no-process-exit process.exit(1) From 77a7a285df9db852ad031553987acb046b015f29 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Fri, 23 Aug 2024 08:55:34 +0200 Subject: [PATCH 32/46] chore: update code dep --- apps/html-renderer/app/package.json | 2 +- .../packages/generic/src/coreHandler.ts | 83 +++++++++------- .../packages/generic/src/packageManager.ts | 33 ++++--- package.json | 4 +- yarn.lock | 96 ++++++++----------- 5 files changed, 112 insertions(+), 106 deletions(-) diff --git a/apps/html-renderer/app/package.json b/apps/html-renderer/app/package.json index bf50b1df..7f99835f 100644 --- a/apps/html-renderer/app/package.json +++ b/apps/html-renderer/app/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@html-renderer/generic": "1.50.5", - "@sofie-automation/shared-lib": "1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0", + "@sofie-automation/shared-lib": "1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0", "@sofie-package-manager/api": "1.50.6", "portfinder": "^1.0.32", "tslib": "^2.1.0", diff --git a/apps/package-manager/packages/generic/src/coreHandler.ts b/apps/package-manager/packages/generic/src/coreHandler.ts index c1f89d84..375990d0 100644 --- a/apps/package-manager/packages/generic/src/coreHandler.ts +++ b/apps/package-manager/packages/generic/src/coreHandler.ts @@ -8,12 +8,14 @@ import { CoreCredentials, StatusCode as SofieStatusCode, protectString, - unprotectString, - PeripheralDeviceForDevice, PeripheralDeviceId, PeripheralDeviceCommand, ExternalPeripheralDeviceAPI, PeripheralDeviceAPI, + PeripheralDevicePubSub, + PeripheralDevicePubSubCollectionsNames, + PeripheralDevicePubSubCollections, + CollectionDocCheck, } from '@sofie-automation/server-core-integration' import { DeviceConfig } from './connector' @@ -34,7 +36,6 @@ import { ExpectationId, PackageContainerId, AppId, - CoreProtectedString, } from '@sofie-package-manager/api' import { DEFAULT_DELAY_REMOVAL_PACKAGE, @@ -44,6 +45,7 @@ import { import { PackageManagerHandler } from './packageManager' import { getCredentials } from './credentials' import { FakeCore } from './fakeCore' +import { PeripheralDeviceCommandId } from '@sofie-automation/shared-lib/dist/core/model/Ids' let packageJson: any try { @@ -77,7 +79,7 @@ export class CoreHandler { private _deviceOptions: DeviceConfig private _onConnected?: () => any - private _executedFunctions: { [id: string]: boolean } = {} + private _executedFunctions = new Set() private _packageManagerHandler?: PackageManagerHandler private _coreConfig?: CoreConfig private processHandler?: ProcessHandler @@ -156,22 +158,33 @@ export class CoreHandler { this.logger.info('Core: Setting up subscriptions..') this.logger.info('DeviceId: ' + this.core.deviceId) await Promise.all([ - this.core.autoSubscribe('peripheralDeviceForDevice', this.core.deviceId), - this.core.autoSubscribe('peripheralDeviceCommands', this.core.deviceId), - this.core.autoSubscribe('packageManagerPlayoutContext', this.core.deviceId), - this.core.autoSubscribe('packageManagerPackageContainers', this.core.deviceId), - this.core.autoSubscribe('packageManagerExpectedPackages', this.core.deviceId, undefined), + this.core.autoSubscribe(PeripheralDevicePubSub.peripheralDeviceForDevice, this.core.deviceId), + this.core.autoSubscribe(PeripheralDevicePubSub.peripheralDeviceCommands, this.core.deviceId), + this.core.autoSubscribe(PeripheralDevicePubSub.packageManagerPlayoutContext, this.core.deviceId, undefined), + this.core.autoSubscribe( + PeripheralDevicePubSub.packageManagerPackageContainers, + this.core.deviceId, + undefined + ), + this.core.autoSubscribe( + PeripheralDevicePubSub.packageManagerExpectedPackages, + this.core.deviceId, + undefined, + undefined + ), ]) this.logger.info('Core: Subscriptions are set up!') // setup observers - const observer = this.core.observe('peripheralDeviceForDevice') - observer.added = (id: string) => this.onDeviceChanged(protectString(id)) - observer.changed = (id: string) => this.onDeviceChanged(protectString(id)) + const observer = this.core.observe(PeripheralDevicePubSubCollectionsNames.peripheralDeviceForDevice) + observer.added = (id: PeripheralDeviceId) => this.onDeviceChanged(id) + observer.changed = (id: PeripheralDeviceId) => this.onDeviceChanged(id) this.setupObserverForPeripheralDeviceCommands() - const peripheralDevices = this.core.getCollection('peripheralDeviceForDevice') + const peripheralDevices = this.core.getCollection( + PeripheralDevicePubSubCollectionsNames.peripheralDeviceForDevice + ) if (peripheralDevices) { peripheralDevices.find({}).forEach((device) => { this.onDeviceChanged(device._id) @@ -231,7 +244,7 @@ export class CoreHandler { } onDeviceChanged(id: PeripheralDeviceId): void { if (id === this.core.deviceId) { - const col = this.core.getCollection('peripheralDeviceForDevice') + const col = this.core.getCollection(PeripheralDevicePubSubCollectionsNames.peripheralDeviceForDevice) if (!col) throw new Error('collection "peripheralDeviceForDevice" not found!') const device = col.findOne(id) @@ -285,14 +298,14 @@ export class CoreHandler { executeFunction(cmd: PeripheralDeviceCommand): void { if (cmd) { - if (this._executedFunctions[unprotectString(cmd._id)]) return // prevent it from running multiple times + if (this._executedFunctions.has(cmd._id)) return // prevent it from running multiple times // Ignore specific commands, to reduce noise: if (cmd.functionName !== 'getExpetationManagerStatus') { this.logger.debug(`Executing function "${cmd.functionName}", args: ${JSON.stringify(cmd.args)}`) } - this._executedFunctions[unprotectString(cmd._id)] = true + this._executedFunctions.add(cmd._id) const cb = (err: any, res?: any) => { if (err) { this.logger.error(`executeFunction error: ${stringifyError(err)}`) @@ -329,19 +342,25 @@ export class CoreHandler { } } } - retireExecuteFunction(cmdId: string): void { - delete this._executedFunctions[cmdId] + retireExecuteFunction(cmdId: PeripheralDeviceCommandId): void { + this._executedFunctions.delete(cmdId) } - observe(collectionName: string): Observer { + observe(collectionName: keyof PeripheralDevicePubSubCollections): Observer { if (!this.core && this.notUsingCore) throw new Error('core.observe called, even though notUsingCore is true.') if (!this.core) throw new Error('Core not initialized!') return this.core.observe(collectionName) } - getCollection< - DBObj extends { - _id: CoreProtectedString | string - } = never - >(collectionName: string): Collection { + + // getCollection(collectionName: K): Collection>; + + // getCollection< + // DBObj extends { + // _id: CoreProtectedString | string + // } = never + // >(collectionName: keyof PeripheralDevicePubSubCollections): Collection { + getCollection( + collectionName: K + ): Collection> { if (!this.core && this.notUsingCore) throw new Error('core.observe called, even though notUsingCore is true.') if (!this.core) throw new Error('Core not initialized!') return this.core.getCollection(collectionName) @@ -356,30 +375,30 @@ export class CoreHandler { return this.core?.connected || false } private setupObserverForPeripheralDeviceCommands(): void { - const observer = this.core.observe('peripheralDeviceCommands') + const observer = this.core.observe(PeripheralDevicePubSubCollectionsNames.peripheralDeviceCommands) this._observers.push(observer) - const addedChangedCommand = (id: string) => { - const cmds = this.core.getCollection('peripheralDeviceCommands') + const addedChangedCommand = (id: PeripheralDeviceCommandId) => { + const cmds = this.core.getCollection(PeripheralDevicePubSubCollectionsNames.peripheralDeviceCommands) if (!cmds) throw Error('"peripheralDeviceCommands" collection not found!') - const cmd = cmds.findOne(protectString(id)) + const cmd = cmds.findOne(id) if (!cmd) throw Error('PeripheralCommand "' + id + '" not found!') if (cmd.deviceId === this.core.deviceId) { this.executeFunction(cmd) } } - observer.added = (id: string) => { + observer.added = (id: PeripheralDeviceCommandId) => { addedChangedCommand(id) } - observer.changed = (id: string) => { + observer.changed = (id: PeripheralDeviceCommandId) => { addedChangedCommand(id) } - observer.removed = (id: string) => { + observer.removed = (id: PeripheralDeviceCommandId) => { this.retireExecuteFunction(id) } - const cmds = this.core.getCollection('peripheralDeviceCommands') + const cmds = this.core.getCollection(PeripheralDevicePubSubCollectionsNames.peripheralDeviceCommands) if (!cmds) throw Error('"peripheralDeviceCommands" collection not found!') cmds.find({}).forEach((cmd) => { diff --git a/apps/package-manager/packages/generic/src/packageManager.ts b/apps/package-manager/packages/generic/src/packageManager.ts index 094e7116..eb3fa75a 100644 --- a/apps/package-manager/packages/generic/src/packageManager.ts +++ b/apps/package-manager/packages/generic/src/packageManager.ts @@ -12,13 +12,14 @@ import { import { PackageManagerActivePlaylist, PackageManagerActiveRundown, - PackageManagerExpectedPackage, PackageManagerExpectedPackageBase, - PackageManagerPackageContainers, - PackageManagerPlayoutContext, // eslint-disable-next-line node/no-extraneous-import } from '@sofie-automation/shared-lib/dist/package-manager/publications' -import { Observer, PeripheralDeviceId } from '@sofie-automation/server-core-integration' +import { + Observer, + PeripheralDeviceId, + PeripheralDevicePubSubCollectionsNames, +} from '@sofie-automation/server-core-integration' // eslint-disable-next-line node/no-extraneous-import import { UpdateExpectedPackageWorkStatusesChanges } from '@sofie-automation/shared-lib/dist/peripheralDevice/methodsAPI' // eslint-disable-next-line node/no-extraneous-import @@ -211,12 +212,18 @@ export class PackageManagerHandler { this._observers.push(observer) } - const expectedPackagesObserver = this.coreHandler.observe('deviceExpectedPackages') - triggerUpdateOnAnyChange(expectedPackagesObserver) - - triggerUpdateOnAnyChange(this.coreHandler.observe('packageManagerPlayoutContext')) - triggerUpdateOnAnyChange(this.coreHandler.observe('packageManagerPackageContainers')) - triggerUpdateOnAnyChange(this.coreHandler.observe('packageManagerExpectedPackages')) + triggerUpdateOnAnyChange( + this.coreHandler.observe(PeripheralDevicePubSubCollectionsNames.packageManagerExpectedPackages) + ) + triggerUpdateOnAnyChange( + this.coreHandler.observe(PeripheralDevicePubSubCollectionsNames.packageManagerPlayoutContext) + ) + triggerUpdateOnAnyChange( + this.coreHandler.observe(PeripheralDevicePubSubCollectionsNames.packageManagerPackageContainers) + ) + triggerUpdateOnAnyChange( + this.coreHandler.observe(PeripheralDevicePubSubCollectionsNames.packageManagerExpectedPackages) + ) } public triggerUpdatedExpectedPackages(): void { if (this._triggerUpdatedExpectedPackagesTimeout) { @@ -255,7 +262,7 @@ export class PackageManagerHandler { if (!this.coreHandler.notUsingCore) { const playoutContextObj = this.coreHandler - .getCollection('packageManagerPlayoutContext') + .getCollection(PeripheralDevicePubSubCollectionsNames.packageManagerPlayoutContext) .find()[0] if (playoutContextObj) { activePlaylist = playoutContextObj.activePlaylist @@ -266,7 +273,7 @@ export class PackageManagerHandler { } const packageContainersObj = this.coreHandler - .getCollection('packageManagerPackageContainers') + .getCollection(PeripheralDevicePubSubCollectionsNames.packageManagerPackageContainers) .find()[0] if (packageContainersObj) { Object.assign(packageContainers, packageContainersObj.packageContainers) @@ -277,7 +284,7 @@ export class PackageManagerHandler { // Add from Core collections: const expectedPackagesObjs = this.coreHandler - .getCollection('packageManagerExpectedPackages') + .getCollection(PeripheralDevicePubSubCollectionsNames.packageManagerExpectedPackages) .find() const expectedPackagesCore: ExpectedPackageWrap[] = [] diff --git a/package.json b/package.json index c3483ccb..5adc90f2 100644 --- a/package.json +++ b/package.json @@ -71,8 +71,8 @@ "node": ">=18" }, "dependencies": { - "@sofie-automation/server-core-integration": "1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0", - "@sofie-automation/shared-lib": "1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" + "@sofie-automation/server-core-integration": "1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0", + "@sofie-automation/shared-lib": "1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0" }, "prettier": "@sofie-automation/code-standard-preset/.prettierrc.json", "lint-staged": { diff --git a/yarn.lock b/yarn.lock index 464b2e4e..520e70d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -689,8 +689,8 @@ __metadata: resolution: "@html-renderer/app@workspace:apps/html-renderer/app" dependencies: "@html-renderer/generic": "npm:1.50.5" - "@sofie-automation/shared-lib": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" - "@sofie-package-manager/api": "npm:1.50.5" + "@sofie-automation/shared-lib": "npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0" + "@sofie-package-manager/api": "npm:1.50.6" "@types/ws": "npm:^8.5.4" archiver: "npm:^7.0.1" electron: "npm:^30.0.6" @@ -709,7 +709,7 @@ __metadata: version: 0.0.0-use.local resolution: "@html-renderer/generic@workspace:apps/html-renderer/packages/generic" dependencies: - "@sofie-package-manager/api": "npm:1.50.5" + "@sofie-package-manager/api": "npm:1.50.6" electron: "npm:^30.0.6" rimraf: "npm:^5.0.5" peerDependencies: @@ -1259,10 +1259,10 @@ __metadata: languageName: node linkType: hard -"@mos-connection/model@npm:^3.0.4": - version: 3.0.4 - resolution: "@mos-connection/model@npm:3.0.4" - checksum: 10/3f0e7a845c0aa55c655f4a77dac4297ef76a2b138997c4d89d668adefd174804a4ebb5e35c4df8485c68cf50d95fac651e68ea1b1f89bfe2bde9000e7e0b1480 +"@mos-connection/model@npm:^4.1.1": + version: 4.1.1 + resolution: "@mos-connection/model@npm:4.1.1" + checksum: 10/2461f23e25cd62bf6b717c9d68b581d80bf53548ad6b7bb3844469be383fe95eaa2f74323931444e727f0c474f8f3433a44cd6b5ad9a7e0ce4b8b14985ecfb48 languageName: node linkType: hard @@ -2112,42 +2112,30 @@ __metadata: languageName: node linkType: hard -"@sofie-automation/server-core-integration@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0": - version: 1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0 - resolution: "@sofie-automation/server-core-integration@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" +"@sofie-automation/server-core-integration@npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0": + version: 1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0 + resolution: "@sofie-automation/server-core-integration@npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0" dependencies: - "@sofie-automation/shared-lib": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" + "@sofie-automation/shared-lib": "npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0" ejson: "npm:^2.2.3" eventemitter3: "npm:^4.0.7" faye-websocket: "npm:^0.11.4" got: "npm:^11.8.6" - tslib: "npm:^2.4.0" - underscore: "npm:^1.13.4" - checksum: 10/85a4b2d8acbf36f6dbc72c0dc0f3b05e9268c3cd10e89d1eaf5a1bc70ea44651cbb070b9af335f692520a0fbbd062d5747f7a271b9130179581b20c0bf8a7c99 - languageName: node - linkType: hard - -"@sofie-automation/shared-lib@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0": - version: 1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0 - resolution: "@sofie-automation/shared-lib@npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" - dependencies: - "@mos-connection/model": "npm:^3.0.4" - timeline-state-resolver-types: "npm:9.0.1" - tslib: "npm:^2.4.0" - type-fest: "npm:^2.19.0" - checksum: 10/4ff4f24fc87c4b79bfb592312bfcfc782bcf02e21b25dcffb04edd1de0a3f0fd4ddae005f198d8729fb37894e111233f04f712f83e443f7a310bc5e2b4a81e24 + tslib: "npm:^2.6.2" + underscore: "npm:^1.13.6" + checksum: 10/2553524266f311dd80c7a0f4ffecd1b69ed540ba54048c93816b78f767252988a2998037d106278ec397baaa0e39e4fdb98f2526e5ea15df80490113d969f353 languageName: node linkType: hard -"@sofie-automation/shared-lib@npm:1.50.2": - version: 1.50.2 - resolution: "@sofie-automation/shared-lib@npm:1.50.2" +"@sofie-automation/shared-lib@npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0": + version: 1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0 + resolution: "@sofie-automation/shared-lib@npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0" dependencies: - "@mos-connection/model": "npm:^3.0.4" - timeline-state-resolver-types: "npm:9.0.1" - tslib: "npm:^2.4.0" - type-fest: "npm:^2.19.0" - checksum: 10/5c6465f20e3aac7dcadafa50aac196371f2a0736c61b4f7273e26494abe7b73ea916d7f9879966d8341232d22a81a373af6bcbcdc47ac2adc4e555780758c4ce + "@mos-connection/model": "npm:^4.1.1" + timeline-state-resolver-types: "npm:9.1.0" + tslib: "npm:^2.6.2" + type-fest: "npm:^3.13.1" + checksum: 10/3acccca603f9f55dccd39c69c41608ae01dedda744436aec7364726ee99815dcd3f97d8dc6aa91843972c378d73312bfb1bcdf8626393d6d6495dc5697cf7a98 languageName: node linkType: hard @@ -2210,6 +2198,7 @@ __metadata: xml-js: "npm:^1.6.11" peerDependencies: "@sofie-automation/shared-lib": "*" + typescript: "*" ws: "*" languageName: unknown linkType: soft @@ -10622,8 +10611,8 @@ __metadata: resolution: "package-manager-monorepo@workspace:." dependencies: "@sofie-automation/code-standard-preset": "npm:^2.5.1" - "@sofie-automation/server-core-integration": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" - "@sofie-automation/shared-lib": "npm:1.50.4-nightly-fix-package-manager-html-rendering-types-20240711-124249-45c25b9.0" + "@sofie-automation/server-core-integration": "npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0" + "@sofie-automation/shared-lib": "npm:1.51.0-nightly-fix-pm-types-html-template-20240823-061828-d90c220.0" "@types/jest": "npm:^29.2.5" "@types/rimraf": "npm:^3.0.0" "@yao-pkg/pkg": "npm:^5.11.5" @@ -12826,21 +12815,12 @@ __metadata: languageName: node linkType: hard -"timeline-state-resolver-types@npm:9.0.1": - version: 9.0.1 - resolution: "timeline-state-resolver-types@npm:9.0.1" - dependencies: - tslib: "npm:^2.5.1" - checksum: 10/17bfd8cd52069fb3571bc9cf9c8d711a1c9569ca2cf61ee9a429be8fac4035cf3b6f146d31c68dda6d8fdd5a880f72c8dbe65985d0385e3128407b08c303361a - languageName: node - linkType: hard - -"timeline-state-resolver-types@npm:9.0.1": - version: 9.0.1 - resolution: "timeline-state-resolver-types@npm:9.0.1" +"timeline-state-resolver-types@npm:9.1.0": + version: 9.1.0 + resolution: "timeline-state-resolver-types@npm:9.1.0" dependencies: - tslib: "npm:^2.5.1" - checksum: 10/17bfd8cd52069fb3571bc9cf9c8d711a1c9569ca2cf61ee9a429be8fac4035cf3b6f146d31c68dda6d8fdd5a880f72c8dbe65985d0385e3128407b08c303361a + tslib: "npm:^2.6.2" + checksum: 10/91ada784c017e502523d66cd7c79dd37bd1bb627a48f0b0f63da351f8e710dfbb113e645dac8c70c0b565a2de0ba10e802032da6eb8753aa2333fbd55cab994a languageName: node linkType: hard @@ -12967,15 +12947,15 @@ __metadata: languageName: node linkType: hard -"ts-essentials@npm:^7.0.3": - version: 7.0.3 - resolution: "ts-essentials@npm:7.0.3" +"ts-essentials@npm:^10.0.0": + version: 10.0.2 + resolution: "ts-essentials@npm:10.0.2" peerDependencies: typescript: ">=4.5.0" peerDependenciesMeta: typescript: optional: true - checksum: 10/cee2ba41a5d78688246550e8e30ce201bce7fbcfb9ccb517460a5823143e1b35937e6764cc146d09a132d7da0f2d758a1d45e47a550b6928b98154d3b5f732d8 + checksum: 10/f557d940acf8c80c7807530a5a2985e7426387ddda3213bda9990ee072c6360ada52bb0651ee40aa3e6c62d834b57210a61a3043d137f6b617193bb4069d9555 languageName: node linkType: hard @@ -13030,7 +13010,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.1, tslib@npm:^2.6.2": +"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" checksum: 10/bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca @@ -13108,7 +13088,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:3.13.1, type-fest@npm:^3.0.0, type-fest@npm:^3.1.0": +"type-fest@npm:3.13.1, type-fest@npm:^3.0.0, type-fest@npm:^3.1.0, type-fest@npm:^3.13.1": version: 3.13.1 resolution: "type-fest@npm:3.13.1" checksum: 10/9a8a2359ada34c9b3affcaf3a8f73ee14c52779e89950db337ce66fb74c3399776c697c99f2532e9b16e10e61cfdba3b1c19daffb93b338b742f0acd0117ce12 @@ -13171,7 +13151,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^2.0.0, type-fest@npm:^2.13.0, type-fest@npm:^2.19.0, type-fest@npm:^2.5.0": +"type-fest@npm:^2.0.0, type-fest@npm:^2.13.0, type-fest@npm:^2.5.0": version: 2.19.0 resolution: "type-fest@npm:2.19.0" checksum: 10/7bf9e8fdf34f92c8bb364c0af14ca875fac7e0183f2985498b77be129dc1b3b1ad0a6b3281580f19e48c6105c037fb966ad9934520c69c6434d17fd0af4eed78 @@ -13265,7 +13245,7 @@ __metadata: languageName: node linkType: hard -"underscore@npm:^1.12.0, underscore@npm:^1.13.4, underscore@npm:^1.13.6": +"underscore@npm:^1.12.0, underscore@npm:^1.13.6": version: 1.13.6 resolution: "underscore@npm:1.13.6" checksum: 10/58cf5dc42cb0ac99c146ae4064792c0a2cc84f3a3c4ad88f5082e79057dfdff3371d896d1ec20379e9ece2450d94fa78f2ef5bfefc199ba320653e32c009bd66 From a6f76f15f414db6ef44768c2f5209e9a6a1541b6 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 23 Aug 2024 12:26:29 +0100 Subject: [PATCH 33/46] chore: only open security dependabot prs --- .github/dependabot.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 065be414..055f425d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,3 +12,5 @@ updates: interval: "weekly" reviewers: - "@nrkno/sofie-ops" + # Only create security updates + open-pull-requests-limit: 0 From cebbbe901c97062618a4220656297ee7ba74d09c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:26:49 +0100 Subject: [PATCH 34/46] chore(deps): bump ejs from 3.1.9 to 3.1.10 (#178) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4befdab0..218e38c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4776,13 +4776,13 @@ __metadata: linkType: hard "ejs@npm:^3.1.7": - version: 3.1.9 - resolution: "ejs@npm:3.1.9" + version: 3.1.10 + resolution: "ejs@npm:3.1.10" dependencies: jake: "npm:^10.8.5" bin: ejs: bin/cli.js - checksum: 10/71f56d37540d2c2d71701f0116710c676f75314a3e997ef8b83515d5d4d2b111c5a72725377caeecb928671bacb84a0d38135f345904812e989847057d59f21a + checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 languageName: node linkType: hard From dfb539215085a66b3adca31db5e046b0ffac13fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:26:57 +0100 Subject: [PATCH 35/46] chore(deps): bump braces from 3.0.2 to 3.0.3 (#187) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 218e38c9..23445c86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3438,11 +3438,11 @@ __metadata: linkType: hard "braces@npm:^3.0.2": - version: 3.0.2 - resolution: "braces@npm:3.0.2" + version: 3.0.3 + resolution: "braces@npm:3.0.3" dependencies: - fill-range: "npm:^7.0.1" - checksum: 10/966b1fb48d193b9d155f810e5efd1790962f2c4e0829f8440b8ad236ba009222c501f70185ef732fef17a4c490bb33a03b90dab0631feafbdf447da91e8165b1 + fill-range: "npm:^7.1.1" + checksum: 10/fad11a0d4697a27162840b02b1fad249c1683cbc510cd5bf1a471f2f8085c046d41094308c577a50a03a579dd99d5a6b3724c4b5e8b14df2c4443844cfcda2c6 languageName: node linkType: hard @@ -5518,12 +5518,12 @@ __metadata: languageName: node linkType: hard -"fill-range@npm:^7.0.1": - version: 7.0.1 - resolution: "fill-range@npm:7.0.1" +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" dependencies: to-regex-range: "npm:^5.0.1" - checksum: 10/e260f7592fd196b4421504d3597cc76f4a1ca7a9488260d533b611fc3cefd61e9a9be1417cb82d3b01ad9f9c0ff2dbf258e1026d2445e26b0cf5148ff4250429 + checksum: 10/a7095cb39e5bc32fada2aa7c7249d3f6b01bd1ce461a61b0adabacccabd9198500c6fb1f68a7c851a657e273fce2233ba869638897f3d7ed2e87a2d89b4436ea languageName: node linkType: hard From 8f51d540526d30d6564f86694de3050ba20da514 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:27:16 +0100 Subject: [PATCH 36/46] chore(deps): bump ws from 8.16.0 to 8.18.0 (#192) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 23445c86..eaf2d3f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12552,8 +12552,8 @@ __metadata: linkType: hard "ws@npm:^8.12.0": - version: 8.16.0 - resolution: "ws@npm:8.16.0" + version: 8.18.0 + resolution: "ws@npm:8.18.0" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -12562,7 +12562,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/7c511c59e979bd37b63c3aea4a8e4d4163204f00bd5633c053b05ed67835481995f61a523b0ad2b603566f9a89b34cb4965cb9fab9649fbfebd8f740cea57f17 + checksum: 10/70dfe53f23ff4368d46e4c0b1d4ca734db2c4149c6f68bc62cb16fc21f753c47b35fcc6e582f3bdfba0eaeb1c488cddab3c2255755a5c3eecb251431e42b3ff6 languageName: node linkType: hard From 13d62e048d9a0469ea9abf082a3d62e96a9708b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:27:23 +0100 Subject: [PATCH 37/46] chore(deps): bump winston from 3.8.2 to 3.14.2 (#198) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 61 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/yarn.lock b/yarn.lock index eaf2d3f1..6f16e0fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -525,10 +525,10 @@ __metadata: languageName: unknown linkType: soft -"@colors/colors@npm:1.5.0": - version: 1.5.0 - resolution: "@colors/colors@npm:1.5.0" - checksum: 10/9d226461c1e91e95f067be2bdc5e6f99cfe55a721f45afb44122e23e4b8602eeac4ff7325af6b5a369f36396ee1514d3809af3f57769066d80d83790d8e53339 +"@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0": + version: 1.6.0 + resolution: "@colors/colors@npm:1.6.0" + checksum: 10/66d00284a3a9a21e5e853b256942e17edbb295f4bd7b9aa7ef06bbb603568d5173eb41b0f64c1e51748bc29d382a23a67d99956e57e7431c64e47e74324182d9 languageName: node linkType: hard @@ -2631,6 +2631,13 @@ __metadata: languageName: node linkType: hard +"@types/triple-beam@npm:^1.3.2": + version: 1.3.5 + resolution: "@types/triple-beam@npm:1.3.5" + checksum: 10/519b6a1b30d4571965c9706ad5400a200b94e4050feca3e7856e3ea7ac00ec9903e32e9a10e2762d0f7e472d5d03e5f4b29c16c0bd8c1f77c8876c683b2231f1 + languageName: node + linkType: hard + "@types/underscore@npm:^1.10.24": version: 1.11.15 resolution: "@types/underscore@npm:1.11.15" @@ -8180,16 +8187,17 @@ __metadata: languageName: node linkType: hard -"logform@npm:^2.3.2, logform@npm:^2.4.0": - version: 2.4.2 - resolution: "logform@npm:2.4.2" +"logform@npm:^2.6.0, logform@npm:^2.6.1": + version: 2.6.1 + resolution: "logform@npm:2.6.1" dependencies: - "@colors/colors": "npm:1.5.0" + "@colors/colors": "npm:1.6.0" + "@types/triple-beam": "npm:^1.3.2" fecha: "npm:^4.2.0" ms: "npm:^2.1.1" safe-stable-stringify: "npm:^2.3.1" triple-beam: "npm:^1.3.0" - checksum: 10/939b809719c91a220539027b1dde68c61eee64a52f6121e56293ab64a5ad0b9069ffd40cde33e0fc81959257d8a1e014c57b7a9e878ab2fd0b4a3c16dbca6cec + checksum: 10/e67f414787fbfe1e6a997f4c84300c7e06bee3d0bd579778af667e24b36db3ea200ed195d41b61311ff738dab7faabc615a07b174b22fe69e0b2f39e985be64b languageName: node linkType: hard @@ -10513,6 +10521,17 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^3.6.2": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10/d9e3e53193adcdb79d8f10f2a1f6989bd4389f5936c6f8b870e77570853561c362bee69feca2bbb7b32368ce96a85504aa4cedf7cf80f36e6a9de30d64244048 + languageName: node + linkType: hard + "readable-stream@npm:^4.1.0": version: 4.3.0 resolution: "readable-stream@npm:4.3.0" @@ -12397,33 +12416,33 @@ __metadata: languageName: node linkType: hard -"winston-transport@npm:^4.5.0": - version: 4.5.0 - resolution: "winston-transport@npm:4.5.0" +"winston-transport@npm:^4.7.0": + version: 4.7.1 + resolution: "winston-transport@npm:4.7.1" dependencies: - logform: "npm:^2.3.2" - readable-stream: "npm:^3.6.0" + logform: "npm:^2.6.1" + readable-stream: "npm:^3.6.2" triple-beam: "npm:^1.3.0" - checksum: 10/3184b7f29fa97aac5b75ff680100656116aff8d164c09bc7459c9b7cb1ce47d02254caf96c2293791ec175c0e76e5ff59b5ed1374733e0b46248cf4f68a182fc + checksum: 10/bc48c921ec9b4a71c1445bf274aa6b00c01089a6c26fc0b19534f8a32fa2710c6766c9e6db53a23492c20772934025d312dd9fb08df157ccb6579ad6b9dae9a7 languageName: node linkType: hard "winston@npm:*, winston@npm:^3.5.1": - version: 3.8.2 - resolution: "winston@npm:3.8.2" + version: 3.14.2 + resolution: "winston@npm:3.14.2" dependencies: - "@colors/colors": "npm:1.5.0" + "@colors/colors": "npm:^1.6.0" "@dabh/diagnostics": "npm:^2.0.2" async: "npm:^3.2.3" is-stream: "npm:^2.0.0" - logform: "npm:^2.4.0" + logform: "npm:^2.6.0" one-time: "npm:^1.0.0" readable-stream: "npm:^3.4.0" safe-stable-stringify: "npm:^2.3.1" stack-trace: "npm:0.0.x" triple-beam: "npm:^1.3.0" - winston-transport: "npm:^4.5.0" - checksum: 10/cd92fd95f95cde597128066c965aa72dca5e7d504a8c5952ebd6704761ea807b9b9c721b8fe2b0639b7005c9ad7dc175f9031e354d9ac1195eef91126abca341 + winston-transport: "npm:^4.7.0" + checksum: 10/ba818714606175f27c38c42b22913e65f17987a0c8c41bcc73d55f3be8d70d629313f45e312ec02eea7bf074f9abee3f228746140245eb5258487c4161f3a798 languageName: node linkType: hard From 3bbbe8bbc4e93df14939ab20f7517b5daf3dec84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:27:44 +0100 Subject: [PATCH 38/46] chore(deps): bump koa-bodyparser and @types/koa-bodyparser (#179) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6f16e0fa..4262f81b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2389,16 +2389,7 @@ __metadata: languageName: node linkType: hard -"@types/koa-bodyparser@npm:^4.3.0": - version: 4.3.7 - resolution: "@types/koa-bodyparser@npm:4.3.7" - dependencies: - "@types/koa": "npm:*" - checksum: 10/3a2cac14cb4a720d017d7708fbe9e8a310b5ecebbe62703a2606bb48c775fbeaf9fd601ba9cb7add03c7059f90327d8bcad3d70443eaea383b117b5c020054ec - languageName: node - linkType: hard - -"@types/koa-bodyparser@npm:^4.3.12": +"@types/koa-bodyparser@npm:^4.3.0, @types/koa-bodyparser@npm:^4.3.12": version: 4.3.12 resolution: "@types/koa-bodyparser@npm:4.3.12" dependencies: @@ -7682,12 +7673,13 @@ __metadata: linkType: hard "koa-bodyparser@npm:^4.3.0": - version: 4.3.0 - resolution: "koa-bodyparser@npm:4.3.0" + version: 4.4.1 + resolution: "koa-bodyparser@npm:4.4.1" dependencies: co-body: "npm:^6.0.0" copy-to: "npm:^2.0.1" - checksum: 10/c227fe0fb5a55b98fc91d865e80229b60178d216d53b732b07833eb38f48a7ed6aa768a083bc06e359db33298547e9a65842fbe9d3f0fdaf5149fe0becafc88f + type-is: "npm:^1.6.18" + checksum: 10/c741a99ccacc92ee126edad121fed2d200753348e0dedfd65ec67fcfa513b4db9f791ef3200817358ab2c120bcf8e73488cbd0b7f3c7d522a0b21bbb647ce616 languageName: node linkType: hard @@ -12005,7 +11997,7 @@ __metadata: languageName: node linkType: hard -"type-is@npm:^1.6.16": +"type-is@npm:^1.6.16, type-is@npm:^1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" dependencies: From a4ce0b19d40c21021fbd8a23bbbe519ad81c0cd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:54:16 +0100 Subject: [PATCH 39/46] chore(deps): bump axios from 1.6.7 to 1.7.4 (#199) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4262f81b..eb2b277f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3272,13 +3272,13 @@ __metadata: linkType: hard "axios@npm:^1.0.0": - version: 1.6.7 - resolution: "axios@npm:1.6.7" + version: 1.7.4 + resolution: "axios@npm:1.7.4" dependencies: - follow-redirects: "npm:^1.15.4" + follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/a1932b089ece759cd261f175d9ebf4d41c8994cf0c0767cda86055c7a19bcfdade8ae3464bf4cec4c8b142f4a657dc664fb77a41855e8376cf38b86d7a86518f + checksum: 10/7a1429be1e3d0c2e1b96d4bba4d113efbfabc7c724bed107beb535c782c7bea447ff634886b0c7c43395a264d085450d009eb1154b5f38a8bae49d469fdcbc61 languageName: node linkType: hard @@ -5606,7 +5606,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.15.4": +"follow-redirects@npm:^1.15.6": version: 1.15.6 resolution: "follow-redirects@npm:1.15.6" peerDependenciesMeta: From 1b06fbbda9144524f37b4f542427963dbcd3507a Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 23 Aug 2024 13:04:34 +0100 Subject: [PATCH 40/46] fix: pin electron version. fix build of html-renderer on linux --- apps/html-renderer/app/package.json | 5 +++-- apps/html-renderer/packages/generic/package.json | 6 +++--- yarn.lock | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/html-renderer/app/package.json b/apps/html-renderer/app/package.json index 7f99835f..1139096c 100644 --- a/apps/html-renderer/app/package.json +++ b/apps/html-renderer/app/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@types/ws": "^8.5.4", "archiver": "^7.0.1", - "electron": "^30.0.6", + "electron": "30.0.6", "electron-builder": "^24.13.3", "lerna": "^6.6.1", "rimraf": "^5.0.5" @@ -59,9 +59,10 @@ ] }, "linux": { + "target": "dir", + "executableName": "html-renderer", "extraFiles": [] }, - "electronVersion": "30.0.6", "files": [ "dist/**/*" ], diff --git a/apps/html-renderer/packages/generic/package.json b/apps/html-renderer/packages/generic/package.json index 3c11a37b..ed8feb1c 100644 --- a/apps/html-renderer/packages/generic/package.json +++ b/apps/html-renderer/packages/generic/package.json @@ -22,11 +22,11 @@ ] }, "peerDependencies": { - "@sofie-automation/shared-lib": "*" + "@sofie-automation/shared-lib": "*", + "electron": "*" }, "dependencies": { - "@sofie-package-manager/api": "1.50.6", - "electron": "^30.0.6" + "@sofie-package-manager/api": "1.50.6" }, "devDependencies": { "rimraf": "^5.0.5" diff --git a/yarn.lock b/yarn.lock index 520e70d3..17b7cad6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -693,7 +693,7 @@ __metadata: "@sofie-package-manager/api": "npm:1.50.6" "@types/ws": "npm:^8.5.4" archiver: "npm:^7.0.1" - electron: "npm:^30.0.6" + electron: "npm:30.0.6" electron-builder: "npm:^24.13.3" lerna: "npm:^6.6.1" portfinder: "npm:^1.0.32" @@ -5425,7 +5425,7 @@ __metadata: languageName: node linkType: hard -"electron@npm:^30.0.6": +"electron@npm:30.0.6, electron@npm:^30.0.6": version: 30.0.6 resolution: "electron@npm:30.0.6" dependencies: From 40e12d3083b385a2cddf8ea93cdaa522fbed7073 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Fri, 23 Aug 2024 13:04:55 +0100 Subject: [PATCH 41/46] fix: html-renderer lookup paths on linux --- apps/appcontainer-node/packages/generic/src/appContainer.ts | 3 ++- shared/packages/api/src/htmlRenderer.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/appcontainer-node/packages/generic/src/appContainer.ts b/apps/appcontainer-node/packages/generic/src/appContainer.ts index 243acb70..4021d705 100644 --- a/apps/appcontainer-node/packages/generic/src/appContainer.ts +++ b/apps/appcontainer-node/packages/generic/src/appContainer.ts @@ -601,7 +601,8 @@ export class AppContainer { availableApp: AvailableAppInfo, useCriticalOnlyMode: boolean ): cp.ChildProcess { - const cwd = process.execPath.match(/node.exe$/) + const isRunningInDevelopmentMode = process.execPath.endsWith('node.exe') || process.execPath.endsWith('node') + const cwd = isRunningInDevelopmentMode ? undefined // Process runs as a node process, we're probably in development mode. : path.dirname(process.execPath) // Process runs as a node process, we're probably in development mode. diff --git a/shared/packages/api/src/htmlRenderer.ts b/shared/packages/api/src/htmlRenderer.ts index b23034f9..f3cc86ca 100644 --- a/shared/packages/api/src/htmlRenderer.ts +++ b/shared/packages/api/src/htmlRenderer.ts @@ -34,11 +34,11 @@ export async function testHtmlRenderer(): Promise { ] } else { alternatives = [ - 'html-renderer', + './html-renderer', path.resolve('html-renderer/html-renderer'), path.resolve('../html-renderer'), path.resolve('../html-renderer/html-renderer'), - path.resolve('../../html-renderer/app/deploy/html-renderer/html-renderer'), + path.resolve('../../html-renderer/app/deploy/linux-unpacked/html-renderer'), ] } From f3164b48616710f5cd043475f4f0c9963400782e Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 26 Aug 2024 08:39:02 +0200 Subject: [PATCH 42/46] fix: run HTMLRenderer using `yarn start` script when in development mode --- shared/packages/api/src/htmlRenderer.ts | 48 +++++++++++++++---- .../expectationHandlers/renderHTML.ts | 6 +-- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/shared/packages/api/src/htmlRenderer.ts b/shared/packages/api/src/htmlRenderer.ts index f3cc86ca..68fe2736 100644 --- a/shared/packages/api/src/htmlRenderer.ts +++ b/shared/packages/api/src/htmlRenderer.ts @@ -1,11 +1,11 @@ -import { spawn } from 'child_process' +import { spawn, ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'child_process' import * as fs from 'fs/promises' import * as path from 'path' import { stringifyError } from './lib' let overriddenHTMLRendererPath: string | null = null /** - * Override the paths of the html-renderer executables, intended for unit testing purposes + * Override the paths of the html-renderer executables, intended for unit testing purposes or when running in development mode * @param paths Paths to executables */ export function overrideHTMLRendererExecutables(overridePath: string | null): void { @@ -17,10 +17,27 @@ export interface HTMLRendererProcess { cancel: () => void } let htmlRenderExecutable = 'N/A' // Is set when testHtmlRenderer is run -export function getHtmlRendererExecutable(): string { +function getHtmlRendererExecutable(): string { if (overriddenHTMLRendererPath) return overriddenHTMLRendererPath return htmlRenderExecutable } + +export function spawnHtmlRendererExecutable( + args: string[], + options?: SpawnOptionsWithoutStdio +): ChildProcessWithoutNullStreams { + const executable = getHtmlRendererExecutable() + + if (executable.includes('yarn --cwd')) { + // Is is development mode and executing using yarn. Use shell mode: + return spawn(executable, args, { + ...options, + shell: true, + }) + } else { + return spawn(executable, args, options) + } +} /** Check if HTML-Renderer is available, returns null if no error found */ export async function testHtmlRenderer(): Promise { if (htmlRenderExecutable === 'N/A') { @@ -52,17 +69,28 @@ export async function testHtmlRenderer(): Promise { // If it exists, use that path: htmlRenderExecutable = alternative } + if (htmlRenderExecutable === 'N/A') { - return `Not able to find any HTML-Renderer executable, tried: ${alternatives.join(', ')}` + if ( + process.execPath.endsWith('node.exe') || // windows + process.execPath.endsWith('node') // linux + ) { + // Process runs as a node process, we're probably in development mode. + // Use the source code directly instead of the executable: + overrideHTMLRendererExecutables(`yarn --cwd ${path.resolve('../../html-renderer/app')} start`) + } else { + return `Not able to find any HTML-Renderer executable, tried: ${alternatives.join(', ')}` + } } } - return testExecutable(getHtmlRendererExecutable()) + return testHtmlRendererExecutable() } -export async function testExecutable(executable: string): Promise { +export async function testHtmlRendererExecutable(): Promise { return new Promise((resolve) => { - const htmlRendererProcess = spawn(executable, ['--', '--test=true']) + const executablePath = getHtmlRendererExecutable() + const htmlRendererProcess = spawnHtmlRendererExecutable(['--', '--test=true']) let output = '' htmlRendererProcess.stderr.on('data', (data) => { const str = data.toString() @@ -73,7 +101,7 @@ export async function testExecutable(executable: string): Promise output += str }) htmlRendererProcess.on('error', (err) => { - resolve(`Process ${executable} emitted error: ${stringifyError(err)}`) + resolve(`Process ${executablePath} emitted error: ${stringifyError(err)}`) }) htmlRendererProcess.on('exit', (code) => { const m = output.match(/Version: ([\w.]+)/i) // Version 1.50.1 @@ -82,10 +110,10 @@ export async function testExecutable(executable: string): Promise if (m) { resolve(null) } else { - resolve(`Process ${executable} bad version: "${output}"`) + resolve(`Process ${executablePath} bad version: "${output}"`) } } else { - resolve(`Process ${executable} exited with code ${code}`) + resolve(`Process ${executablePath} exited with code ${code}`) } }) }) diff --git a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts index 253458ac..b87a3ee3 100644 --- a/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts +++ b/shared/packages/worker/src/worker/workers/genericWorker/expectationHandlers/renderHTML.ts @@ -1,4 +1,4 @@ -import { ChildProcessWithoutNullStreams, spawn } from 'child_process' +import { ChildProcessWithoutNullStreams } from 'child_process' import * as fs from 'fs/promises' import * as path from 'path' import WebSocket from 'ws' @@ -16,7 +16,7 @@ import { AccessorId, startTimer, hashObj, - getHtmlRendererExecutable, + spawnHtmlRendererExecutable, assertNever, hash, protectString, @@ -657,7 +657,7 @@ class HTMLRenderer { `--background=${this.exp.endRequirement.version.renderer?.background ?? 'default'}`, `--interactive=true`, ]) - this.htmlRendererProcess = spawn(getHtmlRendererExecutable(), args, { + this.htmlRendererProcess = spawnHtmlRendererExecutable(args, { windowsVerbatimArguments: true, // To fix an issue with arguments on Windows }) From 9bc6a52482486d65ba674cf800c002101cc5701d Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 26 Aug 2024 08:40:06 +0200 Subject: [PATCH 43/46] fix: default to png as screenshot --- apps/html-renderer/packages/generic/src/BrowserRenderer.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/html-renderer/packages/generic/src/BrowserRenderer.ts b/apps/html-renderer/packages/generic/src/BrowserRenderer.ts index 7cd0d501..5981a14f 100644 --- a/apps/html-renderer/packages/generic/src/BrowserRenderer.ts +++ b/apps/html-renderer/packages/generic/src/BrowserRenderer.ts @@ -109,6 +109,9 @@ export class BrowserRenderer implements InteractiveAPI { await fs.promises.writeFile(filename, image.toPNG()) } else if (fileName.endsWith('.jpeg')) { await fs.promises.writeFile(filename, image.toJPEG(90)) + } else if (!fileName.includes('.')) { + // No file format, default to png: + await fs.promises.writeFile(filename, image.toPNG()) } else { throw new Error(`Unsupported file format: ${fileName}`) } From ca54b28ccce83f7883199fd5dce998dd1168d13f Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 26 Aug 2024 08:40:32 +0200 Subject: [PATCH 44/46] chore: yarn.lock --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 17b7cad6..ce4129ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -710,10 +710,10 @@ __metadata: resolution: "@html-renderer/generic@workspace:apps/html-renderer/packages/generic" dependencies: "@sofie-package-manager/api": "npm:1.50.6" - electron: "npm:^30.0.6" rimraf: "npm:^5.0.5" peerDependencies: "@sofie-automation/shared-lib": "*" + electron: "*" languageName: unknown linkType: soft @@ -5425,7 +5425,7 @@ __metadata: languageName: node linkType: hard -"electron@npm:30.0.6, electron@npm:^30.0.6": +"electron@npm:30.0.6": version: 30.0.6 resolution: "electron@npm:30.0.6" dependencies: From 5817e2556495e7054016e07f6f659a633dc42322 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 26 Aug 2024 08:57:34 +0200 Subject: [PATCH 45/46] chore: refactor to DRY and fix an issue in unit tests --- shared/packages/api/src/HelpfulEventEmitter.ts | 5 +++-- shared/packages/api/src/htmlRenderer.ts | 7 ++----- shared/packages/api/src/lib.ts | 13 +++++++++++++ shared/packages/api/src/logger.ts | 4 ++-- shared/packages/api/src/websocketConnection.ts | 5 ++--- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/shared/packages/api/src/HelpfulEventEmitter.ts b/shared/packages/api/src/HelpfulEventEmitter.ts index 57b7e6bf..5ebb8d15 100644 --- a/shared/packages/api/src/HelpfulEventEmitter.ts +++ b/shared/packages/api/src/HelpfulEventEmitter.ts @@ -1,4 +1,5 @@ import EventEmitter from 'events' +import { isRunningInTest } from './lib' /* eslint-disable no-console */ /** An EventEmitter which does a check that you've remembered to listen to the 'error' event */ @@ -21,7 +22,7 @@ export class HelpfulEventEmitter extends EventEmitter { console.error(`Stack: ${orgError.stack}`) // If we're running in Jest, it's better to make it a little more obvious that something is wrong: - if (process.env.JEST_WORKER_ID !== undefined) { + if (isRunningInTest()) { // Since no error listener is registered, this'll cause the process to exit and tests to fail: this.emit('error', orgError) } @@ -29,7 +30,7 @@ export class HelpfulEventEmitter extends EventEmitter { } // If we're running in Jest, it's better to make it a little more obvious that something is wrong: - if (process.env.JEST_WORKER_ID !== undefined && !this.listenerCount('error')) { + if (isRunningInTest() && !this.listenerCount('error')) { // Since no error listener is registered, this'll cause the process to exit and tests to fail: this.emit('error', orgError) } diff --git a/shared/packages/api/src/htmlRenderer.ts b/shared/packages/api/src/htmlRenderer.ts index 68fe2736..afd336c5 100644 --- a/shared/packages/api/src/htmlRenderer.ts +++ b/shared/packages/api/src/htmlRenderer.ts @@ -1,7 +1,7 @@ import { spawn, ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'child_process' import * as fs from 'fs/promises' import * as path from 'path' -import { stringifyError } from './lib' +import { isRunningInDevelopment, stringifyError } from './lib' let overriddenHTMLRendererPath: string | null = null /** @@ -71,10 +71,7 @@ export async function testHtmlRenderer(): Promise { } if (htmlRenderExecutable === 'N/A') { - if ( - process.execPath.endsWith('node.exe') || // windows - process.execPath.endsWith('node') // linux - ) { + if (isRunningInDevelopment()) { // Process runs as a node process, we're probably in development mode. // Use the source code directly instead of the executable: overrideHTMLRendererExecutables(`yarn --cwd ${path.resolve('../../html-renderer/app')} start`) diff --git a/shared/packages/api/src/lib.ts b/shared/packages/api/src/lib.ts index 8087d0b5..246594e0 100644 --- a/shared/packages/api/src/lib.ts +++ b/shared/packages/api/src/lib.ts @@ -382,3 +382,16 @@ export function mapToObject(map: Map): { [key: string]: T } { }) return o } +/** Returns true if we're running tests (in Jest) */ +export function isRunningInTest(): boolean { + // Note: JEST_WORKER_ID is set when running in unit tests + return process.env.JEST_WORKER_ID !== undefined +} +export function isRunningInDevelopment(): boolean { + return ( + !isRunningInTest() && + // Process runs as a node process, we're probably in development mode.: + (process.execPath.endsWith('node.exe') || // windows + process.execPath.endsWith('node')) // linux + ) +} diff --git a/shared/packages/api/src/logger.ts b/shared/packages/api/src/logger.ts index 4baeec68..6c564122 100644 --- a/shared/packages/api/src/logger.ts +++ b/shared/packages/api/src/logger.ts @@ -1,7 +1,7 @@ import _ from 'underscore' import * as Winston from 'winston' import { ProcessConfig } from './config' -import { stringifyError } from './lib' +import { isRunningInTest, stringifyError } from './lib' const { combine, label, json, timestamp, printf } = Winston.format @@ -142,7 +142,7 @@ export function setupLogger( transports: [transportConsole], }) } - if (handleProcess && process.env.JEST_WORKER_ID === undefined) { + if (handleProcess && !isRunningInTest()) { // Is not running in Jest logger.info('Logging to Console') } diff --git a/shared/packages/api/src/websocketConnection.ts b/shared/packages/api/src/websocketConnection.ts index 460fceeb..ac7077ef 100644 --- a/shared/packages/api/src/websocketConnection.ts +++ b/shared/packages/api/src/websocketConnection.ts @@ -1,6 +1,6 @@ import WebSocket from 'ws' import { HelpfulEventEmitter } from './HelpfulEventEmitter' -import { stringifyError } from './lib' +import { isRunningInTest, stringifyError } from './lib' import { AppContainerId, ExpectationManagerId, WorkerAgentId, WorkforceId } from './ids' import { MethodsInterfaceBase } from './methods' @@ -12,8 +12,7 @@ export const PING_TIME = 10 * 1000 * If the sender doesn't recieve a reply after this time, * the message is considered lost. */ -export const MESSAGE_TIMEOUT = process.env.JEST_WORKER_ID !== undefined ? 3000 : 10000 -// Note: JEST_WORKER_ID is set when running in unit tests +export const MESSAGE_TIMEOUT = isRunningInTest() ? 3000 : 10000 /** * Execution timeout. From 5fba6822f57b16509af99ff6ba39c1776b4ac651 Mon Sep 17 00:00:00 2001 From: Johan Nyman Date: Mon, 26 Aug 2024 09:44:02 +0200 Subject: [PATCH 46/46] chore: refactor to avoid regex --- scripts/gather-all-built.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/gather-all-built.mjs b/scripts/gather-all-built.mjs index c6492ea9..bfed60fd 100644 --- a/scripts/gather-all-built.mjs +++ b/scripts/gather-all-built.mjs @@ -36,7 +36,8 @@ for (const deployfolder of deployfolders) { filter: (src, _dest) => { const m = ( // Is executable or zip - src.match(/.*\.(exe|zip)$/) || + src.endsWith('.exe') || + src.endsWith('.zip') || // Is the deploy folder src.endsWith('deploy') )