github-script/__test__/script-file.test.ts
Osher 67c280d263 feat: add script-file input for lint-friendly external JS scripts
Closes #714

Inline `script` is a YAML string — invisible to linters and IDEs.
The common workaround was wrapping a `require` call inside the inline script,
which still needs boilerplate and assumes a path convention.

New optional `script-file` input that accepts a path to a JS file.
The file must `module.exports` an async function receiving the standard
IoC dependency bag (`github`, `octokit`, `getOctokit`, `context`, `core`,
`exec`, `glob`, `io`, `require`).

```yaml
- uses: actions/checkout@v4
- uses: actions/github-script@v9
  with:
    script-file: .github/scripts/my-script.js
```

`script` and `script-file` are mutually exclusive — exactly one must be provided.
Relative paths resolve against `$GITHUB_WORKSPACE`; absolute paths are used as-is.

- `action.yml` — adds `script-file` input; makes `script` optional
- `src/script-file.ts` — path resolution and script loading logic
- `src/args.ts` — `AsyncFunctionArguments` extracted from `async-function.ts` so neither execution path depends on the other
- `src/main.ts` — mutual-exclusion validation; dispatches to the right execution path
- `types/non-webpack-require.ts` — corrects `__non_webpack_require__` type from deprecated `NodeRequire` / wrong `NodeJS.RequireResolve` to `NodeJS.Require`
- `__test__/script-file.test.ts` — 10 tests covering path resolution, arg forwarding, error cases
- `README.md` — new `## Script file` section with usage, IoC bag table, path resolution rules
- `.github/fixtures/script-file/` — fixture JS files for integration tests
- `.github/workflows/integration.yml` — 10 new integration test jobs: happy path (relative path, absolute path, all IoC args, json/string encoding,
  require-in-file) and error cases (both inputs set, neither set, nonexistent file, non-function export, file:// protocol)
2026-04-26 12:06:28 +03:00

133 lines
3.9 KiB
TypeScript

import * as path from 'node:path'
import * as os from 'node:os'
import * as fs from 'node:fs'
import {callScriptFile, resolveScriptFilePath} from '../src/script-file'
const fullArgs = {
github: {},
octokit: {},
getOctokit: () => null,
context: {},
core: {},
exec: {},
glob: {},
io: {},
// eslint-disable-next-line @typescript-eslint/no-require-imports
require: require,
// eslint-disable-next-line @typescript-eslint/no-require-imports
__original_require__: require
}
describe('resolveScriptFilePath', () => {
test('rejects file:// protocol', () => {
expect(() => resolveScriptFilePath('file:///some/path.js')).toThrow(
'"script-file" must not use the "file://" protocol'
)
})
test('returns absolute path as-is', () => {
const abs = '/absolute/path/to/script.js'
expect(resolveScriptFilePath(abs)).toEqual(abs)
})
test('resolves relative path against GITHUB_WORKSPACE when set', () => {
const original = process.env['GITHUB_WORKSPACE']
process.env['GITHUB_WORKSPACE'] = '/workspace'
try {
expect(resolveScriptFilePath('scripts/run.js')).toEqual(
'/workspace/scripts/run.js'
)
} finally {
if (original === undefined) {
delete process.env['GITHUB_WORKSPACE']
} else {
process.env['GITHUB_WORKSPACE'] = original
}
}
})
})
describe('callScriptFile', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'github-script-test-'))
})
afterEach(() => {
fs.rmSync(tmpDir, {recursive: true, force: true})
})
test('calls the exported function with args', async () => {
const scriptPath = path.join(tmpDir, 'script.js')
fs.writeFileSync(
scriptPath,
'module.exports = async ({core}) => core.value'
)
const result = await callScriptFile(
{...fullArgs, core: {value: 42}} as never,
scriptPath,
// eslint-disable-next-line @typescript-eslint/no-require-imports
require
)
expect(result).toEqual(42)
})
test('forwards all injected args to the exported function', async () => {
const scriptPath = path.join(tmpDir, 'all-args.js')
fs.writeFileSync(
scriptPath,
`module.exports = async (args) => Object.keys(args).sort()`
)
const result = await callScriptFile(
fullArgs as never,
scriptPath,
// eslint-disable-next-line @typescript-eslint/no-require-imports
require
)
expect(result).toEqual(Object.keys(fullArgs).sort())
})
test('throws when file does not export a function', async () => {
const scriptPath = path.join(tmpDir, 'not-a-fn.js')
fs.writeFileSync(scriptPath, 'module.exports = 42')
await expect(
// eslint-disable-next-line @typescript-eslint/no-require-imports
callScriptFile(fullArgs as never, scriptPath, require)
).rejects.toThrow('"script-file" must export a function, got number')
})
test('throws when file does not exist', async () => {
const scriptPath = path.join(tmpDir, 'nonexistent.js')
await expect(
// eslint-disable-next-line @typescript-eslint/no-require-imports
callScriptFile(fullArgs as never, scriptPath, require)
).rejects.toThrow()
})
test('propagates rejection from the exported function', async () => {
const scriptPath = path.join(tmpDir, 'throws.js')
fs.writeFileSync(
scriptPath,
"module.exports = async () => { throw new Error('boom') }"
)
await expect(
// eslint-disable-next-line @typescript-eslint/no-require-imports
callScriptFile(fullArgs as never, scriptPath, require)
).rejects.toThrow('boom')
})
test('rejects file:// path before loading', async () => {
await expect(
// eslint-disable-next-line @typescript-eslint/no-require-imports
callScriptFile(fullArgs as never, 'file:///some/path.js', require)
).rejects.toThrow('"script-file" must not use the "file://" protocol')
})
})