mirror of
https://github.com/awalsh128/cache-apt-pkgs-action.git
synced 2026-08-24 18:04:54 +00:00
stuff
This commit is contained in:
parent
c2de292527
commit
fd8f3d3fd1
6
.github/workflows/pr.yml
vendored
6
.github/workflows/pr.yml
vendored
|
|
@ -1,8 +1,10 @@
|
|||
name: Pull Requests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
# TODO Enable once v2 ready
|
||||
# pull_request:
|
||||
# types: [opened, synchronize, reopened, edited]
|
||||
|
||||
jobs:
|
||||
check-branch:
|
||||
|
|
|
|||
|
|
@ -32,8 +32,12 @@ There are three kinds of version labels you can use.
|
|||
- `@latest` - This will give you the latest release.
|
||||
- `@v#` - Major only will give you the latest release for that major version only (e.g. `v1`).
|
||||
- Branch
|
||||
- `@main-v2` - Most recent manual and automated tested code. Possibly unstable since it is pre-release.
|
||||
- `@staging-v2` - Most recent automated tested code and can sometimes contain experimental features. Is pulled from dev stable code.
|
||||
- version 1.x
|
||||
- `@master` - Most recent manual and automated tested code. Possibly unstable since it is pre-release (publishes as `1.x.x`).
|
||||
- `@staging` - Most recent automated tested code and can sometimes contain experimental features (no publish).
|
||||
- version 2.x
|
||||
- `@main-2` - Live and stable version, pre-production before action release (publishes as `2.x.x`).
|
||||
- `@staging-2` - Possibly unstable and can sometimes contain experimental features (publishes as `2.x.x-rc#`).
|
||||
|
||||
### Inputs
|
||||
|
||||
|
|
|
|||
50
V2_FAQ.MD
50
V2_FAQ.MD
|
|
@ -1,27 +1,55 @@
|
|||
# cache-apt-pkgs-action - Version 2 FAQ
|
||||
<img src="logo.png" alt="Description" height="80" align="center" style="padding-right: 1em;"> <span style="font-size: 2em; font-weight: bold;">Cache APT Packages Action v2 :: FAQ</span>
|
||||
|
||||
## Why did you create a new version
|
||||
## Why did you create a new version?
|
||||
|
||||
### Bash Scripts
|
||||
|
||||
Bash shell scripting grew in complexity and became very unreadable. The action needed higher level language idioms like array and map handling, complex pattern matching, and complex text processing.
|
||||
Bash shell scripting grew in complexity and became very unreadable. The action needed higher level language
|
||||
idioms like:
|
||||
|
||||
- arrays, map handling, complex pattern matching, and complex text processing;
|
||||
- robust logging, debugging, other instrumentations; and
|
||||
- avoiding cryptic shell and system environment considerations.
|
||||
|
||||
The lack of these
|
||||
|
||||
### Language Choice
|
||||
|
||||
Languages like Python, Go, Rust, etc. have performance and writeability trade offs that would greatly improve this action, but ultimately Typescript was chosen because:
|
||||
Languages like Python, Go, Rust, etc. have performance and writeability trade offs that would also meet these
|
||||
requirements, but ultimately Typescript was chosen because it:
|
||||
|
||||
- it is the most performant and well supported for GitHub actions;
|
||||
- native strongly types libraries for action args and actions like caching;
|
||||
- provides a balance of code comprehension for contributors and performance; and
|
||||
- allows for better integration, CI, regression testing, and release workflows.
|
||||
- is the most performant and well supported for GitHub actions;
|
||||
- has native strongly typed libraries for actions maintained by GitHub like
|
||||
- [`@actions/core`](https://github.com/actions/toolkit/tree/main/packages/core) for runtime environment, and
|
||||
- other common services like [`@actions/cache`](https://github.com/actions/toolkit/tree/main/packages/cache)
|
||||
and [`@actions/artifact`](https://github.com/actions/toolkit/tree/main/packages/artifact);
|
||||
- requires no binary compilation;
|
||||
- provides a balance of code comprehension and performance for contributors; and
|
||||
- allows for better continuous integration, release workflow, hermetic unit testing, and smoke testing.
|
||||
|
||||
## Where did all the APT functionality go?
|
||||
|
||||
Since this has been a complex part of the codebase, I decided to separate the concern as its own library. This can now be found in [npm/ts-apt](https://www.npmjs.com/package/ts-apt). For ease of use there is an option to bring in the workspace for inline source so it can be debugged and then committed to the [ts-apt](http://github.com/awalsh128/ts-apt) repository.
|
||||
Since this has been a complex part of the codebase, I decided to separate the concern as its own library. This
|
||||
can now be found at [`npm/ts-apt`](https://www.npmjs.com/package/ts-apt) with the sourcecode in the
|
||||
[`awalsh128/ts-apt`](http://github.com/awalsh128/ts-apt) repository.
|
||||
|
||||
For ease of use there is an option to bring in the workspace for inline source via `npm run dev:tsapt:[un]link`
|
||||
so it can be debugged and then PR'd to the [awalsh128/ts-apt](http://github.com/awalsh128/ts-apt)
|
||||
repository.
|
||||
|
||||
## How do I start using this?
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is still in beta so use at your own risk!
|
||||
> This is in the early phase of development. Use at your own risk!
|
||||
|
||||
For now you can use the `v2.*` release tags (pinning also supported). Once this version has had time to soak it will be released as the latest. `v1.*` will still get updates but in maintenance mode.
|
||||
**No change is needed and the action signatures are backwards compatible.**
|
||||
|
||||
- Just change to the latest `v2.*` in your GitHub workflow.
|
||||
- `v1.*` will still get patch updates but is in maintenance mode.
|
||||
|
||||
## Some internal changes
|
||||
|
||||
- Logging uses native GitHub calls that will result in run annotations.
|
||||
- Output will tend to be more verbose but this will be tuned as this version matures.
|
||||
- All the metadata generated like manifests, the cache key, and logs are now stored as artifacts.
|
||||
- Debug mode can either be specified via an input parameter `debug` or [as part of the repository settings](https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging).
|
||||
|
|
|
|||
2449
package-lock.json
generated
2449
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -98,6 +98,7 @@
|
|||
"@semantic-release/npm": "^13.1.5",
|
||||
"@semantic-release/release-notes-generator": "^14.1.1",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||
"@typescript-eslint/parser": "^8.62.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
|
|
@ -109,7 +110,7 @@
|
|||
"typedoc": "^0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.12.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9",
|
||||
"vitest": "^4.1.10",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { createPackageManager, type CommandRunner } from "ts-apt";
|
||||
import { Cache, CacheKey } from "./cache.ts";
|
||||
import {
|
||||
createPackageManager,
|
||||
type CommandRunner,
|
||||
type PackageName,
|
||||
} from "ts-apt";
|
||||
import { Cache, CacheKey } from "./cache.js";
|
||||
import { Manifest } from "./manifest.js";
|
||||
import { ActionPackageNames } from "./packages.js";
|
||||
import winston from "winston";
|
||||
|
|
@ -40,34 +44,6 @@ export class ActionRunner {
|
|||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies configured behavior when package input resolves to an empty set.
|
||||
*
|
||||
* @param behavior Empty-package handling strategy.
|
||||
* @param packages Normalized package list.
|
||||
* @returns Nothing.
|
||||
* @throws Error when behavior is error and packages is empty.
|
||||
*/
|
||||
validateEmptyPackages(
|
||||
behavior: EmptyPackageBehavior,
|
||||
packages: ActionPackageNames,
|
||||
): void {
|
||||
if (packages.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (behavior === "ignore") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (behavior === "warn") {
|
||||
process.stdout.write("::warning::Packages argument is empty.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Packages argument is empty.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts manifest entries into the action output CSV format.
|
||||
*
|
||||
|
|
@ -108,6 +84,7 @@ export class ActionRunner {
|
|||
this.logger,
|
||||
);
|
||||
const packageInfos = await installManager.install(packageNames.toArray());
|
||||
manifest = Manifest.from(new Date(), cacheKey, packageInfos);
|
||||
await this.cache.archiveAndSave(cacheKey, packageInfos);
|
||||
}
|
||||
|
||||
|
|
|
|||
75
src/cache.ts
75
src/cache.ts
|
|
@ -5,10 +5,14 @@ import * as crypto from "node:crypto";
|
|||
import { promises as fs } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ActionPackageNames, findInstallScript } from "./packages.ts";
|
||||
import { Manifest, ManifestEntry } from "./manifest.ts";
|
||||
import { CommandRunner, PackageInfo, PackageName } from "ts-apt/types.js";
|
||||
import { deserializePackageName } from "ts-apt/package.ts";
|
||||
import { ActionPackageNames, findInstallScript } from "./packages.js";
|
||||
import { Manifest, ManifestEntry } from "./manifest.js";
|
||||
import {
|
||||
deserializePackageName,
|
||||
CommandRunner,
|
||||
PackageInfo,
|
||||
PackageName,
|
||||
} from "ts-apt";
|
||||
|
||||
export const CACHE_DEFAULT_DIRNAME = "cache-apt-pkgs";
|
||||
export const CACHE_KEY_FILENAME = "cache_key.md5";
|
||||
|
|
@ -41,7 +45,10 @@ export class CacheKey {
|
|||
this.forceUpdateIncrement = forceUpdateIncrement;
|
||||
this.arch = arch;
|
||||
this.packageNames = packageNames;
|
||||
this.hash = crypto.createHash("md5").update(this.toJSON()).digest("hex");
|
||||
this.hash = crypto
|
||||
.createHash("md5")
|
||||
.update(this.toJSON(false))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,14 +71,28 @@ export class CacheKey {
|
|||
/**
|
||||
* Serializes cache key fields to a stable, human-readable format.
|
||||
*
|
||||
* NOTE: Object is always stable JSON stringification for consistent hashing.
|
||||
*
|
||||
* @param readable Whether to pretty-print the JSON output. Defaults to false.
|
||||
* @returns Serialized cache key components.
|
||||
*/
|
||||
toJSON(): string {
|
||||
return JSON.stringify(this, null, 2);
|
||||
toJSON(readable: boolean = false): string {
|
||||
// Helper to sort keys recursively
|
||||
const sortObj = (o: any): any => {
|
||||
if (o === null || typeof o !== "object") return o;
|
||||
if (Array.isArray(o)) return o.map(sortObj);
|
||||
return Object.keys(o)
|
||||
.sort()
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = sortObj(o[key]);
|
||||
return acc;
|
||||
}, {} as any);
|
||||
};
|
||||
return JSON.stringify(sortObj(this), null, readable ? 2 : 0);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.hash} (key input: ${JSON.stringify(this)})`;
|
||||
return `${this.hash} (key input: ${this.toJSON(true)})`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +118,7 @@ export class Cache {
|
|||
|
||||
private async runInstallScripts(archiveFilename: string): Promise<void> {
|
||||
const serializePackageName = archiveFilename.replace(/\.tar$/, "");
|
||||
const packageName = deserializePackageName(serializePackageName);
|
||||
const packageName = deserializePackageName(serializePackageName)!;
|
||||
|
||||
const runScript = async (
|
||||
packageName: PackageName,
|
||||
|
|
@ -140,31 +161,29 @@ export class Cache {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const logFileError = (prefixMessage: string) => {
|
||||
const logFileError = async (prefixMessage: string) => {
|
||||
const contents = await fs
|
||||
.readdir(this.path)
|
||||
.then((entries) => entries.join("\n"))
|
||||
.catch(
|
||||
(reason: any) => `Unable to read cache directory contents: ${reason}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`${prefixMessage}, skipping cache restore.\n` +
|
||||
`This may indicate a cache corruption or an unexpected cache hit for a different package set.\n` +
|
||||
fs
|
||||
.readdir(this.path)
|
||||
.then(
|
||||
(entries) => `Cache directory contents:\n${entries.join("\n")}`,
|
||||
)
|
||||
.catch(
|
||||
(reason: any) =>
|
||||
`Unable to read cache directory contents: ${reason}`,
|
||||
),
|
||||
`Cache directory contents:\n${contents}`,
|
||||
);
|
||||
};
|
||||
|
||||
this.logger.info(`Cache hit for key ${key.hash}, restoring...`);
|
||||
const manifestPath = path.join(this.path, MANIFEST_MAIN_FILENAME);
|
||||
if (
|
||||
!fs
|
||||
!(await fs
|
||||
.access(manifestPath, fs.constants.R_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
.catch(() => false))
|
||||
) {
|
||||
logFileError(`Manifest file not found at ${manifestPath}`);
|
||||
await logFileError(`Manifest file not found at ${manifestPath}`);
|
||||
return undefined;
|
||||
}
|
||||
const archives = await fs
|
||||
|
|
@ -175,7 +194,9 @@ export class Cache {
|
|||
.then((entries: string[]) => entries.sort());
|
||||
|
||||
if (archives.length === 0) {
|
||||
logFileError(`No archive files found in cache directory ${this.path}`);
|
||||
await logFileError(
|
||||
`No archive files found in cache directory ${this.path}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +212,8 @@ export class Cache {
|
|||
await this.runInstallScripts(archiveFilename);
|
||||
}
|
||||
}
|
||||
|
||||
return await Manifest.readFromFile(manifestPath);
|
||||
}
|
||||
|
||||
private async createArchive(manifestEntry: ManifestEntry): Promise<void> {
|
||||
|
|
@ -232,10 +255,10 @@ export class Cache {
|
|||
const allEntries = manifest.entries;
|
||||
const packages = manifest.cacheKey.packageNames;
|
||||
const entriesByName = new Map(
|
||||
allEntries.map((entry) => [entry.packageName, entry]),
|
||||
allEntries.map((entry) => [entry.packageName.serialize(), entry]),
|
||||
);
|
||||
const mainEntries = packages.toArray().map((pkg) => {
|
||||
const installed = entriesByName.get(pkg);
|
||||
const installed = entriesByName.get(pkg.serialize());
|
||||
return new ManifestEntry(pkg, installed?.filepaths ?? []);
|
||||
});
|
||||
|
||||
|
|
@ -295,7 +318,7 @@ export class Cache {
|
|||
|
||||
try {
|
||||
const cacheId = await ghcache.saveCache(
|
||||
manifest.entries.flatMap((entry) => entry.filepaths),
|
||||
[this.path],
|
||||
manifest.cacheKey.hash,
|
||||
);
|
||||
if (cacheId === undefined) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as core from "@actions/core";
|
||||
import { runAction, type ActionInputs } from "./action.js";
|
||||
import { DefaultCommandRunner } from "ts-apt";
|
||||
import { Cache, CACHE_DEFAULT_DIRNAME } from "./cache.ts";
|
||||
import { Cache, CACHE_DEFAULT_DIRNAME } from "./cache.js";
|
||||
import { Instruments } from "./instrumentation.js";
|
||||
|
||||
/**
|
||||
|
|
@ -59,7 +59,7 @@ async function main(): Promise<void> {
|
|||
const outputs = await runAction(
|
||||
inputs,
|
||||
commandRunner,
|
||||
new Cache(commandRunner, instruments.appLogger, cacheDir),
|
||||
new Cache(commandRunner, instruments.appLogger, instruments.cacheDir),
|
||||
instruments.appLogger,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
CACHE_KEY_FILENAME,
|
||||
MANIFEST_ALL_FILENAME,
|
||||
MANIFEST_MAIN_FILENAME,
|
||||
} from "./cache.ts";
|
||||
} from "./cache.js";
|
||||
|
||||
const APP_LOG_FILENAME = "capa_app.log";
|
||||
const EXEC_LOG_FILENAME = "capa_exec.log";
|
||||
|
|
@ -23,15 +23,23 @@ function createStream(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages artifact creation and upload.
|
||||
*/
|
||||
export class Artifacts {
|
||||
constructor(
|
||||
/** Directory that all the artifacts are stored. */
|
||||
readonly dir: string,
|
||||
/** Filenames of monitored artifacts. */
|
||||
readonly artifactFilenames: string[],
|
||||
/** Whether debug is enabled which affects artifacts otherwise not. */
|
||||
readonly debug: boolean = false,
|
||||
/** Unique runner ID of the GitHub Action workflow run. */
|
||||
readonly runId: string = process.env.GITHUB_RUN_ID ??
|
||||
`ghrunid-notfound-${crypto.randomUUID()}`,
|
||||
) {}
|
||||
|
||||
/** Upload artifacts to GitHub Actions via [@actions/artifact]. */
|
||||
upload(): string {
|
||||
const client = new DefaultArtifactClient();
|
||||
client.uploadArtifact(
|
||||
|
|
@ -48,6 +56,7 @@ export class Artifacts {
|
|||
}
|
||||
}
|
||||
|
||||
/** Create the logger used for command line execution */
|
||||
function createExecLogger(debug: boolean, filepath: string): winston.Logger {
|
||||
const logger = winston.createLogger({
|
||||
level: debug ? "debug" : "info",
|
||||
|
|
@ -60,6 +69,7 @@ function createExecLogger(debug: boolean, filepath: string): winston.Logger {
|
|||
return logger;
|
||||
}
|
||||
|
||||
/** Create the logger used for GitHub Actions integration. */
|
||||
function createGitHubLogger(debug: boolean, filepath: string): winston.Logger {
|
||||
const logger = winston.createLogger({
|
||||
level: debug ? "debug" : "info",
|
||||
|
|
@ -87,11 +97,17 @@ function createGitHubLogger(debug: boolean, filepath: string): winston.Logger {
|
|||
return logger;
|
||||
}
|
||||
|
||||
/** Container for all instrumentation: telemetry, logging, and artifacts generated */
|
||||
export class Instruments {
|
||||
/** Cache directory where all artifacts are stored, including cached packages. */
|
||||
readonly cacheDir: string;
|
||||
/** Whether debug is enabled which affects artifacts otherwise not. */
|
||||
readonly debug: boolean;
|
||||
/** Manages artifact creation and upload. */
|
||||
readonly artifacts: Artifacts;
|
||||
/** Logger used for GitHub Actions integration. */
|
||||
readonly appLogger: winston.Logger;
|
||||
/** Logger used for command line execution. */
|
||||
readonly execLogger: winston.Logger;
|
||||
|
||||
constructor(cacheDir: string, debug: boolean, artifacts?: Artifacts) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import fs from "node:fs";
|
||||
import { CacheKey } from "./cache.ts";
|
||||
import { createPackageName, packageNameFromJSON } from "ts-apt/package.ts";
|
||||
import { PackageInfo, PackageName } from "ts-apt/types.ts";
|
||||
import { CacheKey } from "./cache.js";
|
||||
import { packageNameFromJSON, PackageInfo, PackageName } from "ts-apt";
|
||||
|
||||
export class ManifestEntry {
|
||||
readonly packageName: PackageName;
|
||||
|
|
@ -42,22 +41,22 @@ export class Manifest {
|
|||
);
|
||||
}
|
||||
|
||||
static async readFromFile(filePath: string): Promise<Manifest> {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return Manifest.fromJSON(JSON.parse(content));
|
||||
}
|
||||
|
||||
static from(
|
||||
date: Date,
|
||||
cacheKey: CacheKey,
|
||||
packageInfos: PackageInfo[],
|
||||
): Manifest {
|
||||
const entries = packageInfos.map(
|
||||
(info) =>
|
||||
new ManifestEntry(createPackageName(info.name, info.version), []),
|
||||
(info: PackageInfo) => new ManifestEntry(info.name, []),
|
||||
);
|
||||
return new Manifest(date, entries, cacheKey);
|
||||
}
|
||||
|
||||
async readFromFile(filePath: string): Promise<Manifest> {
|
||||
return Manifest.fromJSON(await fs.promises.readFile(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
async writeToFile(filePath: string): Promise<void> {
|
||||
await fs.promises.writeFile(
|
||||
filePath,
|
||||
|
|
|
|||
119
src/packages.ts
119
src/packages.ts
|
|
@ -1,7 +1,11 @@
|
|||
import * as fs from "fs";
|
||||
import type { PackageName, PackageManager } from "ts-apt/types.ts";
|
||||
import path from "path";
|
||||
import { createPackageName, deserializePackageName } from "ts-apt/package.ts";
|
||||
import {
|
||||
createPackageName,
|
||||
deserializePackageName,
|
||||
type PackageName,
|
||||
type PackageManager,
|
||||
} from "ts-apt";
|
||||
import winston from "winston";
|
||||
|
||||
/**
|
||||
|
|
@ -14,6 +18,7 @@ import winston from "winston";
|
|||
export async function buildFileList(
|
||||
packageName: PackageName,
|
||||
packageManager: PackageManager,
|
||||
root: string = "/",
|
||||
): Promise<string[]> {
|
||||
// Converts absolute paths to tar-relative paths.
|
||||
const tarRelativePath = (filePath: string) =>
|
||||
|
|
@ -30,8 +35,8 @@ export async function buildFileList(
|
|||
})
|
||||
.map((filePath) => tarRelativePath(filePath));
|
||||
|
||||
const preinst = await findInstallScript(packageName, "preinst", "/");
|
||||
const postinst = await findInstallScript(packageName, "postinst", "/");
|
||||
const preinst = await findInstallScript(packageName, "preinst", root);
|
||||
const postinst = await findInstallScript(packageName, "postinst", root);
|
||||
|
||||
if (preinst) {
|
||||
files.push(tarRelativePath(preinst));
|
||||
|
|
@ -40,7 +45,7 @@ export async function buildFileList(
|
|||
files.push(tarRelativePath(postinst));
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
return [...new Set(files)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,9 +66,7 @@ export async function findInstallScript(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const pattern = new RegExp(
|
||||
`^${packageName.serialize()}(:.*)?\\.${extension}$`,
|
||||
);
|
||||
const pattern = new RegExp(`^${packageName.name}(:.*)?\\.${extension}$`);
|
||||
const matches = fs
|
||||
.readdirSync(scriptsDir)
|
||||
.filter((entry) => pattern.test(entry))
|
||||
|
|
@ -76,49 +79,87 @@ export async function findInstallScript(
|
|||
return path.join(scriptsDir, candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a concrete version for an unpinned package name.
|
||||
*
|
||||
* @param packageManager ts-apt package manager instance used for metadata lookup.
|
||||
* @param packageName Package name with or without a version pin.
|
||||
* @returns Resolved package version.
|
||||
* @throws Error when no version can be resolved.
|
||||
*/
|
||||
export async function resolvePackageVersion(
|
||||
packageManager: PackageManager,
|
||||
packageName: PackageName,
|
||||
): Promise<string> {
|
||||
const packageInfo = await packageManager.getPackageInfo([packageName]);
|
||||
const version = packageInfo[0]?.version;
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
`Unable to resolve package version for '${packageName.serialize()}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
export class ActionPackageNames {
|
||||
private readonly items: PackageName[];
|
||||
|
||||
private constructor(items: PackageName[]) {
|
||||
this.items = items.sort();
|
||||
this.items = items.sort((a, b) => a.compareTo(b));
|
||||
}
|
||||
|
||||
static fromInput(serializedPackageNames: string): ActionPackageNames {
|
||||
const names = serializedPackageNames
|
||||
.replace(/[,\\]/g, " ")
|
||||
const tokens = serializedPackageNames
|
||||
.replace(/[,\\\n]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((part) => deserializePackageName(part.trim()));
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
const names: PackageName[] = [];
|
||||
|
||||
for (let index = 0; index < tokens.length; ) {
|
||||
const current = tokens[index]!;
|
||||
const next = tokens[index + 1];
|
||||
|
||||
const splitNameArch = (namePart: string) => {
|
||||
const [name = "", arch] = namePart.split(":", 2);
|
||||
return { name, arch };
|
||||
};
|
||||
|
||||
try {
|
||||
if (current.includes("=")) {
|
||||
const [namePart, ...versionParts] = current.split("=");
|
||||
const version = versionParts.join("=").trim();
|
||||
if (namePart === undefined || namePart.trim() === "") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const split = splitNameArch(namePart.trim());
|
||||
let arch = split.arch;
|
||||
|
||||
if (
|
||||
arch === undefined &&
|
||||
next !== undefined &&
|
||||
!next.includes("=") &&
|
||||
next.trim() !== ""
|
||||
) {
|
||||
arch = next.trim();
|
||||
index += 1;
|
||||
}
|
||||
|
||||
names.push(
|
||||
createPackageName(
|
||||
split.name,
|
||||
version === "" ? undefined : version,
|
||||
arch,
|
||||
),
|
||||
);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const split = splitNameArch(current);
|
||||
names.push(createPackageName(split.name, undefined, split.arch));
|
||||
} catch {
|
||||
// Ignore invalid package tokens.
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return new ActionPackageNames(names);
|
||||
}
|
||||
|
||||
static fromJSON(json: any): ActionPackageNames {
|
||||
const items = (json as any[]).map((item: any) =>
|
||||
createPackageName(item.name, item.version, item.rc),
|
||||
const sourceItems = Array.isArray(json)
|
||||
? json
|
||||
: Array.isArray(json?.items)
|
||||
? json.items
|
||||
: [];
|
||||
|
||||
const items = sourceItems.map((item: any) =>
|
||||
createPackageName(item.name, item.version, item.arch),
|
||||
);
|
||||
return new ActionPackageNames(items);
|
||||
}
|
||||
|
|
@ -127,7 +168,7 @@ export class ActionPackageNames {
|
|||
return this.items.length;
|
||||
}
|
||||
|
||||
toArray(): ReadonlyArray<PackageName> {
|
||||
toArray(): readonly PackageName[] {
|
||||
return this.items;
|
||||
}
|
||||
}
|
||||
|
|
@ -141,8 +182,8 @@ export class ActionPackageNames {
|
|||
export async function updateAptLists(
|
||||
packageManager: PackageManager,
|
||||
logger: winston.Logger,
|
||||
aptListsPath: string = "/var/lib/apt/lists",
|
||||
): Promise<void> {
|
||||
const aptListsPath = "/var/lib/apt/lists";
|
||||
const maxDepth = 5;
|
||||
|
||||
const search = async (
|
||||
|
|
|
|||
|
|
@ -1,380 +0,0 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import winston from "winston";
|
||||
|
||||
vi.mock("@actions/cache", () => ({
|
||||
restoreCache: vi.fn(),
|
||||
saveCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("ts-apt", () => ({
|
||||
createPackageManager: vi.fn(),
|
||||
DefaultCommandRunner: vi.fn().mockImplementation(() => ({
|
||||
run: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import * as cacheMod from "@actions/cache";
|
||||
import { createPackageManager, DefaultCommandRunner } from "ts-apt";
|
||||
import { Manifest } from "../src/manifest.js";
|
||||
import { CacheKey } from "../src/cache.ts";
|
||||
import {
|
||||
ActionPackageName,
|
||||
ActionRunner,
|
||||
runAction as runActionEntry,
|
||||
} from "../src/action.js";
|
||||
|
||||
describe("ActionRunner coverage", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createRunner() {
|
||||
const commandRunner = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const logger = winston.createLogger({ silent: true });
|
||||
const tarModule = {
|
||||
create: vi.fn(),
|
||||
extract: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
runner: new ActionRunner(
|
||||
commandRunner as never,
|
||||
tarModule as never,
|
||||
logger,
|
||||
),
|
||||
commandRunner,
|
||||
tarModule,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
it("parseBoolean invalid value expected throw", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(() => runner.parseBoolean("invalid", "debug")).toThrow(
|
||||
/must be either true or false/,
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizeInputPackages escaped separators expected sorted output", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.normalizeInputPackages(" z, a \\ b ")).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("validateEmptyPackages ignore empty expected no throw", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(() => runner.validateEmptyPackages("ignore", [])).not.toThrow();
|
||||
});
|
||||
|
||||
it("findInstallScript missing directory expected undefined", () => {
|
||||
const { runner } = createRunner();
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
|
||||
expect(runner.findInstallScript("curl", "preinst", "/tmp")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("findInstallScript matching scripts expected first sorted path", () => {
|
||||
const { runner } = createRunner();
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(true);
|
||||
vi.spyOn(fs, "readdirSync").mockReturnValue([
|
||||
"curl:amd64.preinst",
|
||||
"curl.preinst",
|
||||
"curl.postinst",
|
||||
] as never);
|
||||
|
||||
expect(runner.findInstallScript("curl", "preinst", "/tmp")).toBe(
|
||||
"/tmp/var/lib/dpkg/info/curl:amd64.preinst",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildFileListForPackage mixed files and scripts expected unique sorted tar paths", async () => {
|
||||
const { runner } = createRunner();
|
||||
const packageManager = {
|
||||
listInstalledFiles: vi
|
||||
.fn()
|
||||
.mockResolvedValue(["/a", "/b", "/missing", "/a"]),
|
||||
};
|
||||
|
||||
vi.spyOn(fs, "existsSync").mockImplementation((p) => p !== "/missing");
|
||||
vi.spyOn(fs, "lstatSync").mockImplementation(
|
||||
(p) =>
|
||||
({
|
||||
isFile: () => p === "/a",
|
||||
isSymbolicLink: () => p === "/b",
|
||||
}) as fs.Stats,
|
||||
);
|
||||
vi.spyOn(runner, "findInstallScript")
|
||||
.mockReturnValueOnce("/var/lib/dpkg/info/curl.preinst")
|
||||
.mockReturnValueOnce("/var/lib/dpkg/info/curl.postinst");
|
||||
|
||||
await expect(
|
||||
runner.buildFileListForPackage(
|
||||
packageManager as never,
|
||||
new ActionPackageName("curl"),
|
||||
),
|
||||
).resolves.toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"var/lib/dpkg/info/curl.postinst",
|
||||
"var/lib/dpkg/info/curl.preinst",
|
||||
]);
|
||||
});
|
||||
|
||||
it("installAndCachePackages new archives expected tar create and manifests", async () => {
|
||||
const { runner, tarModule } = createRunner();
|
||||
const packageManager = {
|
||||
install: vi.fn().mockResolvedValue([{ name: "curl", version: "1.0" }]),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.spyOn(runner, "updateAptLists").mockResolvedValue(undefined);
|
||||
vi.spyOn(runner, "buildFileListForPackage").mockResolvedValue([
|
||||
"usr/bin/curl",
|
||||
]);
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
|
||||
await runner.installAndCachePackages(
|
||||
"/cache",
|
||||
[new ActionPackageName("curl")],
|
||||
packageManager as never,
|
||||
new CacheKey("", "4", "x86_64", ["curl"]),
|
||||
);
|
||||
|
||||
expect(tarModule.create).toHaveBeenCalledOnce();
|
||||
expect(fs.existsSync("/cache/manifest_main.json")).toBe(true);
|
||||
expect(fs.existsSync("/cache/manifest_all.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("restorePackages install scripts enabled expected extract and script runs", async () => {
|
||||
const { runner, commandRunner, tarModule } = createRunner();
|
||||
|
||||
vi.spyOn(fs, "readdirSync").mockReturnValue([
|
||||
"curl=1.0.tar",
|
||||
"notes.txt",
|
||||
] as never);
|
||||
vi.spyOn(runner, "findInstallScript")
|
||||
.mockReturnValueOnce("/preinst")
|
||||
.mockReturnValueOnce("/postinst");
|
||||
|
||||
await runner.restorePackages("/cache", true);
|
||||
|
||||
expect(tarModule.extract).toHaveBeenCalledWith({
|
||||
cwd: "/",
|
||||
file: "/cache/curl=1.0.tar",
|
||||
preservePaths: true,
|
||||
});
|
||||
expect(commandRunner.run).toHaveBeenNthCalledWith(1, "sudo", [
|
||||
"sh",
|
||||
"-x",
|
||||
"/preinst",
|
||||
"install",
|
||||
]);
|
||||
expect(commandRunner.run).toHaveBeenNthCalledWith(2, "sudo", [
|
||||
"sh",
|
||||
"-x",
|
||||
"/postinst",
|
||||
"configure",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runAction version contains spaces expected throw", async () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "curl",
|
||||
version: "bad version",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
}),
|
||||
).rejects.toThrow(/cannot contain spaces/);
|
||||
});
|
||||
|
||||
it("runAction empty normalized packages expected empty outputs and manifests", async () => {
|
||||
const { runner } = createRunner();
|
||||
const cacheDir = path.join(os.tmpdir(), "action-empty-cache");
|
||||
|
||||
vi.mocked(createPackageManager).mockResolvedValue({} as never);
|
||||
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([]);
|
||||
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "",
|
||||
version: "",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "ignore",
|
||||
debug: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
cacheHit: false,
|
||||
packageVersionList: "",
|
||||
allPackageVersionList: "",
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(cacheDir, "manifest_main.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(cacheDir, "manifest_all.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("runAction cache miss expected install and save cache", async () => {
|
||||
const { runner } = createRunner();
|
||||
const cacheDir = path.join(os.tmpdir(), "action-cache-miss");
|
||||
|
||||
vi.mocked(createPackageManager)
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockResolvedValueOnce({ install: vi.fn() } as never);
|
||||
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([
|
||||
new ActionPackageName("curl", "1.0"),
|
||||
]);
|
||||
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
|
||||
vi.spyOn(runner, "getCacheKey").mockResolvedValue("cache-apt-pkgs_key");
|
||||
vi.spyOn(runner, "installAndCachePackages").mockResolvedValue(undefined);
|
||||
vi.mocked(cacheMod.restoreCache).mockResolvedValue(undefined);
|
||||
vi.mocked(cacheMod.saveCache).mockResolvedValue(1);
|
||||
vi.spyOn(Manifest, "readFromFile")
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [{ name: "curl", version: "1.0", filepaths: [] }],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ name: "curl", version: "1.0", filepaths: [] },
|
||||
{ name: "dep", version: "1.0", filepaths: [] },
|
||||
],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
cacheHit: false,
|
||||
packageVersionList: "curl=1.0",
|
||||
allPackageVersionList: "curl=1.0,dep=1.0",
|
||||
});
|
||||
|
||||
expect(runner.installAndCachePackages).toHaveBeenCalledOnce();
|
||||
expect(cacheMod.saveCache).toHaveBeenCalledWith(
|
||||
[cacheDir],
|
||||
"cache-apt-pkgs_key",
|
||||
);
|
||||
});
|
||||
|
||||
it("runAction cache hit expected restore packages", async () => {
|
||||
const { runner } = createRunner();
|
||||
const cacheDir = path.join(os.tmpdir(), "action-cache-hit");
|
||||
|
||||
vi.mocked(createPackageManager).mockResolvedValue({} as never);
|
||||
vi.spyOn(runner, "normalizePackagesWithVersions").mockResolvedValue([
|
||||
new ActionPackageName("curl", "1.0"),
|
||||
]);
|
||||
vi.spyOn(runner, "getCacheRoot").mockReturnValue(cacheDir);
|
||||
vi.spyOn(runner, "getCacheKey").mockResolvedValue("cache-apt-pkgs_key");
|
||||
vi.spyOn(runner, "restorePackages").mockResolvedValue(undefined);
|
||||
vi.mocked(cacheMod.restoreCache).mockResolvedValue("cache-apt-pkgs_key");
|
||||
vi.spyOn(Manifest, "readFromFile")
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [{ name: "curl", version: "1.0", filepaths: [] }],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
Manifest.deserialize(
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ name: "curl", version: "1.0", filepaths: [] },
|
||||
{ name: "dep", version: "1.0", filepaths: [] },
|
||||
],
|
||||
cacheKeyInput: "",
|
||||
cacheKey: "",
|
||||
forceUpdateIncrement: "4",
|
||||
arch: "x86_64",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runner.runAction({
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: true,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
cacheHit: true,
|
||||
packageVersionList: "curl=1.0",
|
||||
allPackageVersionList: "curl=1.0,dep=1.0",
|
||||
});
|
||||
|
||||
expect(runner.restorePackages).toHaveBeenCalledWith(cacheDir, true);
|
||||
});
|
||||
|
||||
it("runAction wrapper valid inputs expected delegated runner output", async () => {
|
||||
const logger = winston.createLogger({ silent: true });
|
||||
const delegated = {
|
||||
cacheHit: false,
|
||||
packageVersionList: "curl=1.0",
|
||||
allPackageVersionList: "curl=1.0,dep=1.0",
|
||||
};
|
||||
|
||||
const runSpy = vi
|
||||
.spyOn(ActionRunner.prototype, "runAction")
|
||||
.mockResolvedValue(delegated);
|
||||
|
||||
await expect(
|
||||
runActionEntry(
|
||||
{
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
},
|
||||
{ run: vi.fn() } as never,
|
||||
logger,
|
||||
),
|
||||
).resolves.toEqual(delegated);
|
||||
|
||||
expect(DefaultCommandRunner).toHaveBeenCalledWith(logger, logger);
|
||||
expect(runSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,119 +1,84 @@
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import winston from "winston";
|
||||
import { Manifest } from "../src/manifest.js";
|
||||
import { CacheKey } from "../src/cache.js";
|
||||
import { ActionPackageNames } from "../src/packages.js";
|
||||
import { createPackageManager } from "ts-apt";
|
||||
|
||||
import { ActionRunner } from "../src/action.js";
|
||||
|
||||
vi.mock("ts-apt", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ts-apt")>();
|
||||
return {
|
||||
...actual,
|
||||
createPackageManager: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ActionRunner", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(createPackageManager).mockReset();
|
||||
});
|
||||
|
||||
function createRunner() {
|
||||
const cache = {
|
||||
loadAndRestore: vi.fn(),
|
||||
archiveAndSave: vi.fn(),
|
||||
};
|
||||
|
||||
const commandRunner = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const logger = winston.createLogger({ silent: true });
|
||||
const tarModule = {
|
||||
create: vi.fn(),
|
||||
extract: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
runner: new ActionRunner(
|
||||
commandRunner as never,
|
||||
tarModule as never,
|
||||
logger,
|
||||
),
|
||||
runner: new ActionRunner(cache as never, commandRunner as never, logger),
|
||||
cache,
|
||||
commandRunner,
|
||||
};
|
||||
}
|
||||
|
||||
it("resolves package versions from package manager metadata", async () => {
|
||||
const { runner } = createRunner();
|
||||
const packageManager = {
|
||||
getPackageInfo: vi.fn().mockResolvedValue([{ version: "1.2.3" }]),
|
||||
};
|
||||
it("returns cache-hit outputs when manifest is restored", async () => {
|
||||
const { runner, cache } = createRunner();
|
||||
const packageNames = ActionPackageNames.fromInput("curl git");
|
||||
const cacheKey = new CacheKey("v1", "0", process.arch, packageNames);
|
||||
const restored = new Manifest(new Date(), [], cacheKey);
|
||||
cache.loadAndRestore.mockResolvedValue(restored);
|
||||
|
||||
await expect(
|
||||
runner.resolvePackageVersion(packageManager as never, "git"),
|
||||
).resolves.toBe("1.2.3");
|
||||
const result = await runner.runAction({
|
||||
packages: "curl git",
|
||||
version: "v1",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
});
|
||||
|
||||
expect(result.cacheHit).toBe(true);
|
||||
expect(cache.archiveAndSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when package metadata does not contain a version", async () => {
|
||||
const { runner } = createRunner();
|
||||
const packageManager = {
|
||||
getPackageInfo: vi.fn().mockResolvedValue([{}]),
|
||||
};
|
||||
it("archives installed packages on cache miss", async () => {
|
||||
const { runner, cache } = createRunner();
|
||||
cache.loadAndRestore.mockResolvedValue(undefined);
|
||||
vi.mocked(createPackageManager).mockResolvedValue({
|
||||
install: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: {
|
||||
serialize: () => "curl=1.0.0",
|
||||
},
|
||||
},
|
||||
]),
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
runner.resolvePackageVersion(packageManager as never, "git"),
|
||||
).rejects.toThrow(/Unable to resolve package version/);
|
||||
});
|
||||
await runner.runAction({
|
||||
packages: "curl",
|
||||
version: "v1",
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
});
|
||||
|
||||
it("normalizes packages and fills in missing versions", async () => {
|
||||
const { runner } = createRunner();
|
||||
vi.spyOn(runner, "resolvePackageVersion")
|
||||
.mockResolvedValueOnce("8.0")
|
||||
.mockResolvedValueOnce("2.39");
|
||||
|
||||
await expect(
|
||||
runner.normalizePackagesWithVersions({} as never, "git curl=8.1"),
|
||||
).resolves.toEqual(["curl=8.1", "git=8.0"]);
|
||||
});
|
||||
|
||||
it("warns instead of throwing for empty packages when behavior is warn", () => {
|
||||
const { runner } = createRunner();
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
expect(() => runner.validateEmptyPackages("warn", [])).not.toThrow();
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
"::warning::Packages argument is empty.\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for empty packages when behavior is error", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(() => runner.validateEmptyPackages("error", [])).toThrow(
|
||||
/Packages argument is empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the cache root under the current home directory", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.getCacheRoot()).toBe(
|
||||
path.join(os.homedir(), "cache-apt-pkgs"),
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes runner cache keys using the current architecture", async () => {
|
||||
const { runner, commandRunner } = createRunner();
|
||||
commandRunner.run.mockResolvedValue({ stdout: "arm64\n" });
|
||||
|
||||
await expect(runner.getCacheKey(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
"cache-apt-pkgs_36cfe31d08e34e7bd87b39c0e0145ece",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes versions from package specifiers", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.packageSpecifierToName("curl=8.1")).toBe("curl");
|
||||
expect(runner.packageSpecifierToName("git")).toBe("git");
|
||||
});
|
||||
|
||||
it("strips leading slashes from tar paths", () => {
|
||||
const { runner } = createRunner();
|
||||
|
||||
expect(runner.tarRelativePath("/var/cache/apt/pkg.tar")).toBe(
|
||||
"var/cache/apt/pkg.tar",
|
||||
);
|
||||
expect(runner.tarRelativePath("relative/file")).toBe("relative/file");
|
||||
expect(cache.archiveAndSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,48 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ActionPackageName,
|
||||
normalizeInputPackages,
|
||||
parseBoolean,
|
||||
runAction,
|
||||
type ActionInputs,
|
||||
type ActionOutputs,
|
||||
} from "../src/action.js";
|
||||
import { Manifest, ManifestEntry } from "../src/manifest.js";
|
||||
import { CacheKey } from "../src/cache.js";
|
||||
import { createPackageName } from "ts-apt";
|
||||
import { ActionPackageNames } from "../src/packages.js";
|
||||
|
||||
describe("non runner functions", () => {
|
||||
describe("runAction", () => {
|
||||
const PKG_NAME = "curl";
|
||||
const PKG_VER = "1.2.3";
|
||||
const PKG_DISTRO = "focal";
|
||||
const PKG2_NAME = "git";
|
||||
const PKG3_NAME = "jq";
|
||||
|
||||
it("normalizes package list syntax", () => {
|
||||
const input = ` ${PKG2_NAME}, ${PKG_NAME} \\\n ${PKG3_NAME} `;
|
||||
expect(normalizeInputPackages(input)).toEqual([
|
||||
PKG_NAME,
|
||||
PKG2_NAME,
|
||||
PKG3_NAME,
|
||||
]);
|
||||
});
|
||||
it("returns expected outputs for cache hit", async () => {
|
||||
const inputs: ActionInputs = {
|
||||
packages: `${PKG_NAME},${PKG2_NAME},${PKG3_NAME}`,
|
||||
version: PKG_VER,
|
||||
executeInstallScripts: false,
|
||||
emptyPackagesBehavior: "error",
|
||||
debug: false,
|
||||
};
|
||||
|
||||
it("parses true/false values", () => {
|
||||
expect(parseBoolean("true", "debug")).toBe(true);
|
||||
expect(parseBoolean("false", "debug")).toBe(false);
|
||||
});
|
||||
const packageNames = ActionPackageNames.fromInput(inputs.packages);
|
||||
const cacheKey = new CacheKey(
|
||||
inputs.version,
|
||||
"0",
|
||||
process.arch,
|
||||
packageNames,
|
||||
);
|
||||
const manifest = new Manifest(
|
||||
new Date(),
|
||||
[
|
||||
new ManifestEntry(createPackageName(PKG_NAME, PKG_VER, undefined), []),
|
||||
new ManifestEntry(createPackageName(PKG2_NAME, PKG_VER, undefined), []),
|
||||
new ManifestEntry(createPackageName(PKG3_NAME, PKG_VER, undefined), []),
|
||||
],
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
it("fails for invalid booleans", () => {
|
||||
expect(() => parseBoolean("TRUE", "debug")).toThrow();
|
||||
});
|
||||
const mockCache = {
|
||||
loadAndRestore: async () => manifest,
|
||||
archiveAndSave: async () => {},
|
||||
};
|
||||
|
||||
it("serializes ActionPackageName with no version", () => {
|
||||
expect(new ActionPackageName(PKG_NAME).serialize()).toEqual(PKG_NAME);
|
||||
});
|
||||
const mockCommandRunner = {
|
||||
run: async () => {},
|
||||
};
|
||||
|
||||
it("serializes ActionPackageName with version", () => {
|
||||
expect(new ActionPackageName(PKG_NAME, PKG_VER).serialize()).toEqual(
|
||||
`${PKG_NAME}=${PKG_VER}`,
|
||||
const mockLogger = {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
const outputs: ActionOutputs = await runAction(
|
||||
inputs,
|
||||
mockCommandRunner as never,
|
||||
mockCache as never,
|
||||
mockLogger as never,
|
||||
);
|
||||
|
||||
expect(outputs.cacheHit).toBe(true);
|
||||
expect(outputs.packageVersionList).toBe(
|
||||
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`,
|
||||
);
|
||||
expect(outputs.allPackageVersionList).toBe(
|
||||
`${PKG_NAME}=${PKG_VER},${PKG2_NAME}=${PKG_VER},${PKG3_NAME}=${PKG_VER}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes ActionPackageName with version and distro", () => {
|
||||
expect(
|
||||
new ActionPackageName(PKG_NAME, PKG_VER, PKG_DISTRO).serialize(),
|
||||
).toEqual(`${PKG_NAME}=${PKG_VER}`);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
121
test/cache.test.ts
Normal file
121
test/cache.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Cache, CacheKey } from "../src/cache.js";
|
||||
import { ActionPackageNames } from "../src/packages.js";
|
||||
|
||||
const ARCH = "arm64";
|
||||
const CACHE_VER = "v1";
|
||||
const FORCE_UPDATE_INCREMENT = "4";
|
||||
const INPUT_PACKAGE_NAMES = ["curl=1", "git=2"];
|
||||
const SERIALIZED_INPUT_PACKAGE_NAMES = INPUT_PACKAGE_NAMES.join(" ");
|
||||
const HASH = "6753d4609b4f220748c84ac0893bd97d";
|
||||
const CACHE_KEY_JSON =
|
||||
'{"arch":"arm64","forceUpdateIncrement":"4","hash":"6753d4609b4f220748c84ac0893bd97d","packageNames":{"items":[{"name":"curl","version":"1"},{"name":"git","version":"2"}]},"version":"v1"}';
|
||||
|
||||
describe("cache", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("CacheKey", () => {
|
||||
it("creates a cache key with the expected hash", () => {
|
||||
const key = new CacheKey(
|
||||
CACHE_VER,
|
||||
FORCE_UPDATE_INCREMENT,
|
||||
ARCH,
|
||||
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
|
||||
);
|
||||
expect(key.hash).toBe(HASH);
|
||||
});
|
||||
|
||||
it("creates a cache key of unordered package names with the expected hash", () => {
|
||||
const key = new CacheKey(
|
||||
CACHE_VER,
|
||||
FORCE_UPDATE_INCREMENT,
|
||||
ARCH,
|
||||
ActionPackageNames.fromInput(INPUT_PACKAGE_NAMES.reverse().join(" ")),
|
||||
);
|
||||
expect(key.hash).toBe(HASH);
|
||||
});
|
||||
|
||||
it("creates a cache key with the expected hash", () => {
|
||||
const key = new CacheKey(
|
||||
CACHE_VER,
|
||||
FORCE_UPDATE_INCREMENT,
|
||||
ARCH,
|
||||
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
|
||||
);
|
||||
expect(key.hash).toBe(HASH);
|
||||
});
|
||||
|
||||
it("JSON roundtrips cache keys", () => {
|
||||
const key = new CacheKey(
|
||||
CACHE_VER,
|
||||
FORCE_UPDATE_INCREMENT,
|
||||
ARCH,
|
||||
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
|
||||
);
|
||||
|
||||
expect(key.toJSON()).toBe(CACHE_KEY_JSON);
|
||||
|
||||
expect(CacheKey.fromJSON(key.toJSON())).toEqual(
|
||||
new CacheKey(
|
||||
CACHE_VER,
|
||||
FORCE_UPDATE_INCREMENT,
|
||||
ARCH,
|
||||
ActionPackageNames.fromInput(SERIALIZED_INPUT_PACKAGE_NAMES),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// it("rejects invalid serialized cache keys", () => {
|
||||
// expect(() => CacheKey.fromJSON("invalid")).toThrow(
|
||||
// /Invalid serialized cache key/,
|
||||
// );
|
||||
// });
|
||||
|
||||
// it("builds cache paths under the current home directory", () => {
|
||||
// const cache = new Cache({
|
||||
// run: vi.fn(),
|
||||
// } as never);
|
||||
|
||||
// expect(cache.path).toBe(path.join(os.homedir(), "custom-cache"));
|
||||
// });
|
||||
|
||||
// it("hashes cache keys without appending the default x86_64 architecture", async () => {
|
||||
// const commandRunner = {
|
||||
// run: vi.fn().mockResolvedValue({ stdout: "x86_64\n" }),
|
||||
// };
|
||||
// const cache = new Cache("cache-apt-pkgs", commandRunner as never);
|
||||
|
||||
// const expectedHash = crypto
|
||||
// .createHash("md5")
|
||||
// .update("curl=1 git=2 @ 'v1' 4")
|
||||
// .digest("hex");
|
||||
|
||||
// await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
// `cache-apt-pkgs_${expectedHash}`,
|
||||
// );
|
||||
// });
|
||||
|
||||
// it("hashes cache keys with non-default architectures", async () => {
|
||||
// const commandRunner = {
|
||||
// run: vi.fn().mockResolvedValue({ stdout: "arm64\n" }),
|
||||
// };
|
||||
// const cache = new Cache("cache-apt-pkgs", commandRunner as never);
|
||||
|
||||
// const expectedHash = crypto
|
||||
// .createHash("md5")
|
||||
// .update("curl=1 git=2 @ 'v1' 4 arm64")
|
||||
// .digest("hex");
|
||||
|
||||
// await expect(cache.key(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
// `cache-apt-pkgs_${expectedHash}`,
|
||||
// );
|
||||
// });
|
||||
});
|
||||
75
test/instrumentation.test.ts
Normal file
75
test/instrumentation.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Artifacts, Instruments } from "../src/instrumentation.ts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("instrumentation", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const ARTIFACT_FILENAMES = [
|
||||
"capa_app.log",
|
||||
"capa_exec.log",
|
||||
"cache_key.json",
|
||||
"manifest_main.json",
|
||||
"manifest_all.json",
|
||||
];
|
||||
|
||||
describe("Artifacts", () => {
|
||||
it("constructor initializes properties correctly", () => {
|
||||
const dir = "/path/to/artifacts";
|
||||
const debug = true;
|
||||
const runId = "test-run-id";
|
||||
|
||||
const artifacts = new Artifacts(dir, ARTIFACT_FILENAMES, debug, runId);
|
||||
expect(artifacts).toEqual({
|
||||
dir: dir,
|
||||
artifactFilenames: ARTIFACT_FILENAMES,
|
||||
debug: debug,
|
||||
runId: runId,
|
||||
});
|
||||
});
|
||||
|
||||
it("constructor generates a random runId when not provided", () => {
|
||||
const dir = "/path/to/artifacts";
|
||||
const debug = false;
|
||||
|
||||
const artifacts = new Artifacts(dir, ARTIFACT_FILENAMES, debug);
|
||||
expect(artifacts.runId).toMatch(/^ghrunid-notfound-/);
|
||||
});
|
||||
});
|
||||
|
||||
// describe("Instruments", () => {
|
||||
// it("constructor initializes properties correctly", () => {
|
||||
// const cacheDir = "/path/to/cache";
|
||||
// const debug = true;
|
||||
// const artifacts = new Artifacts(
|
||||
// "/path/to/cache",
|
||||
// ARTIFACT_FILENAMES,
|
||||
// debug,
|
||||
// );
|
||||
|
||||
// const instruments = new Instruments(cacheDir, debug, artifacts);
|
||||
// expect(instruments).toEqual({
|
||||
// cacheDir: cacheDir,
|
||||
// debug: debug,
|
||||
// artifacts: artifacts,
|
||||
// appLogger: expect.toBeTypeOf("object"),
|
||||
// execLogger: expect.toBeTypeOf("object"),
|
||||
// });
|
||||
// });
|
||||
|
||||
// it("constructor initializes artifacts when not provided", () => {
|
||||
// const cacheDir = "/path/to/cache";
|
||||
// const debug = false;
|
||||
|
||||
// const instruments = new Instruments(cacheDir, debug);
|
||||
// expect(instruments).toEqual({
|
||||
// cacheDir: cacheDir,
|
||||
// debug: debug,
|
||||
// artifacts: expect.toBeTypeOf("object"),
|
||||
// appLogger: expect.toBeTypeOf("object"),
|
||||
// execLogger: expect.toBeTypeOf("object"),
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Cache, CacheKey, isAptListsFresh } from "../src/cache.ts";
|
||||
import { ActionPackageName } from "../src/action.ts";
|
||||
|
||||
describe("io", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("detects fresh apt lists when a file exists within search depth", () => {
|
||||
vi.spyOn(fs, "statSync").mockImplementation(
|
||||
(currentPath: fs.PathLike) =>
|
||||
({
|
||||
isDirectory: () =>
|
||||
String(currentPath) !== "/var/lib/apt/lists/partial/pkg.idx",
|
||||
}) as fs.Stats,
|
||||
);
|
||||
|
||||
vi.spyOn(fs, "readdirSync")
|
||||
.mockImplementationOnce(() => ["partial"] as never)
|
||||
.mockImplementationOnce(() => ["pkg.idx"] as never);
|
||||
|
||||
expect(isAptListsFresh()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a boolean for the current system apt list state", () => {
|
||||
expect(typeof isAptListsFresh()).toBe("boolean");
|
||||
});
|
||||
|
||||
it("serializes package values", () => {
|
||||
expect(new ActionPackageName("git", "1.2.3").serialize()).toBe("git@1.2.3");
|
||||
});
|
||||
|
||||
it("serializes and deserializes cache keys", () => {
|
||||
const key = new CacheKey("v1", "4", "arm64", ["curl=1", "git=2"]);
|
||||
expect(key.serialize()).toBe("v1 | 4 | arm64 | curl=1,git=2");
|
||||
|
||||
expect(CacheKey.deserialize(key.serialize())).toEqual(
|
||||
new CacheKey("v1", "4", "arm64", ["curl=1", "git=2"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid serialized cache keys", () => {
|
||||
expect(() => CacheKey.deserialize("invalid")).toThrow(
|
||||
/Invalid serialized cache key/,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds cache paths under the current home directory", () => {
|
||||
const cache = new Cache("custom-cache", {
|
||||
run: vi.fn(),
|
||||
} as never);
|
||||
|
||||
expect(cache.path).toBe(path.join(os.homedir(), "custom-cache"));
|
||||
});
|
||||
|
||||
it("hashes cache keys without appending the default x86_64 architecture", async () => {
|
||||
const commandRunner = {
|
||||
run: vi.fn().mockResolvedValue({ stdout: "x86_64\n" }),
|
||||
};
|
||||
const cache = new Cache("cache-apt-pkgs", commandRunner as never);
|
||||
|
||||
const expectedHash = crypto
|
||||
.createHash("md5")
|
||||
.update("curl=1 git=2 @ 'v1' 4")
|
||||
.digest("hex");
|
||||
|
||||
await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
`cache-apt-pkgs_${expectedHash}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("hashes cache keys with non-default architectures", async () => {
|
||||
const commandRunner = {
|
||||
run: vi.fn().mockResolvedValue({ stdout: "arm64\n" }),
|
||||
};
|
||||
const cache = new Cache("cache-apt-pkgs", commandRunner as never);
|
||||
|
||||
const expectedHash = crypto
|
||||
.createHash("md5")
|
||||
.update("curl=1 git=2 @ 'v1' 4 arm64")
|
||||
.digest("hex");
|
||||
|
||||
await expect(cache.getKey(["curl=1", "git=2"], "v1")).resolves.toBe(
|
||||
`cache-apt-pkgs_${expectedHash}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,46 +3,57 @@ import path from "node:path";
|
|||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Manifest, ManifestEntry } from "../src/manifest.js";
|
||||
import { CacheKey } from "../src/cache.js";
|
||||
import { createPackageName } from "ts-apt";
|
||||
import { ActionPackageNames } from "../src/packages.js";
|
||||
|
||||
describe("manifest", () => {
|
||||
it("serializes and deserializes manifest entries", () => {
|
||||
const manifest = new Manifest(
|
||||
[
|
||||
new ManifestEntry("z", "2", undefined, ["/b", "/a"]),
|
||||
new ManifestEntry("a", "1", undefined, []),
|
||||
],
|
||||
"input",
|
||||
"cache-key",
|
||||
const cacheKey = new CacheKey(
|
||||
"v1",
|
||||
"4",
|
||||
"x86_64",
|
||||
"amd64",
|
||||
ActionPackageNames.fromInput("curl git"),
|
||||
);
|
||||
const manifest = new Manifest(
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
[
|
||||
new ManifestEntry(createPackageName("curl", "8.1.0", "amd64"), [
|
||||
"/b",
|
||||
"/a",
|
||||
]),
|
||||
],
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
const parsed = Manifest.deserialize(manifest.serialize());
|
||||
expect(parsed.entries[0]?.name).toBe("z");
|
||||
expect(parsed.cacheKey).toBe("cache-key");
|
||||
const parsed = Manifest.fromJSON(JSON.parse(JSON.stringify(manifest)));
|
||||
expect(parsed.entries).toHaveLength(1);
|
||||
expect(parsed.entries[0]?.packageName.serialize()).toBe("curl:amd64=8.1.0");
|
||||
expect(parsed.entries[0]?.filepaths).toEqual(["/a", "/b"]);
|
||||
});
|
||||
|
||||
it("writes and reads manifest files", () => {
|
||||
it("writes and reads manifest files", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-test-"));
|
||||
const filePath = path.join(tempDir, "manifest.json");
|
||||
const manifest = new Manifest(
|
||||
[new ManifestEntry("curl", "8.1", undefined, ["usr/bin/curl"])],
|
||||
"input",
|
||||
"cache-key",
|
||||
const cacheKey = new CacheKey(
|
||||
"v1",
|
||||
"4",
|
||||
"x86_64",
|
||||
"amd64",
|
||||
ActionPackageNames.fromInput("curl"),
|
||||
);
|
||||
const manifest = new Manifest(
|
||||
new Date("2026-01-01T00:00:00.000Z"),
|
||||
[
|
||||
new ManifestEntry(createPackageName("curl", "8.1.0", "amd64"), [
|
||||
"usr/bin/curl",
|
||||
]),
|
||||
],
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
manifest.writeToFile(filePath);
|
||||
const parsed = Manifest.readFromFile(filePath);
|
||||
await manifest.writeToFile(filePath);
|
||||
const parsed = await Manifest.readFromFile(filePath);
|
||||
expect(parsed.entries).toHaveLength(1);
|
||||
expect(parsed.entries[0]?.name).toBe("curl");
|
||||
});
|
||||
|
||||
it("throws for missing files", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "manifest-missing-"));
|
||||
expect(() =>
|
||||
Manifest.readFromFile(path.join(tempDir, "none.json")),
|
||||
).toThrow(/Manifest file not found/);
|
||||
expect(parsed.entries[0]?.packageName.serialize()).toBe("curl:amd64=8.1.0");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
174
test/packages.test.ts
Normal file
174
test/packages.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createPackageName } from "ts-apt";
|
||||
import {
|
||||
ActionPackageNames,
|
||||
updateAptLists,
|
||||
buildFileList,
|
||||
findInstallScript,
|
||||
} from "../src/packages.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const PACKAGE_NAME = createPackageName("test-package", "1.0.0", "amd64");
|
||||
|
||||
function makeTempRoot(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function ensureScriptsDir(root: string): string {
|
||||
const scriptsDir = path.join(root, "var", "lib", "dpkg", "info");
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
return scriptsDir;
|
||||
}
|
||||
|
||||
describe("packages", () => {
|
||||
it("buildFileList returns an array of package files", async () => {
|
||||
const root = makeTempRoot("pkg-files-");
|
||||
const scriptsDir = ensureScriptsDir(root);
|
||||
const packageFiles = [
|
||||
path.join(scriptsDir, "test-package.list"),
|
||||
path.join(scriptsDir, "test-package.md5sums"),
|
||||
path.join(scriptsDir, "test-package.postinst"),
|
||||
path.join(scriptsDir, "test-package.preinst"),
|
||||
];
|
||||
for (const filePath of packageFiles) {
|
||||
fs.writeFileSync(filePath, "", "utf8");
|
||||
}
|
||||
|
||||
const manager = {
|
||||
listInstalledFiles: vi.fn().mockResolvedValue(packageFiles),
|
||||
};
|
||||
const result = await buildFileList(PACKAGE_NAME, manager as any, root);
|
||||
|
||||
expect(result).toEqual(packageFiles.map((item) => item.slice(1)).sort());
|
||||
});
|
||||
|
||||
it("buildFileList returns an empty array when no files are found", async () => {
|
||||
const root = makeTempRoot("pkg-empty-");
|
||||
|
||||
const manager = {
|
||||
listInstalledFiles: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const result = await buildFileList(PACKAGE_NAME, manager as any, root);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("findInstallScript returns undefined when scripts directory does not exist", async () => {
|
||||
const missingRoot = makeTempRoot("pkg-missing-");
|
||||
|
||||
const result = await findInstallScript(
|
||||
PACKAGE_NAME,
|
||||
"preinst",
|
||||
missingRoot,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("findInstallScript returns undefined when no matching scripts are found", async () => {
|
||||
const root = makeTempRoot("pkg-no-match-");
|
||||
const scriptsDir = ensureScriptsDir(root);
|
||||
fs.writeFileSync(
|
||||
path.join(scriptsDir, "other-package.preinst"),
|
||||
"",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await findInstallScript(PACKAGE_NAME, "preinst", root);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("findInstallScript returns the first matching script when multiple are found", async () => {
|
||||
const root = makeTempRoot("pkg-match-");
|
||||
const scriptsDir = ensureScriptsDir(root);
|
||||
fs.writeFileSync(
|
||||
path.join(scriptsDir, "test-package.postinst"),
|
||||
"",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(scriptsDir, "test-package.preinst"), "", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(scriptsDir, "test-package.preinst.backup"),
|
||||
"",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await findInstallScript(PACKAGE_NAME, "preinst", root);
|
||||
expect(result).toBe(path.join(scriptsDir, "test-package.preinst"));
|
||||
});
|
||||
|
||||
it("updateAptLists calls the package manager's update method", async () => {
|
||||
const aptListsPath = makeTempRoot("apt-lists-");
|
||||
|
||||
const mockUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const packageManager = {
|
||||
update: mockUpdate,
|
||||
};
|
||||
|
||||
await updateAptLists(
|
||||
packageManager as any,
|
||||
mockLogger as any,
|
||||
aptListsPath,
|
||||
);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("ActionPackageNames", () => {
|
||||
it("fromInput parses APT serialized input", () => {
|
||||
const input = "test-package=1.0.0 amd64 other-package=2.0.0 i386";
|
||||
const result = ActionPackageNames.fromInput(input);
|
||||
expect(result.toArray()).toEqual([
|
||||
createPackageName("other-package", "2.0.0", "i386"),
|
||||
createPackageName("test-package", "1.0.0", "amd64"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fromInput returns an empty list when input is empty", () => {
|
||||
const result = ActionPackageNames.fromInput("");
|
||||
expect(result.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
it("fromInput returns an empty list when input is whitespace", () => {
|
||||
const result = ActionPackageNames.fromInput(" ");
|
||||
expect(result.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
it("fromJSON parses an array of serialized package names", () => {
|
||||
const json = [
|
||||
{ name: "test-package", version: "1.0.0", arch: "amd64" },
|
||||
{ name: "other-package", version: "2.0.0", arch: "i386" },
|
||||
];
|
||||
const result = ActionPackageNames.fromJSON(json);
|
||||
expect(result.toArray()).toEqual([
|
||||
createPackageName("other-package", "2.0.0", "i386"),
|
||||
createPackageName("test-package", "1.0.0", "amd64"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fromJSON returns an empty list when input is empty", () => {
|
||||
const result = ActionPackageNames.fromJSON([]);
|
||||
expect(result.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
it("length returns the number of package names", () => {
|
||||
const input = "test-package=1.0.0 amd64 other-package=2.0.0 i386";
|
||||
const result = ActionPackageNames.fromInput(input);
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("toArray returns a sorted array of package names", () => {
|
||||
const input = "test-package=1.0.0 amd64 other-package=2.0.0 i386";
|
||||
const result = ActionPackageNames.fromInput(input);
|
||||
expect(result.toArray()).toEqual([
|
||||
createPackageName("other-package", "2.0.0", "i386"),
|
||||
createPackageName("test-package", "1.0.0", "amd64"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"paths": {
|
||||
"ts-apt": [
|
||||
"./node_modules/ts-apt/src/index.ts"
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@
|
|||
],
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/globals"
|
||||
"node"
|
||||
],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@
|
|||
"types": [
|
||||
"node"
|
||||
],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue