diff --git a/examples/house-game/sde.config.js b/examples/house-game/sde.config.js index de80b665..cde6fb52 100644 --- a/examples/house-game/sde.config.js +++ b/examples/house-game/sde.config.js @@ -45,19 +45,11 @@ export async function config() { } }, - plugins: [ - // Copy the generated model listing to the app so that it can be loaded - // at runtime - { - postGenerate: async context => { - const srcPath = joinPath(context.config.prepDir, 'build', 'processed.json') - const dstName = 'listing.json' - const stagedFilePath = context.prepareStagedFile('model', dstName, generatedFilePath(), dstName) - await copyFile(srcPath, stagedFilePath) - return true - } - }, + // Copy the generated model listing to the app so that it can be loaded + // at runtime + outListingFile: generatedFilePath('listing.json'), + plugins: [ // Generate a `worker.js` file that runs the generated model in a worker workerPlugin({ outputPaths: [generatedFilePath('worker.js')] diff --git a/packages/build/.gitignore b/packages/build/.gitignore index ebb00c95..65ad48f0 100644 --- a/packages/build/.gitignore +++ b/packages/build/.gitignore @@ -1,3 +1,4 @@ dist docs/entry.md sde-prep +tests/build-prod/outputs diff --git a/packages/build/docs/interfaces/ResolvedConfig.md b/packages/build/docs/interfaces/ResolvedConfig.md index 8b9b43a2..ac1ea183 100644 --- a/packages/build/docs/interfaces/ResolvedConfig.md +++ b/packages/build/docs/interfaces/ResolvedConfig.md @@ -67,3 +67,12 @@ ___ The code format to generate. If 'js', the model will be compiled to a JavaScript file. If 'c', the model will be compiled to a C file (in which case an additional plugin will be needed to convert the C code to a WebAssembly module). + +___ + +### outListingFile + + `Optional` **outListingFile**: `string` + +The absolute path to the JSON file that will be written by the build process that +lists all dimensions and variables in the model. diff --git a/packages/build/docs/interfaces/UserConfig.md b/packages/build/docs/interfaces/UserConfig.md index a306ce43..ee18c266 100644 --- a/packages/build/docs/interfaces/UserConfig.md +++ b/packages/build/docs/interfaces/UserConfig.md @@ -64,6 +64,16 @@ defaults to 'js'. ___ +### outListingFile + + `Optional` **outListingFile**: `string` + +If defined, the build process will write a JSON file to the provided path that lists +all dimensions and variables in the model. This can be an absolute path, or if it +is a relative path it will be resolved relative to the `rootDir` for the project. + +___ + ### plugins `Optional` **plugins**: [`Plugin`](Plugin.md)[] diff --git a/packages/build/src/_shared/resolved-config.ts b/packages/build/src/_shared/resolved-config.ts index f24b9ce4..322b5ff4 100644 --- a/packages/build/src/_shared/resolved-config.ts +++ b/packages/build/src/_shared/resolved-config.ts @@ -49,6 +49,12 @@ export interface ResolvedConfig { */ genFormat: 'js' | 'c' + /** + * The absolute path to the JSON file that will be written by the build process that + * lists all dimensions and variables in the model. + */ + outListingFile?: string + /** * The path to the `@sdeverywhere/cli` package. This is currently only used to get * access to the files in the `src/c` directory. diff --git a/packages/build/src/build/impl/gen-model.ts b/packages/build/src/build/impl/gen-model.ts index 43ef8432..b8178d42 100644 --- a/packages/build/src/build/impl/gen-model.ts +++ b/packages/build/src/build/impl/gen-model.ts @@ -1,7 +1,7 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund import { copyFile, readdir, readFile, writeFile } from 'fs/promises' -import { join as joinPath } from 'path' +import { basename, dirname, join as joinPath } from 'path' import { log } from '../../_shared/log' @@ -87,6 +87,17 @@ export async function generateModel(context: BuildContext, plugins: Plugin[]): P await copyFile(generatedCodePath, stagedOutputJsPath) } + if (config.outListingFile) { + // Copy the model listing file + const srcListingJsonPath = joinPath(prepDir, 'build', 'processed.json') + const stagedDir = 'model' + const stagedFile = 'listing.json' + const dstDir = dirname(config.outListingFile) + const dstFile = basename(config.outListingFile) + const stagedListingJsonPath = context.prepareStagedFile(stagedDir, stagedFile, dstDir, dstFile) + await copyFile(srcListingJsonPath, stagedListingJsonPath) + } + const t1 = performance.now() const elapsed = ((t1 - t0) / 1000).toFixed(1) log('info', `Done generating model (${elapsed}s)`) diff --git a/packages/build/src/config/config-loader.ts b/packages/build/src/config/config-loader.ts index ee4ea709..e2c07aab 100644 --- a/packages/build/src/config/config-loader.ts +++ b/packages/build/src/config/config-loader.ts @@ -1,7 +1,7 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund import { existsSync, lstatSync, mkdirSync } from 'fs' -import { dirname, join as joinPath, relative, resolve as resolvePath } from 'path' +import { dirname, isAbsolute, join as joinPath, relative, resolve as resolvePath } from 'path' import { fileURLToPath } from 'url' import type { Result } from 'neverthrow' @@ -171,6 +171,17 @@ function resolveUserConfig( throw new Error(`The configured genFormat value is invalid; must be either 'js' or 'c'`) } + // Validate the out listing file, if defined + let outListingFile: string + if (userConfig.outListingFile) { + // Get the absolute path of the output file + if (isAbsolute(userConfig.outListingFile)) { + outListingFile = userConfig.outListingFile + } else { + outListingFile = resolvePath(rootDir, userConfig.outListingFile) + } + } + return { mode, rootDir, @@ -179,6 +190,7 @@ function resolveUserConfig( modelInputPaths, watchPaths, genFormat, + outListingFile, sdeDir, sdeCmdPath } diff --git a/packages/build/src/config/user-config.ts b/packages/build/src/config/user-config.ts index 1d9d12b5..4a4c3fe9 100644 --- a/packages/build/src/config/user-config.ts +++ b/packages/build/src/config/user-config.ts @@ -48,6 +48,13 @@ export interface UserConfig { */ genFormat?: 'js' | 'c' + /** + * If defined, the build process will write a JSON file to the provided path that lists + * all dimensions and variables in the model. This can be an absolute path, or if it + * is a relative path it will be resolved relative to the `rootDir` for the project. + */ + outListingFile?: string + /** * The array of plugins that are used to customize the build process. These will be * executed in the order defined here. diff --git a/packages/build/tests/build/build-dev.spec.ts b/packages/build/tests/build-dev/build-dev.spec.ts similarity index 76% rename from packages/build/tests/build/build-dev.spec.ts rename to packages/build/tests/build-dev/build-dev.spec.ts index 229b744f..c86d52cd 100644 --- a/packages/build/tests/build/build-dev.spec.ts +++ b/packages/build/tests/build-dev/build-dev.spec.ts @@ -1,13 +1,15 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund -import { resolve as resolvePath } from 'path' +import { rmSync } from 'node:fs' +import { resolve as resolvePath } from 'node:path' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { ModelSpec, UserConfig } from '../../src' import { build } from '../../src' import { buildOptions } from '../_shared/build-options' +import {} from 'vitest' const modelSpec: ModelSpec = { inputs: [{ varName: 'Y', defaultValue: 0, minValue: -10, maxValue: 10 }], @@ -16,6 +18,11 @@ const modelSpec: ModelSpec = { } describe('build in development mode', () => { + beforeEach(() => { + const prepDir = resolvePath(__dirname, 'sde-prep') + rmSync(prepDir, { recursive: true, force: true }) + }) + it('should return undefined exit code', async () => { const userConfig: UserConfig = { rootDir: resolvePath(__dirname, '..'), diff --git a/packages/build/tests/build/build-prod.spec.ts b/packages/build/tests/build-prod/build-prod.spec.ts similarity index 79% rename from packages/build/tests/build/build-prod.spec.ts rename to packages/build/tests/build-prod/build-prod.spec.ts index 00ebbcfb..9727e44b 100644 --- a/packages/build/tests/build/build-prod.spec.ts +++ b/packages/build/tests/build-prod/build-prod.spec.ts @@ -1,8 +1,9 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund -import { resolve as resolvePath } from 'path' +import { existsSync, rmSync } from 'node:fs' +import { join as joinPath, resolve as resolvePath } from 'node:path' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import type { ModelSpec, Plugin, UserConfig } from '../../src' import { build } from '../../src' @@ -56,6 +57,59 @@ const plugin = (num: number, calls: string[]) => { } describe('build in production mode', () => { + beforeEach(() => { + const prepDir = resolvePath(__dirname, 'sde-prep') + rmSync(prepDir, { recursive: true, force: true }) + + const outputsDir = resolvePath(__dirname, 'outputs') + rmSync(outputsDir, { recursive: true, force: true }) + }) + + it('should write listing.json file (when absolute path is provided)', async () => { + const userConfig: UserConfig = { + genFormat: 'c', + rootDir: resolvePath(__dirname, '..'), + prepDir: resolvePath(__dirname, 'sde-prep'), + modelFiles: [resolvePath(__dirname, '..', '_shared', 'sample.mdl')], + // Note that `outListingFile` is specified with an absolute path here + outListingFile: resolvePath(__dirname, 'outputs', 'listing.json'), + modelSpec: async () => { + return modelSpec + } + } + + const result = await build('production', buildOptions(userConfig)) + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + + expect(result.value.exitCode).toBe(0) + expect(existsSync(resolvePath(__dirname, 'outputs', 'listing.json'))).toBe(true) + }) + + it('should write listing.json file (when relative path is provided)', async () => { + const userConfig: UserConfig = { + genFormat: 'c', + rootDir: resolvePath(__dirname, '..'), + prepDir: resolvePath(__dirname, 'sde-prep'), + modelFiles: [resolvePath(__dirname, '..', '_shared', 'sample.mdl')], + // Note that `outListingFile` is specified with a relative path here, which + // will be resolved relative to `rootDir` + outListingFile: joinPath('build-prod', 'outputs', 'listing.json'), + modelSpec: async () => { + return modelSpec + } + } + + const result = await build('production', buildOptions(userConfig)) + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + + expect(result.value.exitCode).toBe(0) + expect(existsSync(resolvePath(__dirname, 'outputs', 'listing.json'))).toBe(true) + }) + it('should skip certain callbacks if model files array is empty', async () => { const calls: string[] = [] diff --git a/packages/build/tests/config/config.spec.ts b/packages/build/tests/config/config.spec.ts index ec1f5c85..8107e719 100644 --- a/packages/build/tests/config/config.spec.ts +++ b/packages/build/tests/config/config.spec.ts @@ -1,14 +1,20 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund -import { join as joinPath } from 'path' +import { rmSync } from 'node:fs' +import { join as joinPath } from 'node:path' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { build } from '../../src' import { buildOptions } from '../_shared/build-options' describe('build config file loading', () => { + beforeEach(() => { + const prepDir = joinPath(__dirname, 'sde-prep') + rmSync(prepDir, { recursive: true, force: true }) + }) + it('should fail if config file cannot be found', async () => { const configPath = joinPath(__dirname, 'sde.unknown.js') const result = await build('production', buildOptions(configPath))