diff --git a/docs/diagrams/sasjs-compile.md b/docs/diagrams/sasjs-compile.md index 68af4ae58..b62bdac9e 100644 --- a/docs/diagrams/sasjs-compile.md +++ b/docs/diagrams/sasjs-compile.md @@ -40,15 +40,40 @@ flowchart TD H -->|no| J["compileFile(target, filePath, macroFolders, programFolders, ..., fileType, sourceFolder)\n→ loadDependencies() resolves %macro/[include] headers, writes resolved SAS in place"] ``` +## `compileTestFile` — destination path resolution + +```mermaid +flowchart TD + A["compileTestFile(target, filePath, testVar, saveToRoot, removeOriginalFile, compileTree, destinationPath?) — compileTestFile.ts:68"] --> B["loadDependencies(target, absolutePath(filePath), macroFolders, programFolders, SASJsFileType.test, compileTree)\n→ resolves %macro/[include] headers into final SAS content"] + B --> C["dependencies = (testVar ? testVar + newline : '') + newline + dependencies"] + C --> D{"destinationPath explicitly given?"} + D -->|yes| F["use it as-is"] + D -->|no| E["getTestFileDestinationFragment(filePath, buildDestinationFolderName, saveToRoot) — compileTestFile.ts:44\n(see subgraph below)"] + E --> F2["destinationPath = join(buildDestinationTestFolder, fragment)"] + F --> G["createFile(destinationPath, dependencies)"] + F2 --> G + G --> H{"removeOriginalFile?"} + H -->|yes| I["deleteFile(filePath) — source removed"] + H -->|no| J["source left in place"] +``` + +```mermaid +flowchart TD + A["getTestFileDestinationFragment(filePath, buildDestinationFolderName, saveToRoot)"] --> B["segments = filePath.split(/[\\\\/]/) — splits on both '/' and '\\'\nregardless of host OS or which separator the input string uses"] + B --> C{"saveToRoot?"} + C -->|true, e.g. testSetUp/testTearDown| D["return segments.pop() || ''\n→ just the file's basename, flattened to build-test-folder root"] + C -->|false, e.g. service/job test files| E["reduce over segments: drop everything up to and\nincluding buildDestinationFolderName, keep the remainder,\njoin with path.sep\n→ preserves the relative path fragment under services/jobs"] +``` + ## `compileTestFlow` — building `testFlow.json` + coverage ```mermaid flowchart TD - A["compileTestFlow(target) — compileTestFile.ts:98"] --> B{"sasjsbuild/tests folder exists?"} + A["compileTestFlow(target) — compileTestFile.ts:130"] --> B{"sasjsbuild/tests folder exists?"} B -->|no| Z["return undefined"] B -->|yes| C["testFiles = listFilesAndSubFoldersInFolder(buildDestinationTestFolder)\nprefixed with 'tests/'"] C --> D{"testSetUp / testTearDown configured\n(target.testConfig or root sasjsconfig.json)?"} - D --> E["match by basename against testFiles;\nremove matched entry from testFiles list,\nset testFlow.testSetUp / testFlow.testTearDown = 'tests/{basename}'"] + D --> E["match by basename against testFiles;\nremove matched entry from testFiles list,\nset testFlow.testSetUp / testFlow.testTearDown = 'tests/{basename}'\n(no match found → left unset, file stays a regular/standalone test entry)"] E --> F["testFlow.tests = remaining testFiles (posix-joined)"] F --> G["printTestCoverage(testFlow, buildDestinationFolder, target)"] G --> H["collectCoverage() over sasjsbuild/services, sasjsbuild/jobs,\nand each macro folder (excluding *.test.sas)"] @@ -66,14 +91,17 @@ flowchart TD service/job folders are compiled with their relative path preserved under `sasjsbuild/tests/{services|jobs}/...` (`saveToRoot = false`). Both paths go through `getTestFileDestinationFragment(filePath, buildDestinationFolderName, saveToRoot)` - in `compileTestFile.ts`. + in `compileTestFile.ts`, which splits on both `/` and `\` so the result doesn't + depend on which separator style the input path happens to use. - Macro test files (`*.test.sas` under any macro folder) are handled separately via - `copyTestMacroFiles()` (copy into `sasjsbuild/tests/macros`) + a second `compileFile()` - pass to resolve their dependencies - this happens after `compileJobsServicesTests`, - not inside it. + `copyTestMacroFiles()` (copy into `sasjsbuild/tests/macros`, skipping files that + already exist at the destination) + a second `compileFile()` pass to resolve their + dependencies - this happens after `compileJobsServicesTests`, not inside it. - `compileTestFlow` only ever *reads* whatever already landed in `sasjsbuild/tests` - it doesn't compile anything itself, it just assembles - `testFlow.json` and prints/writes coverage. + `testFlow.json` and prints/writes coverage. If a configured `testSetUp`/ + `testTearDown` doesn't match any actual compiled file by basename, it's silently + left unset and the corresponding file (if any) is treated as a regular test. - Dependency resolution (`%macro`/program includes) for every non-test file goes through `loadDependencies()` → `loadDependenciesFile()` (from `@sasjs/utils`), which is also memoized per-file via the `compileTree` (`{target}_compileTree.json`) to diff --git a/docs/diagrams/sasjs-doc.md b/docs/diagrams/sasjs-doc.md new file mode 100644 index 000000000..e77349abb --- /dev/null +++ b/docs/diagrams/sasjs-doc.md @@ -0,0 +1,106 @@ +# `sasjs doc` logic + +Scope: `src/commands/docs/docsCommand.ts`, `src/commands/docs/generateDocs.ts`, +`src/commands/docs/generateDot.ts`, `src/commands/docs/initDocs.ts`, +`src/commands/docs/internal/*`. Entry point is `DocsCommand.execute()` at +`docsCommand.ts:55`. + +## Top-level dispatch + +```mermaid +flowchart TD + Start(["sasjs doc [subCommand] — docsCommand.ts:55"]) --> Sub{"subCommand?"} + Sub -->|"init"| Init["executeInitDocs() → initDocs()\n(see subgraph below)"] + Sub -->|"lineage"| Dot["executeGenerateDot() → generateDot(target, config, outDirectory)\n(see subgraph below)"] + Sub -->|"(none)"| Docs["executeGenerateDocs() → generateDocs(target, config, outDirectory)\n(see subgraph below)"] + Init --> Result["log success/error, return ReturnCode.Success | InternalError"] + Dot --> Result + Docs --> Result +``` + +## `initDocs()` — scaffold the doxy folder + +```mermaid +flowchart TD + A["initDocs() — initDocs.ts:6"] --> B["setupDoxygen('.') — utils.ts\ncopy() the packaged src/doxy template\n(Doxyfile, DoxygenLayout.xml, favicon.ico, logo.png,\nnew_footer.html, new_header.html, new_stylesheet.css)\ninto {projectDir}/sasjs/doxy"] +``` + +## `generateDocs()` — the default subcommand + +```mermaid +flowchart TD + A["generateDocs(target?, config?, outDirectory?) — generateDocs.ts:32"] --> B["getDocConfig(target, config, outDirectory)\nresolves: newOutDirectory (target > config > default),\nserverUrl (for lineage links), enableLineage (target > config > true),\ndoxyContent (config merged with target override)"] + B --> C["getFoldersForDocs(target, config)\ncollects macroCore/macro/program/service/job folders,\ntarget-level entries appended to root-level entries\n(macroCore uses target's displayMacroCore only if explicitly set there)"] + C --> D{"combinedFolders empty?"} + D -->|yes| E["throw: 'Unable to locate folders for generating docs.'"] + D -->|no| F["read {projectDir}/package.json for PROJECT_NAME / PROJECT_BRIEF\n(falls back to placeholder text if missing/unparseable)"] + F --> G["merge doxyContent defaults (favIcon/footer/header/layout/logo/readMe/\nstylesheet/path=sasjs/doxy) with docConfig.doxyContent override;\nresolve doxyContent.path to absolute if overridden"] + G --> H{"enableLineage?"} + H -->|yes| I["LAYOUT_FILE = doxyContent.path/DoxygenLayout.xml, used as-is"] + H -->|no| J["read DoxygenLayout.xml, strip the Lineage entry,\nwrite a timestamped tmp copy → LAYOUT_FILE"] + I --> K + J --> K["build readMePath = join(doxyContent.path, doxyContent.readMe);\nset DOXY_INPUT (readMePath + all folders), DOXY_MAINPAGE (readMePath),\nHTML_* / PROJECT_* / LAYOUT_FILE via setVariableCmd()\n(env-var-style shell prefix, OS-specific syntax)"] + K --> L["getDoxyConfigPath(doxyContent.path)\nlooks for Doxyfile, then DoxyFile, doxyfile, doxyFile as fallbacks;\nthrows if none found"] + L --> M["(re)create newOutDirectory"] + M --> N["shelljs.exec('doxyParams doxygen configPath')\nnon-silent only when LOG_LEVEL=Debug"] + N --> O{"!enableLineage?"} + O -->|yes| P["delete the tmp LAYOUT_FILE created earlier"] + O -->|no| Q + P --> Q{"exec code !== 0?"} + Q -->|yes, stderr starts with 'error: '| R["throw '\\n' + stderr"] + Q -->|yes, other stderr| S["throw 'Doxygen application is not installed or configured...' + stderr"] + Q -->|no| T{"enableLineage?"} + T -->|yes| U["createDotFiles(service+job folders, newOutDirectory, serverUrl)\n(see createDotFiles subgraph)"] + T -->|no| V + U --> V["return { outDirectory: newOutDirectory }"] +``` + +## `generateDot()` — `sasjs doc lineage` subcommand + +```mermaid +flowchart TD + A["generateDot(target?, config?, outDirectory?) — generateDot.ts:14"] --> B["getDocConfig(target, config, outDirectory)\n→ serverUrl, newOutDirectory"] + B --> C["getFoldersForDocs(target, config)\n→ service + job folders only (no macro/program/macroCore)"] + C --> D["createDotFiles(folderList, newOutDirectory, serverUrl)\n(see subgraph below)"] + D --> E["return { outDirectory: newOutDirectory }"] +``` + +## `createDotFiles()` — lineage dot/svg generation + +```mermaid +flowchart TD + A["createDotFiles(folderList, outDirectory, serverUrl) — createDotFiles.ts:14"] --> B["createFolder(outDirectory)"] + B --> C["getDotFileContent(folderList, serverUrl)\nparses each service/job file's Input/Output header annotations\ninto DOT graph nodes/edges (libs, tables, jobs), serverUrl\nprefixes node links to the Data Controller viewer if configured"] + C --> D["createFile(outDirectory/data_lineage.dot, dotFileContent)"] + D --> E["graphviz.dot(dotFileContent, 'svg') — node-graphviz"] + E --> F{"graphviz succeeds?"} + F -->|yes| G["createFile(outDirectory/data_lineage.svg, svg)"] + F -->|no| H["throw 'Unable to generate graph from generated Dot file.' + error"] +``` + +## Key facts + +- `getDocConfig` establishes precedence rules used throughout: target-level + `docConfig` wins over root `sasjsconfig.json` `docConfig`, which wins over + built-in defaults. `enableLineage` defaults to `true` if neither specifies it. +- `getFoldersForDocs` behaves differently for the two generators: + `generateDocs` includes macro/macroCore/program folders in addition to + service/job folders (everything Doxygen needs to scan); `generateDot` only + needs service/job folders (the only place lineage-relevant Input/Output + annotations live). +- Doxygen itself is invoked as an external shell command (`doxygen`) - the whole + `generateDocs` flow assumes it's installed and on `PATH`; a non-zero exit code + is the only failure signal available, distinguished only by whether `stderr` + happens to start with `error: ` (a specific Doxygen-side config error) versus + anything else (treated as "not installed/configured"). +- `enableLineage` controls two independent things: whether the Lineage tab + appears in the generated Doxygen layout (via a stripped tmp copy of + `DoxygenLayout.xml`), and whether `createDotFiles` runs afterward to produce + `data_lineage.dot`/`.svg`. Both are gated by the same flag but are otherwise + unrelated code paths. +- `getDoxyConfigPath` tries four case variants of the config filename + (`Doxyfile`, `DoxyFile`, `doxyfile`, `doxyFile`) in that order and uses + whichever is found first; it throws only if none exist. +- `initDocs` is purely a file-copy operation - it doesn't read or validate + any existing project state, so re-running it resets `sasjs/doxy/` back to + the packaged template. diff --git a/src/commands/compile/internal/compileTestFile.ts b/src/commands/compile/internal/compileTestFile.ts index e346b26de..76cfae0c2 100644 --- a/src/commands/compile/internal/compileTestFile.ts +++ b/src/commands/compile/internal/compileTestFile.ts @@ -24,6 +24,47 @@ import { loadDependencies } from './' const getFileName = (filePath: string) => path.parse(filePath).base +/** + * Resolves the relative destination fragment for a compiled test file inside + * `sasjsbuild/tests` - either just the file's basename (saveToRoot, used for + * testSetUp/testTearDown), or the path fragment following the build + * destination folder name (used for service/job test files). + * + * `filePath` for testSetUp/testTearDown comes straight from a user's + * sasjsconfig.json, which conventionally uses forward slashes for + * portability - it is not guaranteed to match the current OS's separator. + * Splitting on both `/` and `\` (instead of only `path.sep`) means this + * works whether the config was authored with POSIX-style paths and the CLI + * is running on Windows (`path.sep === '\\'`) or vice versa. Splitting on + * `path.sep` alone silently no-ops when the string contains none of that + * separator, leaving the whole config path intact and producing a nested + * `sasjsbuild/tests/sasjs/tests/testsetup.sas` instead of a flat + * `sasjsbuild/tests/testsetup.sas`. + */ +export const getTestFileDestinationFragment = ( + filePath: string, + buildDestinationFolderName: string | undefined, + saveToRoot: boolean +): string => { + const segments = filePath.split(/[\\/]/) + + const destinationFragment = saveToRoot + ? segments.pop() || '' + : segments + .reduce( + (acc: string[], item: string, i: number, arr: string[]) => + acc.length + ? [...acc, item] + : arr[i - 1] === buildDestinationFolderName + ? [...acc, item] + : acc, + [] as string[] + ) + .join(path.sep) + + return destinationFragment +} + export async function compileTestFile( target: Target, filePath: string, @@ -55,20 +96,11 @@ export async function compileTestFile( destinationPath || path.join( buildDestinationTestFolder, - saveToRoot - ? filePath.split(path.sep).pop() || '' - : filePath - .split(path.sep) - .reduce( - (acc: any, item: any, i: any, arr: any) => - acc.length - ? [...acc, item] - : arr[i - 1] === buildDestinationFolderName - ? [...acc, item] - : acc, - [] - ) - .join(path.sep) + getTestFileDestinationFragment( + filePath, + buildDestinationFolderName, + saveToRoot + ) ) await createFile(destinationPath, dependencies) diff --git a/src/commands/compile/internal/spec/compileTestFile.spec.ts b/src/commands/compile/internal/spec/compileTestFile.spec.ts index 1cbd10077..4b73193da 100644 --- a/src/commands/compile/internal/spec/compileTestFile.spec.ts +++ b/src/commands/compile/internal/spec/compileTestFile.spec.ts @@ -1,4 +1,10 @@ -import { compileTestFlow } from '../' +import { + compileTestFile, + compileTestFlow, + copyTestMacroFiles, + getCompileTree, + getTestFileDestinationFragment +} from '../' import { Logger, LogLevel, @@ -8,9 +14,9 @@ import { fileExists, generateTimestamp, deleteFolder, + createFolder, ServerType, - isTestFile, - Configuration + isTestFile } from '@sasjs/utils' import { removeTestApp, @@ -21,8 +27,65 @@ import path from 'path' import { compile } from '../../compile' import chalk from 'chalk' +describe('getTestFileDestinationFragment', () => { + // sasjsconfig.json testConfig paths (testSetUp/testTearDown) are conventionally + // forward-slash, regardless of the OS the CLI is running on - these tests pin + // that a Windows-style host separator doesn't break the flattening/reduce logic + // when the config value itself uses forward slashes (see PLAN-windows-testsetup-path.md). + describe('saveToRoot = true (testSetUp/testTearDown)', () => { + it('flattens a forward-slash config path to its basename', () => { + expect( + getTestFileDestinationFragment( + 'sasjs/tests/testsetup.sas', + 'tests', + true + ) + ).toEqual('testsetup.sas') + }) + + it('flattens a backslash (Windows-native) path to its basename', () => { + expect( + getTestFileDestinationFragment( + 'sasjs\\tests\\testsetup.sas', + 'tests', + true + ) + ).toEqual('testsetup.sas') + }) + + it('returns an empty string for a path ending in a separator', () => { + expect( + getTestFileDestinationFragment('sasjs/tests/', 'tests', true) + ).toEqual('') + }) + }) + + describe('saveToRoot = false (service/job test files)', () => { + it('keeps the path fragment after the build destination folder name, for a forward-slash path', () => { + expect( + getTestFileDestinationFragment( + 'sasjsbuild/tests/services/admin/random.test.sas', + 'tests', + false + ) + ).toEqual(['services', 'admin', 'random.test.sas'].join(path.sep)) + }) + + it('keeps the path fragment after the build destination folder name, for a backslash path', () => { + expect( + getTestFileDestinationFragment( + 'sasjsbuild\\tests\\services\\admin\\random.test.sas', + 'tests', + false + ) + ).toEqual(['services', 'admin', 'random.test.sas'].join(path.sep)) + }) + }) +}) + describe('compileTestFile', () => { const appName: string = `cli-tests-compile-test-file-${generateTimestamp()}` + const temp: Target = generateTestTarget( appName, '/Public/app', @@ -41,9 +104,11 @@ describe('compileTestFile', () => { }, macroFolders: ['sasjs/macros'] }) + let sasjsPath: string let testBody: string let buildPath: string + const testFileName = 'random.test.sas' beforeAll(async () => { @@ -119,6 +184,61 @@ describe('compileTestFile', () => { await testContent(path.join('testteardown.sas')) await testContent(path.join('macros', 'testMacro.test.sas')) }) + + it('should use default testVar/saveToRoot when omitted', async () => { + await compile(target) + + const compileTree = getCompileTree(target) + + await expect( + compileTestFile( + target, + target.testConfig!.testSetUp, + undefined, + undefined, + false, + compileTree + ) + ).toResolve() + }) + + it('should prepend a non-empty testVar to the compiled test file', async () => { + await compile(target) + + const compileTree = getCompileTree(target) + const testVar = '%let some_var=some_value;' + + await compileTestFile( + target, + target.testConfig!.testSetUp, + testVar, + true, + false, + compileTree + ) + + const compiledTestSetUpPath = path.join( + buildPath, + 'tests', + 'testsetup.sas' + ) + const compiledContent = await readFile(compiledTestSetUpPath) + + expect(compiledContent.indexOf(testVar)).toEqual(0) + }) + + it('should skip copying a macro test file that is already present in the build folder', async () => { + await compile(target) + + const macroFolderAbsolutePath = path.join( + __dirname, + appName, + 'sasjs', + 'macros' + ) + + await expect(copyTestMacroFiles(macroFolderAbsolutePath)).toResolve() + }) }) describe('compileTestFlow', () => { @@ -154,6 +274,38 @@ describe('compileTestFile', () => { ) }) + it('should return no tests when the tests folder is empty', async () => { + await compile(target) + + const testsFolderPath = path.join(buildPath, 'tests') + await deleteFolder(testsFolderPath) + await createFolder(testsFolderPath) + + const testFlow = await compileTestFlow(target) + + expect(testFlow!.tests).toEqual([]) + expect(testFlow!.testSetUp).toBeUndefined() + expect(testFlow!.testTearDown).toBeUndefined() + }) + + it('should not set testSetUp/testTearDown when the configured file is not among the compiled tests', async () => { + await compile(target) + + const targetWithBogusSetup = new Target({ + ...target.toJson(), + testConfig: { + ...target.testConfig!, + testSetUp: 'does-not-exist-setup.sas', + testTearDown: 'does-not-exist-teardown.sas' + } + }) + + const testFlow = await compileTestFlow(targetWithBogusSetup) + + expect(testFlow!.testSetUp).toBeUndefined() + expect(testFlow!.testTearDown).toBeUndefined() + }) + it('should log coverage', async () => { jest.spyOn(process.logger, 'table').mockImplementation(() => '') diff --git a/src/commands/create/spec/create.spec.ts b/src/commands/create/spec/create.spec.ts index 0bd99c23f..50351cea6 100644 --- a/src/commands/create/spec/create.spec.ts +++ b/src/commands/create/spec/create.spec.ts @@ -36,7 +36,10 @@ describe('sasjs create', () => { }) afterEach(async () => { - await deleteFolder(path.join(__dirname, 'test-app-create-*')) + // deleteFolder() takes a literal path, not a glob - 'test-app-create-*' never + // matches anything on disk, silently leaving every test's real (timestamped) + // folder behind. Delete the actual folder each test just created instead. + if (process.projectDir) await deleteFolder(process.projectDir) }) it('should set up a default app in the current folder', async () => { diff --git a/src/commands/docs/generateDocs.ts b/src/commands/docs/generateDocs.ts index 009e482a1..9863055a1 100644 --- a/src/commands/docs/generateDocs.ts +++ b/src/commands/docs/generateDocs.ts @@ -122,12 +122,17 @@ export async function generateDocs( return tmpFilePath }) + // Doxygen matches USE_MDFILE_AS_MAINPAGE against the file's path exactly as it + // appears in INPUT - a bare name like "README.md" no longer matches an absolute + // INPUT path on newer Doxygen versions (see doxygen/doxygen#10110), which silently + // drops the README content instead of using it as the main page. Passing the same + // absolute path to both keeps them in sync regardless of Doxygen version. + const readMePath = path.join(doxyContent.path, doxyContent.readMe) + const doxyParams = setVariableCmd({ DOXY_HTML_OUTPUT: newOutDirectory, - DOXY_INPUT: `"${path.join( - doxyContent.path, - doxyContent.readMe - )}" ${combinedFolders}`, + DOXY_INPUT: `"${readMePath}" ${combinedFolders}`, + DOXY_MAINPAGE: readMePath, HTML_EXTRA_FILES: `"${path.join(doxyContent.path, doxyContent.favIcon)}"`, HTML_EXTRA_STYLESHEET: `"${path.join( doxyContent.path, diff --git a/src/commands/docs/spec/generateDocs.spec.ts b/src/commands/docs/spec/generateDocs.spec.ts index a85404678..d1605c4b1 100644 --- a/src/commands/docs/spec/generateDocs.spec.ts +++ b/src/commands/docs/spec/generateDocs.spec.ts @@ -1,4 +1,5 @@ import path from 'path' +import shelljs from 'shelljs' import { createTestApp, removeTestApp, @@ -11,9 +12,12 @@ import { copy, deleteFolder, deleteFile, + readFile, + createFile, DocConfig, generateTimestamp, Target, + ServerType, Configuration } from '@sasjs/utils' import { generateDocs } from '../generateDocs' @@ -309,4 +313,69 @@ describe('sasjs doc', () => { await verifyDocs(docOutputDefault, 'no-target') await verifyDotFiles(docOutputDefault) }) + + it('should throw when no macro/program/service/job folders are configured', async () => { + const emptyTarget = new Target({ + name: 'no-folders', + appLoc: '/Public/test/', + serverType: ServerType.SasViya, + docConfig: { displayMacroCore: false } + }) + + await expect( + generateDocs(emptyTarget, { + docConfig: { displayMacroCore: false } + } as Configuration) + ).rejects.toThrow('Unable to locate folders for generating docs.') + }) + + it('should not toggle the spinner when LOG_LEVEL is Debug', async () => { + const originalLogLevel = process.env.LOG_LEVEL + process.env.LOG_LEVEL = 'Debug' + + await expect(generateDocs(defaultTarget, defaultConfig)).resolves.toEqual({ + outDirectory: docOutputDefault + }) + + process.env.LOG_LEVEL = originalLogLevel + }) + + it('should fall back to the default project name when package.json has none', async () => { + const packageJsonPath = path.join(__dirname, appName, 'package.json') + const originalPackageJson = await readFile(packageJsonPath) + const packageJson = JSON.parse(originalPackageJson) + delete packageJson.name + + await createFile(packageJsonPath, JSON.stringify(packageJson)) + + await expect(generateDocs(defaultTarget, defaultConfig)).resolves.toEqual({ + outDirectory: docOutputDefault + }) + + await createFile(packageJsonPath, originalPackageJson) + }) + + it('should throw a Doxygen-specific error when the shell command fails with an "error: " stderr', async () => { + jest.spyOn(shelljs, 'exec').mockReturnValueOnce({ + code: 1, + stderr: 'error: bad config', + stdout: '' + } as any) + + await expect(generateDocs(defaultTarget, defaultConfig)).rejects.toThrow( + '\nerror: bad config' + ) + }) + + it('should throw a generic Doxygen-not-installed error for any other shell failure', async () => { + jest.spyOn(shelljs, 'exec').mockReturnValueOnce({ + code: 1, + stderr: 'command not found', + stdout: '' + } as any) + + await expect(generateDocs(defaultTarget, defaultConfig)).rejects.toThrow( + `The Doxygen application is not installed or configured.` + ) + }) }) diff --git a/src/commands/init/spec/init.spec.ts b/src/commands/init/spec/init.spec.ts index a9cf8abcd..e70da4770 100644 --- a/src/commands/init/spec/init.spec.ts +++ b/src/commands/init/spec/init.spec.ts @@ -18,7 +18,10 @@ describe('sasjs init', () => { }) afterEach(async () => { - await deleteFolder(path.join(__dirname, 'test-app-init-*')) + // deleteFolder() takes a literal path, not a glob - 'test-app-init-*' never + // matches anything on disk, silently leaving every test's real (timestamped) + // folder behind. Delete the actual folder each test just created instead. + if (process.projectDir) await deleteFolder(process.projectDir) }) it('should initialise with default app in the current folder', async () => { diff --git a/src/commands/job/spec/jobCommand.spec.ts b/src/commands/job/spec/jobCommand.spec.ts index 1e7de820f..551711d03 100644 --- a/src/commands/job/spec/jobCommand.spec.ts +++ b/src/commands/job/spec/jobCommand.spec.ts @@ -255,6 +255,16 @@ describe('JobCommand', () => { expect(sasjs.setVerboseMode).not.toHaveBeenCalled() }) + + it('should log deprecation warning if returnStatusOnly flag is present', async () => { + jest.spyOn(process.logger, 'warn') + + await executeCommandWrapper([jobPath, '--returnStatusOnly']) + + expect(process.logger.warn).toHaveBeenCalledWith( + '--returnStatusOnly (-r) flag is deprecated.' + ) + }) }) describe('for server type sas9', () => { @@ -360,16 +370,6 @@ describe('JobCommand', () => { expect(returnCode).toEqual(ReturnCode.Success) }) }) - - it('should log deprecation warning if returnStatusOnly flag is present', async () => { - jest.spyOn(process.logger, 'warn') - - await executeCommandWrapper([jobPath, '--returnStatusOnly']) - - expect(process.logger.warn).toHaveBeenCalledWith( - '--returnStatusOnly (-r) flag is deprecated.' - ) - }) }) const setupMocksForViya = () => { diff --git a/src/doxy/Doxyfile b/src/doxy/Doxyfile index 45ad8a41c..5301479d7 100644 --- a/src/doxy/Doxyfile +++ b/src/doxy/Doxyfile @@ -22,7 +22,7 @@ INHERIT_DOCS = NO INLINE_INFO = NO INPUT = $(DOXY_INPUT) LAYOUT_FILE = $(LAYOUT_FILE) -USE_MDFILE_AS_MAINPAGE = README.md +USE_MDFILE_AS_MAINPAGE = $(DOXY_MAINPAGE) MAX_INITIALIZER_LINES = 0 PROJECT_NAME = $(PROJECT_NAME) PROJECT_LOGO = $(PROJECT_LOGO) diff --git a/src/types/command/spec/targetCommand.spec.ts b/src/types/command/spec/targetCommand.spec.ts new file mode 100644 index 000000000..44986dc3b --- /dev/null +++ b/src/types/command/spec/targetCommand.spec.ts @@ -0,0 +1,54 @@ +import { Logger, LogLevel } from '@sasjs/utils' +import * as configUtils from '../../../utils/config' +import * as loadEnvVariablesModule from '../../../utils/loadEnvVariables' +import { mockProcessExit } from '../../../utils/test' +import { TargetCommand } from '../targetCommand' +import { ReturnCode } from '../returnCode' + +describe('TargetCommand.getTargetInfo', () => { + beforeEach(() => { + process.logger = new Logger(LogLevel.Off) + jest.spyOn(process.logger, 'error') + }) + + it('exits with an internal error when target env variables fail to load', async () => { + const processExitSpy = mockProcessExit() + jest + .spyOn(loadEnvVariablesModule, 'loadTargetEnvVariables') + .mockImplementation(() => Promise.reject(new Error('env file missing'))) + + const command = new TargetCommand(['node', 'sasjs', 'job'], { + strict: false + }) + + await command.getTargetInfo() + + expect(process.logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error loading environment variables'), + expect.any(Error) + ) + expect(processExitSpy).toHaveBeenCalledWith(ReturnCode.InternalError) + }) + + it('exits with an internal error when the target cannot be found in configuration', async () => { + const processExitSpy = mockProcessExit() + jest + .spyOn(loadEnvVariablesModule, 'loadTargetEnvVariables') + .mockImplementation(() => Promise.resolve()) + jest + .spyOn(configUtils, 'findTargetInConfiguration') + .mockImplementation(() => Promise.reject(new Error('target not found'))) + + const command = new TargetCommand(['node', 'sasjs', 'job'], { + strict: false + }) + + await command.getTargetInfo() + + expect(process.logger.error).toHaveBeenCalledWith( + 'Error reading target from configuration: ', + expect.any(Error) + ) + expect(processExitSpy).toHaveBeenCalledWith(ReturnCode.InternalError) + }) +}) diff --git a/src/utils/spec/utils.spec.ts b/src/utils/spec/utils.spec.ts index dbc8568a6..660860319 100644 --- a/src/utils/spec/utils.spec.ts +++ b/src/utils/spec/utils.spec.ts @@ -9,9 +9,17 @@ import { getAdapterInstance, displaySasjsRunnerError, loadEnvVariables, - createReactApp + createReactApp, + prefixAppLoc, + isSASjsProject, + getNodeModulePath, + getUniqServicesObj, + convertToSASStatements, + terminateProcess, + isSasJsServerInServerMode } from '../utils' import { mockProcessExit } from '../test' +import axios from 'axios' import { createFile, deleteFile, @@ -183,6 +191,20 @@ describe('utils', () => { await deleteFile(gitFilePath) }) + + it('should populate all default rules when an existing .gitignore file is empty', async () => { + await createFile(gitFilePath, '') + + await expect(setupGitIgnore(folderPath)).toResolve() + + const gitIgnoreContent = await readFile(gitFilePath) + + expect(gitIgnoreContent).toEqual( + 'node_modules\nsasjsbuild\nsasjsresults\n.env*\n' + ) + + await deleteFile(gitFilePath) + }) }) describe('chunk', () => { @@ -351,4 +373,154 @@ describe('utils', () => { await deleteFolder(reactFolderPath) }, 600000) }) + + describe('prefixAppLoc', () => { + it('should return null when path is falsy', () => { + expect(prefixAppLoc('/my/app', '')).toEqual(null) + }) + + it('should prepend a leading slash to appLoc if missing', () => { + expect(prefixAppLoc('my/app', 'program1')).toEqual('/my/app/program1') + }) + + it('should keep an already-absolute path unchanged', () => { + expect(prefixAppLoc('/my/app', '/absolute/path')).toEqual( + '/absolute/path' + ) + }) + + it('should join an array path before prefixing each segment', () => { + expect(prefixAppLoc('/my/app', ['program1', 'program2'] as any)).toEqual( + '/my/app/program1 /my/app/program2' + ) + }) + }) + + describe('isSASjsProject', () => { + it('should return true when sasjs/sasjsconfig.json exists under projectDir', async () => { + const projectDir = path.join( + __dirname, + `is-sasjs-project-${generateTimestamp()}` + ) + const originalProjectDir = process.projectDir + + await createFolder(path.join(projectDir, 'sasjs')) + await createFile(path.join(projectDir, 'sasjs', 'sasjsconfig.json'), '{}') + process.projectDir = projectDir + + await expect(isSASjsProject()).resolves.toEqual(true) + + process.projectDir = originalProjectDir + await deleteFolder(projectDir) + }) + + it('should return false when no sasjsconfig.json is found in any parent folder', async () => { + const projectDir = path.join( + require('os').tmpdir(), + `not-a-sasjs-project-${generateTimestamp()}` + ) + const originalProjectDir = process.projectDir + + await createFolder(projectDir) + process.projectDir = projectDir + + await expect(isSASjsProject()).resolves.toEqual(false) + + process.projectDir = originalProjectDir + await deleteFolder(projectDir) + }) + }) + + describe('getNodeModulePath', () => { + it('should return the folder path of an installed module', async () => { + const modulePath = await getNodeModulePath('@sasjs/utils') + + expect(modulePath).not.toEqual('') + expect(modulePath.endsWith(path.join('@sasjs', 'utils'))).toEqual(true) + }) + + it('should return an empty string when the module cannot be found', async () => { + await expect( + getNodeModulePath('sasjs-nonexistent-module-xyz') + ).resolves.toEqual('') + }) + }) + + describe('getUniqServicesObj', () => { + it('should return an empty object when services is falsy', () => { + expect(getUniqServicesObj(undefined as any)).toEqual({}) + }) + + it('should key services by their basename, keeping the first of any duplicates', () => { + expect( + getUniqServicesObj(['a/b/service1', 'x/service2', 'y/service1']) + ).toEqual({ + service1: 'a/b/service1', + service2: 'x/service2' + }) + }) + }) + + describe('convertToSASStatements', () => { + it('should convert macroVars to %let statements', () => { + expect( + convertToSASStatements({ + macroVars: { var1: 'val1', var2: 'val2' } + }) + ).toEqual('%let var1=val1;\n%let var2=val2;\n') + }) + + it('should return an empty string when there are no macroVars', () => { + expect(convertToSASStatements({ macroVars: {} })).toEqual('') + }) + }) + + describe('terminateProcess', () => { + it('should exit the process with the given status code', () => { + const mockExit = mockProcessExit() + + terminateProcess(2) + + expect(mockExit).toHaveBeenCalledWith(2) + }) + }) + + describe('isSasJsServerInServerMode', () => { + const target = new Target({ + name: 'test', + appLoc: '/Public/test/', + serverType: ServerType.Sasjs, + serverUrl: 'https://sasjsserver.com' + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should return true when the server reports server mode', async () => { + jest + .spyOn(axios, 'get') + .mockImplementation(() => Promise.resolve({ data: { mode: 'server' } })) + + await expect(isSasJsServerInServerMode(target)).resolves.toEqual(true) + }) + + it('should return false when the server does not report server mode', async () => { + jest + .spyOn(axios, 'get') + .mockImplementation(() => + Promise.resolve({ data: { mode: 'desktop' } }) + ) + + await expect(isSasJsServerInServerMode(target)).resolves.toEqual(false) + }) + + it('should throw when the server info request fails', async () => { + jest.spyOn(axios, 'get').mockImplementation(() => Promise.reject()) + + await expect(isSasJsServerInServerMode(target)).rejects.toThrow( + `An error occurred while fetching server info from ${target.serverUrl}/SASjsApi/info` + ) + }) + }) }) diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 20dc0ebb7..ac8f7e37b 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -7,9 +7,9 @@ import { fileExists, folderExists, createFile, + deleteFile, readFile, copy, - LogLevel, Target, isWindows, isLinux, @@ -90,11 +90,16 @@ export async function createMinimalApp(folderPath: string): Promise { export async function createTemplateApp(folderPath: string, template: string) { return new Promise(async (resolve, reject) => { - const { stdout, stderr, code } = downloadFile( + // This is only a repo-existence probe - its content is never read, only + // stderr/code are checked - so the downloaded file is discarded immediately. + const probeFile = 'response.txt' + const { stderr, code } = downloadFile( `https://username:password@github.com/sasjs/template_${template}`, - 'response.txt' + probeFile ) + await deleteFile(probeFile).catch(() => {}) + if (stderr.includes('404: Not Found') || code) { return reject(new Error(`Template "${template}" is not a SASjs template`)) } @@ -107,6 +112,7 @@ export async function createTemplateApp(folderPath: string, template: string) { return reject(new Error(err)) } ) + return resolve() }) } @@ -214,7 +220,13 @@ const loadDocsSubmodule = async ( function downloadFile(url: string, filename?: string): ShellString { if (isLinux()) { - return shelljs.exec(`wget ${url}`, { silent: true }) + // -O writes to the given name; without it, wget defaults to the + // remote URL's basename, which silently ignores the caller's `filename` and + // can leave an unexpectedly-named file behind (e.g. a repo-existence probe + // meant to be discarded as `response.txt` instead landing as the repo name). + return shelljs.exec(`wget ${url}${filename ? ' -O ' + filename : ''}`, { + silent: true + }) } else if (isWindows()) { // First We set TLS12 & then we invoke request to download file. return shelljs.exec( @@ -224,7 +236,13 @@ function downloadFile(url: string, filename?: string): ShellString { { silent: true } ) } else { - return shelljs.exec(`curl ${url} -LO -f`, { silent: true }) + // -o writes to the given name; -O (used previously) instead saves + // under the remote URL's basename, ignoring `filename` - see the wget comment + // above for the same issue. + return shelljs.exec( + `curl ${url} -L -f${filename ? ' -o ' + filename : ' -O'}`, + { silent: true } + ) } }