From 749c7bfbbd7920b34da52995b5f586499b59f2f6 Mon Sep 17 00:00:00 2001 From: Mike Orozco Date: Sun, 19 Jul 2026 22:49:29 -0500 Subject: [PATCH 1/2] feat: publish scheduled runs as native tasks --- .github/workflows/ci.yml | 44 +++++++ .github/workflows/release.yml | 70 +++++++++++ .gitignore | 5 + .zdp/plugin.json | 4 +- .zdp/update.json | 4 + LICENSE | 21 ++++ README.md | 40 ++++++ bun.lock | 36 ++++++ package.json | 31 +++++ scripts/build.ts | 36 ++++++ scripts/package-release.ts | 136 ++++++++++++++++++++ scripts/release-helpers.ts | 12 ++ sdk/index.ts | 78 ++++++++++++ src/atomic.ts | 13 ++ src/main.ts | 67 ++++++++-- src/renderer.tsx | 2 +- src/schemas.ts | 8 +- tests/cron.test.ts | 26 ++++ tests/fixtures/live-verification-job.ts | 43 +++++++ tests/release.test.ts | 15 +++ tests/scheduler.test.ts | 158 ++++++++++++++++++++++++ tests/schemas.test.ts | 40 ++++++ tsconfig.json | 16 +++ 23 files changed, 891 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .zdp/update.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 scripts/build.ts create mode 100644 scripts/package-release.ts create mode 100644 scripts/release-helpers.ts create mode 100644 sdk/index.ts create mode 100644 src/atomic.ts create mode 100644 tests/cron.test.ts create mode 100644 tests/fixtures/live-verification-job.ts create mode 100644 tests/release.test.ts create mode 100644 tests/scheduler.test.ts create mode 100644 tests/schemas.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1f6f680 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate extension + runs-on: windows-latest + timeout-minutes: 15 + + steps: + - name: Check out source + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Reject generated or private files + shell: pwsh + run: | + $tracked = @(git ls-files -- 'dist/**' 'data/**') + if ($tracked.Count -gt 0) { + $tracked | ForEach-Object { Write-Error "Generated or private path is tracked: $_" } + exit 1 + } + + - name: Verify release metadata + run: bun scripts/package-release.ts --verify-only + + - name: Typecheck, test, and build + run: bun run check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..928df56 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Publish extension release + runs-on: windows-latest + timeout-minutes: 20 + + steps: + - name: Check out tagged source + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify tag and project versions + shell: pwsh + run: bun scripts/package-release.ts --verify-only --tag "$env:GITHUB_REF_NAME" + + - name: Typecheck, test, and build + run: bun run check + + - name: Package extension release + shell: pwsh + run: bun scripts/package-release.ts --tag "$env:GITHUB_REF_NAME" + + - name: Publish immutable GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$env:GITHUB_REF_NAME" --repo "$env:GITHUB_REPOSITORY" 2>$null + if ($LASTEXITCODE -eq 0) { + throw "Release $env:GITHUB_REF_NAME already exists; refusing to overwrite it." + } + + $assets = @(Get-ChildItem "dist/release" -File) + if ($assets.Count -ne 3) { + throw "Expected ZIP, SHA-256, and extension-update.json assets; found $($assets.Count)." + } + foreach ($name in @("zcode-scheduler-$env:GITHUB_REF_NAME.zip", "zcode-scheduler-$env:GITHUB_REF_NAME.zip.sha256", "extension-update.json")) { + if (-not (Test-Path "dist/release/$name" -PathType Leaf)) { throw "Missing release asset: $name" } + } + + $assetPaths = @($assets | ForEach-Object { $_.FullName }) + gh release create "$env:GITHUB_REF_NAME" $assetPaths ` + --repo "$env:GITHUB_REPOSITORY" ` + --verify-tag ` + --generate-notes ` + --title "ZCode Scheduler $env:GITHUB_REF_NAME" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4766167 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +*.log +*.tsbuildinfo diff --git a/.zdp/plugin.json b/.zdp/plugin.json index 069f9c9..af248de 100644 --- a/.zdp/plugin.json +++ b/.zdp/plugin.json @@ -2,14 +2,14 @@ "apiVersion": 1, "id": "scheduler", "name": "Scheduler", - "version": "0.1.2", + "version": "0.1.3", "description": "Create normal ZCode tasks from timezone-aware cron schedules while ZCode is open.", "entrypoints": { "main": "dist/main.cjs", "renderer": "dist/renderer.js" }, "engines": { - "host": ">=0.1.0 <1", + "host": ">=0.2.0 <1", "zcode": ">=3.3.6" }, "pages": [ diff --git a/.zdp/update.json b/.zdp/update.json new file mode 100644 index 0000000..72558fc --- /dev/null +++ b/.zdp/update.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "manifestUrl": "https://github.com/notmike101/zcode-scheduler/releases/latest/download/extension-update.json" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd1eacf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 notmike101 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e9ff17 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# ZCode Scheduler + +Scheduler is an extension for [ZCode Desktop Extensions](https://github.com/notmike101/zcode-extensions). It creates timezone-aware recurring ZCode tasks while the desktop app is open. + +Scheduled runs are ordinary persistent ZCode tasks. They appear immediately in the normal sidebar as `⏰ Job name`, can be opened while they run, remain available after completion, and archive like any other task. + +## Requirements + +- ZCode Desktop Extensions 0.2.0 or newer +- ZCode 3.3.6 or newer +- Windows + +## Install + +Open **Extensions → Available** in ZCode and install Scheduler. The host verifies the release archive checksum before staging it. New installs and updates take effect on the next ZCode launch. + +Manual installation is also supported: download `zcode-scheduler-v0.1.3.zip` from the [latest release](https://github.com/notmike101/zcode-scheduler/releases/latest), extract the `scheduler` folder, and select it from **Extensions → Installed → Install folder**. + +## Scheduling behavior + +- Uses standard five-field cron expressions: minute, hour, day, month, weekday. +- Stores an IANA timezone with every job. +- Runs only while ZCode is open; missed runs are skipped. +- Supports skip, queue-one, and bounded parallel overlap policies. +- Keeps job definitions and run history in the extension's private data directory. +- Backfills retained pre-0.1.3 run sessions into the native task sidebar once when their workspace can be resolved. + +## Development + +```powershell +bun install +bun run check +bun run release:package -- --tag v0.1.3 +``` + +The release command writes a ZIP, its SHA-256 checksum, and `extension-update.json` to `dist/release`. + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e4300be --- /dev/null +++ b/bun.lock @@ -0,0 +1,36 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "zcode-scheduler", + "dependencies": { + "croner": "^10.0.1", + "preact": "^10.27.2", + "zod": "^4.1.12", + }, + "devDependencies": { + "@types/bun": "^1.3.4", + "@types/node": "^24.10.1", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="], + + "preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dd9bca4 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "zcode-scheduler", + "version": "0.1.3", + "description": "Schedule normal, visible ZCode tasks with timezone-aware cron expressions.", + "private": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/notmike101/zcode-scheduler.git" + }, + "homepage": "https://github.com/notmike101/zcode-scheduler#readme", + "type": "module", + "packageManager": "bun@1.3.14", + "scripts": { + "build": "bun scripts/build.ts", + "typecheck": "tsc --noEmit", + "test": "bun test", + "check": "bun run typecheck && bun test && bun run build", + "release:package": "bun scripts/package-release.ts" + }, + "dependencies": { + "croner": "^10.0.1", + "preact": "^10.27.2", + "zod": "^4.1.12" + }, + "devDependencies": { + "@types/bun": "^1.3.4", + "@types/node": "^24.10.1", + "typescript": "^5.9.3" + } +} diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..c1c531d --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,36 @@ +import {mkdir, rm} from "node:fs/promises"; +import path from "node:path"; + +const root = path.resolve(import.meta.dir, ".."); +const dist = path.join(root, "dist"); + +await rm(dist, {recursive: true, force: true}); +await mkdir(dist, {recursive: true}); + +await build({ + entrypoints: [path.join(root, "src", "main.ts")], + outdir: dist, + target: "node", + format: "cjs", + naming: "[name].cjs", +}); + +await build({ + entrypoints: [path.join(root, "src", "renderer.tsx")], + outdir: dist, + target: "browser", + format: "iife", + naming: "[name].js", + loader: {".css": "text"}, +}); + +console.log(`Built Scheduler to ${dist}`); + +async function build(options: Bun.BuildConfig): Promise { + const result = await Bun.build({ + sourcemap: "external", + minify: false, + ...options, + }); + if (!result.success) throw new AggregateError(result.logs, "Bun build failed"); +} diff --git a/scripts/package-release.ts b/scripts/package-release.ts new file mode 100644 index 0000000..0091f60 --- /dev/null +++ b/scripts/package-release.ts @@ -0,0 +1,136 @@ +import {createHash} from "node:crypto"; +import {createReadStream} from "node:fs"; +import {copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {assertReleaseVersion, releaseBaseName} from "./release-helpers.ts"; + +const root = path.resolve(import.meta.dir, ".."); +const args = process.argv.slice(2); +const packageJson = await readJson(path.join(root, "package.json")) as {version?: unknown}; +const pluginManifest = await readJson(path.join(root, ".zdp", "plugin.json")) as { + id?: unknown; + apiVersion?: unknown; + version?: unknown; + engines?: {host?: unknown; zcode?: unknown}; +}; + +if (typeof packageJson.version !== "string") throw new Error("package.json has no version"); +if (typeof pluginManifest.version !== "string") throw new Error(".zdp/plugin.json has no version"); +const tag = valueAfter("--tag") ?? process.env.GITHUB_REF_NAME ?? `v${packageJson.version}`; +const version = assertReleaseVersion(tag, packageJson.version, pluginManifest.version); +assertManifest(pluginManifest); + +if (args.includes("--verify-only")) { + console.log(`Release metadata is consistent for ${tag}.`); + process.exit(0); +} + +if (process.platform !== "win32") throw new Error("Release packaging currently requires Windows"); + +for (const required of [ + path.join(root, "dist", "main.cjs"), + path.join(root, "dist", "renderer.js"), + path.join(root, ".zdp", "update.json"), +]) await requireFile(required); + +const outputRoot = path.resolve(valueAfter("--output") ?? path.join(root, "dist", "release")); +const baseName = releaseBaseName(version); +const archive = path.join(outputRoot, `${baseName}.zip`); +const checksum = `${archive}.sha256`; +const feedPath = path.join(outputRoot, "extension-update.json"); +const temporaryRoot = await mkdtemp(path.join(tmpdir(), "zcode-scheduler-release-")); +const stage = path.join(temporaryRoot, "scheduler"); + +await rm(outputRoot, {recursive: true, force: true}); +await mkdir(path.join(stage, ".zdp"), {recursive: true}); +await mkdir(path.join(stage, "dist"), {recursive: true}); +await mkdir(outputRoot, {recursive: true}); + +try { + await Promise.all([ + copyRequired(path.join(root, ".zdp", "plugin.json"), path.join(stage, ".zdp", "plugin.json")), + copyRequired(path.join(root, ".zdp", "update.json"), path.join(stage, ".zdp", "update.json")), + copyRequired(path.join(root, "dist", "main.cjs"), path.join(stage, "dist", "main.cjs")), + copyRequired(path.join(root, "dist", "renderer.js"), path.join(stage, "dist", "renderer.js")), + copyRequired(path.join(root, "README.md"), path.join(stage, "README.md")), + copyRequired(path.join(root, "LICENSE"), path.join(stage, "LICENSE")), + ]); + + await compress(stage, archive); + const digest = await sha256(archive); + const size = (await stat(archive)).size; + await writeFile(checksum, `${digest} *${path.basename(archive)}\n`, "utf8"); + await writeFile(feedPath, `${JSON.stringify({ + schemaVersion: 1, + id: pluginManifest.id, + apiVersion: pluginManifest.apiVersion, + version, + engines: pluginManifest.engines, + archive: { + url: `https://github.com/notmike101/zcode-scheduler/releases/download/${tag}/${baseName}.zip`, + sha256: digest, + size, + }, + releaseUrl: `https://github.com/notmike101/zcode-scheduler/releases/tag/${tag}`, + publishedAt: process.env.RELEASE_PUBLISHED_AT ?? new Date().toISOString(), + }, null, 2)}\n`, "utf8"); + + console.log(JSON.stringify({tag, version, archive, checksum, feed: feedPath, sha256: digest, size}, null, 2)); +} finally { + await rm(temporaryRoot, {recursive: true, force: true}); +} + +function valueAfter(flag: string): string | undefined { + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : undefined; +} + +function assertManifest(manifest: typeof pluginManifest): asserts manifest is { + id: string; + apiVersion: 1; + version: string; + engines: {host: string; zcode: string}; +} { + if (manifest.id !== "scheduler") throw new Error("Extension manifest id must be scheduler"); + if (manifest.apiVersion !== 1) throw new Error("Extension manifest apiVersion must be 1"); + if (!manifest.engines || typeof manifest.engines.host !== "string" || typeof manifest.engines.zcode !== "string") { + throw new Error("Extension manifest engines are missing"); + } +} + +async function readJson(filePath: string): Promise { + return JSON.parse(await readFile(filePath, "utf8")); +} + +async function copyRequired(source: string, destination: string): Promise { + await requireFile(source); + await mkdir(path.dirname(destination), {recursive: true}); + await copyFile(source, destination); +} + +async function requireFile(filePath: string): Promise { + const info = await stat(filePath).catch(() => undefined); + if (!info?.isFile()) throw new Error(`Required release file is missing: ${filePath}`); +} + +async function compress(source: string, destination: string): Promise { + const command = `Compress-Archive -LiteralPath ${quotePowerShell(source)} -DestinationPath ${quotePowerShell(destination)} -CompressionLevel Optimal -Force`; + const child = Bun.spawn(["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + cwd: root, + stdout: "inherit", + stderr: "inherit", + }); + const code = await child.exited; + if (code !== 0) throw new Error(`Compress-Archive exited ${code}`); +} + +function quotePowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +async function sha256(filePath: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(filePath)) hash.update(chunk); + return hash.digest("hex"); +} diff --git a/scripts/release-helpers.ts b/scripts/release-helpers.ts new file mode 100644 index 0000000..e8d7087 --- /dev/null +++ b/scripts/release-helpers.ts @@ -0,0 +1,12 @@ +export function assertReleaseVersion(tag: string, packageVersion: string, manifestVersion: string): string { + const match = /^v(\d+\.\d+\.\d+)$/.exec(tag); + if (!match) throw new Error(`Release tag ${tag} must use strict vX.Y.Z syntax`); + const version = match[1]!; + if (version !== packageVersion) throw new Error(`Tag ${tag} does not match package version ${packageVersion}`); + if (version !== manifestVersion) throw new Error(`Tag ${tag} does not match extension manifest version ${manifestVersion}`); + return version; +} + +export function releaseBaseName(version: string): string { + return `zcode-scheduler-v${version}`; +} diff --git a/sdk/index.ts b/sdk/index.ts new file mode 100644 index 0000000..ea8b217 --- /dev/null +++ b/sdk/index.ts @@ -0,0 +1,78 @@ +/** Type-only ZCode Desktop Extensions API v1 contract, pinned to host v0.2.0. */ +export type ExtensionDisposable = {dispose: () => unknown | Promise}; + +export type ExtensionLogger = { + child: (scope: string) => ExtensionLogger; + debug: (message: string, data?: unknown) => Promise; + info: (message: string, data?: unknown) => Promise; + warn: (message: string, data?: unknown) => Promise; + error: (message: string, data?: unknown) => Promise; +}; + +export type ExtensionModelRef = {providerId: string; modelId: string; variant?: string}; + +export type ExtensionTaskSpec = { + workspacePath: string; + prompt: string; + title?: string; + mode: "plan" | "build" | "edit" | "yolo"; + model?: ExtensionModelRef; + thoughtLevel?: string; + toolAllowlist?: string[]; + toolDenylist?: string[]; + timeoutMs?: number; +}; + +export type ExtensionTaskResultStatus = "succeeded" | "failed" | "cancelled" | "timed_out" | "lost" | "needs_attention"; +export type ExtensionTaskResult = {sessionId: string; status: ExtensionTaskResultStatus; error?: string}; +export type ExtensionTaskRunHandle = { + sessionId: string; + completion: Promise; + stop: () => Promise; +}; + +export type ExtensionManifest = { + apiVersion: 1; + id: string; + name: string; + version: string; + description?: string; + entrypoints: {main?: string; renderer?: string}; + engines: {host: string; zcode: string}; + pages: Array<{id: string; title: string}>; +}; + +export type ExtensionContext = { + manifest: ExtensionManifest; + dataDir: string; + logger: ExtensionLogger; + ipc: { + handle: (method: string, handler: (payload: unknown) => unknown | Promise) => ExtensionDisposable; + emit: (event: string, payload?: unknown) => void; + }; + lifecycle: {onResume: (handler: () => void) => ExtensionDisposable}; + zcode: { + readWorkspaceState: (workspacePath: string) => Promise; + tasks: { + run: (spec: ExtensionTaskSpec) => Promise; + ensureVisible: (spec: {sessionId: string; workspacePath: string; title?: string}) => Promise; + }; + }; +}; + +export type ExtensionBridge = { + invoke(method: string, payload?: unknown): Promise; + on(listener: (event: string, payload: unknown) => void): () => void; +}; + +export type RendererExtension = { + id: string; + mount(container: HTMLElement, bridge: ExtensionBridge): void | (() => void); +}; + +declare global { + interface Window { + zcodeDesktopPlugins?: ExtensionBridge; + ZDP_REGISTER_PLUGIN_RENDERER?: (extension: RendererExtension) => void; + } +} diff --git a/src/atomic.ts b/src/atomic.ts new file mode 100644 index 0000000..c47e410 --- /dev/null +++ b/src/atomic.ts @@ -0,0 +1,13 @@ +import {mkdir, rename, rm, writeFile} from "node:fs/promises"; +import path from "node:path"; + +export async function writeJsonAtomic(filePath: string, value: unknown): Promise { + await mkdir(path.dirname(filePath), {recursive: true}); + const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(temporary, filePath); + } finally { + await rm(temporary, {force: true}).catch(() => undefined); + } +} diff --git a/src/main.ts b/src/main.ts index ac71eaa..11418b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,19 @@ import {randomUUID} from "node:crypto"; import {appendFile, mkdir, readFile, readdir, rename, rm, stat, writeFile} from "node:fs/promises"; import path from "node:path"; -import {writeJsonAtomic} from "../../../src/shared/atomic.ts"; -import type {PluginContext} from "../../../src/host/plugin-manager.ts"; -import type {TaskRunHandle} from "../../../src/protocol/task-service.ts"; +import {writeJsonAtomic} from "./atomic.ts"; +import type {ExtensionContext, ExtensionTaskRunHandle} from "../sdk/index.ts"; import {editableJobSchema, jobSchema, runRecordSchema, type EditableJob, type RunRecord, type SchedulerJob} from "./schemas.ts"; import {nextRun, preview, systemTimezone, validateCron, validateTimezone} from "./cron.ts"; type Schedule = {timer: NodeJS.Timeout; nextAt: Date}; -type ActiveRun = {record: RunRecord; handle: TaskRunHandle}; +type ActiveRun = {record: RunRecord; handle: ExtensionTaskRunHandle}; -class SchedulerPlugin { - readonly #context: PluginContext; +export class SchedulerPlugin { + readonly #context: ExtensionContext; readonly #jobsFile: string; readonly #historyFile: string; + readonly #migrationsFile: string; readonly #jobs = new Map(); readonly #schedules = new Map(); readonly #active = new Map(); @@ -23,10 +23,11 @@ class SchedulerPlugin { #resumeHandler = () => void this.#reconcileAfterResume(); #resumeDisposable?: {dispose: () => unknown | Promise}; - constructor(context: PluginContext) { + constructor(context: ExtensionContext) { this.#context = context; this.#jobsFile = path.join(context.dataDir, "jobs.json"); this.#historyFile = path.join(context.dataDir, "history.jsonl"); + this.#migrationsFile = path.join(context.dataDir, "migrations.json"); } async initialize(): Promise { @@ -35,6 +36,7 @@ class SchedulerPlugin { for (const job of this.#jobs.values()) this.#arm(job); this.#resumeDisposable = this.#context.lifecycle.onResume(this.#resumeHandler); this.#registerIpc(); + void this.#backfillVisibleSessions(); await this.#context.logger.info("Scheduler initialized", {jobCount: this.#jobs.size}); } @@ -192,12 +194,13 @@ class SchedulerPlugin { const record: RunRecord = { id: randomUUID(), jobId: job.id, jobName: job.name, source, - scheduledAt: scheduledAt.toISOString(), startedAt: new Date().toISOString(), status: "running", + scheduledAt: scheduledAt.toISOString(), startedAt: new Date().toISOString(), status: "running", workspacePath: job.workspacePath, }; try { const handle = await this.#context.zcode.tasks.run({ workspacePath: job.workspacePath, prompt: job.prompt, + title: `⏰ ${job.name}`, mode: job.mode, ...(job.model ? {model: job.model} : {}), ...(job.thoughtLevel ? {thoughtLevel: job.thoughtLevel} : {}), @@ -239,7 +242,7 @@ class SchedulerPlugin { async #recordTerminal(job: SchedulerJob, scheduledAt: Date, source: RunRecord["source"], status: RunRecord["status"], error?: string) { await this.#appendHistory(runRecordSchema.parse({ id: randomUUID(), jobId: job.id, jobName: job.name, source, - scheduledAt: scheduledAt.toISOString(), finishedAt: new Date().toISOString(), status, + scheduledAt: scheduledAt.toISOString(), finishedAt: new Date().toISOString(), status, workspacePath: job.workspacePath, ...(error ? {error} : {}), })); this.#changed(); @@ -284,6 +287,50 @@ class SchedulerPlugin { await writeJsonAtomic(this.#jobsFile, {schemaVersion: 1, jobs: [...this.#jobs.values()]}); } + async #backfillVisibleSessions(): Promise { + try { + const migrations = await this.#readMigrations(); + if (migrations.sidebarTasksV1) return; + const seen = new Set(); + let restored = 0; + let skipped = 0; + for (const record of this.#history) { + if (!record.sessionId || seen.has(record.sessionId)) continue; + seen.add(record.sessionId); + const workspacePath = record.workspacePath ?? this.#jobs.get(record.jobId)?.workspacePath; + if (!workspacePath) { + skipped += 1; + await this.#context.logger.warn("Skipped scheduled task sidebar backfill without a workspace", {sessionId: record.sessionId, jobId: record.jobId}); + continue; + } + await this.#context.zcode.tasks.ensureVisible({ + sessionId: record.sessionId, + workspacePath, + title: `⏰ ${record.jobName}`, + }); + restored += 1; + } + await writeJsonAtomic(this.#migrationsFile, { + ...migrations, + schemaVersion: 1, + sidebarTasksV1: {completedAt: new Date().toISOString(), restored, skipped}, + }); + await this.#context.logger.info("Scheduled task sidebar backfill completed", {restored, skipped}); + } catch (error) { + await this.#context.logger.warn("Scheduled task sidebar backfill will retry on the next launch", {error}); + } + } + + async #readMigrations(): Promise<{schemaVersion?: number; sidebarTasksV1?: unknown}> { + try { + const value = JSON.parse(await readFile(this.#migrationsFile, "utf8")); + return value && typeof value === "object" ? value : {}; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return {}; + } + } + #activeForJob(jobId: string): ActiveRun[] { return [...this.#active.values()].filter(({record}) => record.jobId === jobId); } @@ -312,7 +359,7 @@ class SchedulerPlugin { let scheduler: SchedulerPlugin | undefined; -export async function activate(context: PluginContext) { +export async function activate(context: ExtensionContext) { scheduler = new SchedulerPlugin(context); await scheduler.initialize(); return {dispose: () => scheduler?.dispose()}; diff --git a/src/renderer.tsx b/src/renderer.tsx index bad087f..02be2cb 100644 --- a/src/renderer.tsx +++ b/src/renderer.tsx @@ -1,6 +1,6 @@ import {render} from "preact"; import {useEffect, useMemo, useState} from "preact/hooks"; -import type {ZdpBridge} from "../../../src/renderer/globals.d.ts"; +import type {ExtensionBridge as ZdpBridge} from "../sdk/index.ts"; import type {RunRecord, SchedulerJob} from "./schemas.ts"; import styles from "./scheduler.css"; diff --git a/src/schemas.ts b/src/schemas.ts index 73d0722..a056d34 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,5 +1,10 @@ import {z} from "zod"; -import {modelRefSchema} from "../../../src/shared/schemas.ts"; + +const modelRefSchema = z.object({ + providerId: z.string().min(1), + modelId: z.string().min(1), + variant: z.string().min(1).optional(), +}).strict(); export const jobSchema = z.object({ schemaVersion: z.literal(1), @@ -60,6 +65,7 @@ export const runRecordSchema = z.object({ finishedAt: z.string().datetime().optional(), status: runStatusSchema, sessionId: z.string().optional(), + workspacePath: z.string().min(1).optional(), error: z.string().optional(), }).strict(); diff --git a/tests/cron.test.ts b/tests/cron.test.ts new file mode 100644 index 0000000..9709506 --- /dev/null +++ b/tests/cron.test.ts @@ -0,0 +1,26 @@ +import {describe, expect, test} from "bun:test"; +import {nextRun, preview, validateCron, validateTimezone} from "../src/cron.ts"; + +describe("scheduler cron contract", () => { + test("accepts standard five-field expressions and returns stable UTC runs", () => { + expect(preview("0 9 * * 1-5", "UTC", new Date("2026-07-17T12:00:00.000Z")).nextRuns).toEqual([ + "2026-07-20T09:00:00.000Z", + "2026-07-21T09:00:00.000Z", + "2026-07-22T09:00:00.000Z", + "2026-07-23T09:00:00.000Z", + "2026-07-24T09:00:00.000Z", + ]); + }); + + test("rejects seconds and Quartz-only syntax", () => { + expect(() => validateCron("0 0 9 * * 1-5")).toThrow("exactly five fields"); + expect(() => validateCron("0 9 ? * MON")).toThrow("unsupported syntax"); + }); + + test("validates IANA timezones and honors timezone offsets", () => { + expect(() => validateTimezone("America/Chicago")).not.toThrow(); + expect(() => validateTimezone("Not/A_Zone")).toThrow("Invalid IANA timezone"); + expect(nextRun("0 9 * * *", "America/Chicago", new Date("2026-07-19T00:00:00.000Z"))?.toISOString()) + .toBe("2026-07-19T14:00:00.000Z"); + }); +}); diff --git a/tests/fixtures/live-verification-job.ts b/tests/fixtures/live-verification-job.ts new file mode 100644 index 0000000..a239190 --- /dev/null +++ b/tests/fixtures/live-verification-job.ts @@ -0,0 +1,43 @@ +import {readFile} from "node:fs/promises"; +import path from "node:path"; +import {writeJsonAtomic} from "../../src/atomic.ts"; +import {jobSchema} from "../../src/schemas.ts"; + +const operation = process.argv[2]; +const dataDir = process.argv[3]; +if ((operation !== "add" && operation !== "remove") || !dataDir) { + throw new Error("Usage: bun tests/fixtures/live-verification-job.ts "); +} + +const verificationJobId = "877e1325-ecda-4ef8-98c3-78d126be19b9"; +const jobsFile = path.resolve(dataDir, "jobs.json"); +const parsed = JSON.parse(await readFile(jobsFile, "utf8")) as {jobs?: unknown[]}; +const jobs = (parsed.jobs ?? []).filter((value) => !(value && typeof value === "object" && "id" in value && value.id === verificationJobId)); + +if (operation === "add") { + const scheduled = new Date(Date.now() + 2 * 60_000); + scheduled.setUTCSeconds(0, 0); + const timestamp = new Date().toISOString(); + jobs.push(jobSchema.parse({ + schemaVersion: 1, + id: verificationJobId, + name: "Live running sidebar verification", + enabled: true, + cron: `${scheduled.getUTCMinutes()} ${scheduled.getUTCHours()} ${scheduled.getUTCDate()} ${scheduled.getUTCMonth() + 1} *`, + timezone: "UTC", + workspacePath: "D:\\zcode-patcher", + prompt: "Run the non-modifying PowerShell command Start-Sleep -Seconds 25, then read README.md and return a concise three-bullet summary. Do not modify files.", + mode: "plan", + overlapPolicy: "skip", + maxParallel: 1, + missedPolicy: "skip", + graceMs: 300_000, + createdAt: timestamp, + updatedAt: timestamp, + })); + console.log(JSON.stringify({operation, scheduledAt: scheduled.toISOString(), jobId: verificationJobId})); +} else { + console.log(JSON.stringify({operation, jobId: verificationJobId})); +} + +await writeJsonAtomic(jobsFile, {schemaVersion: 1, jobs}); diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..c71bf1c --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,15 @@ +import {describe, expect, test} from "bun:test"; +import {assertReleaseVersion, releaseBaseName} from "../scripts/release-helpers.ts"; + +describe("release metadata", () => { + test("accepts a matching semantic version tag", () => { + expect(assertReleaseVersion("v0.1.3", "0.1.3", "0.1.3")).toBe("0.1.3"); + expect(releaseBaseName("0.1.3")).toBe("zcode-scheduler-v0.1.3"); + }); + + test("rejects malformed or inconsistent versions", () => { + expect(() => assertReleaseVersion("release-0.1.3", "0.1.3", "0.1.3")).toThrow("strict vX.Y.Z"); + expect(() => assertReleaseVersion("v0.1.4", "0.1.3", "0.1.3")).toThrow("package version"); + expect(() => assertReleaseVersion("v0.1.3", "0.1.3", "0.1.2")).toThrow("manifest version"); + }); +}); diff --git a/tests/scheduler.test.ts b/tests/scheduler.test.ts new file mode 100644 index 0000000..283525d --- /dev/null +++ b/tests/scheduler.test.ts @@ -0,0 +1,158 @@ +import {afterEach, describe, expect, test} from "bun:test"; +import {mkdir, mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import type {ExtensionContext, ExtensionTaskSpec} from "../sdk/index.ts"; +import {SchedulerPlugin} from "../src/main.ts"; + +const roots: string[] = []; +const jobId = "6a1d0f7a-8eef-47aa-8638-3f2629ea1d5d"; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, {recursive: true, force: true}))); +}); + +describe("Scheduler native task integration", () => { + test("creates a normal titled task and preserves its workspace in history", async () => { + const dataDir = await createDataDir(); + await writeJobs(dataDir); + const harness = createContext(dataDir); + const plugin = new SchedulerPlugin(harness.context); + await plugin.initialize(); + + await harness.handlers.get("run-now")!({id: jobId}); + expect(harness.runs).toHaveLength(1); + expect(harness.runs[0]).toMatchObject({ + workspacePath: "D:\\project", + prompt: "Review open work", + title: "⏰ Morning review", + mode: "plan", + }); + + harness.finish({sessionId: "session-new", status: "succeeded"}); + await eventually(async () => { + const history = await readFile(path.join(dataDir, "history.jsonl"), "utf8").catch(() => ""); + return history.includes("session-new"); + }); + const history = await readFile(path.join(dataDir, "history.jsonl"), "utf8"); + expect(history).toContain('"workspacePath":"D:\\\\project"'); + await plugin.dispose(); + }); + + test("backfills retained legacy sessions into the native sidebar once", async () => { + const dataDir = await createDataDir(); + await writeJobs(dataDir); + await writeFile(path.join(dataDir, "history.jsonl"), `${JSON.stringify({ + id: "0388dc64-18aa-4bb6-9876-0903bd72ec10", + jobId, + jobName: "Morning review", + source: "cron", + scheduledAt: "2026-07-18T14:00:00.000Z", + finishedAt: "2026-07-18T14:01:00.000Z", + status: "succeeded", + sessionId: "session-legacy", + })}\n`, "utf8"); + + const harness = createContext(dataDir); + const plugin = new SchedulerPlugin(harness.context); + await plugin.initialize(); + await eventually(() => harness.visible.length === 1); + expect(harness.visible[0]).toEqual({ + sessionId: "session-legacy", + workspacePath: "D:\\project", + title: "⏰ Morning review", + }); + await eventually(async () => { + const migrations = await readFile(path.join(dataDir, "migrations.json"), "utf8").catch(() => ""); + return migrations.includes("sidebarTasksV1"); + }); + await plugin.dispose(); + + const second = createContext(dataDir); + const restarted = new SchedulerPlugin(second.context); + await restarted.initialize(); + await Bun.sleep(25); + expect(second.visible).toHaveLength(0); + await restarted.dispose(); + }); +}); + +async function createDataDir(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "zcode-scheduler-test-")); + roots.push(root); + const dataDir = path.join(root, "data"); + await mkdir(dataDir, {recursive: true}); + return dataDir; +} + +async function writeJobs(dataDir: string): Promise { + await writeFile(path.join(dataDir, "jobs.json"), `${JSON.stringify({schemaVersion: 1, jobs: [{ + schemaVersion: 1, + id: jobId, + name: "Morning review", + enabled: false, + cron: "0 9 * * 1-5", + timezone: "America/Chicago", + workspacePath: "D:\\project", + prompt: "Review open work", + mode: "plan", + overlapPolicy: "skip", + maxParallel: 4, + missedPolicy: "skip", + graceMs: 60_000, + createdAt: "2026-07-19T12:00:00.000Z", + updatedAt: "2026-07-19T12:00:00.000Z", + }]}, null, 2)}\n`, "utf8"); +} + +function createContext(dataDir: string) { + const handlers = new Map unknown | Promise>(); + const runs: ExtensionTaskSpec[] = []; + const visible: Array<{sessionId: string; workspacePath: string; title?: string}> = []; + let resolveCompletion!: (result: {sessionId: string; status: "succeeded"}) => void; + const completion = new Promise<{sessionId: string; status: "succeeded"}>((resolve) => { resolveCompletion = resolve; }); + const logger = { + child: () => logger, + debug: async () => undefined, + info: async () => undefined, + warn: async () => undefined, + error: async () => undefined, + }; + const context: ExtensionContext = { + manifest: { + apiVersion: 1, + id: "scheduler", + name: "Scheduler", + version: "0.1.3", + entrypoints: {}, + engines: {host: ">=0.2.0 <1", zcode: ">=3.3.6"}, + pages: [{id: "jobs", title: "Scheduler"}], + }, + dataDir, + logger, + ipc: { + handle(method, handler) { handlers.set(method, handler); return {dispose: () => handlers.delete(method)}; }, + emit() {}, + }, + lifecycle: {onResume: () => ({dispose() {}})}, + zcode: { + readWorkspaceState: async () => ({}), + tasks: { + async run(spec) { + runs.push(spec); + return {sessionId: "session-new", completion, stop: async () => undefined}; + }, + async ensureVisible(spec) { visible.push(spec); }, + }, + }, + }; + return {context, handlers, runs, visible, finish: resolveCompletion}; +} + +async function eventually(predicate: () => boolean | Promise): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await predicate()) return; + await Bun.sleep(10); + } + throw new Error("Condition was not reached"); +} diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts new file mode 100644 index 0000000..e08f579 --- /dev/null +++ b/tests/schemas.test.ts @@ -0,0 +1,40 @@ +import {describe, expect, test} from "bun:test"; +import {jobSchema, runRecordSchema} from "../src/schemas.ts"; + +const jobId = "6a1d0f7a-8eef-47aa-8638-3f2629ea1d5d"; + +describe("Scheduler persistence schemas", () => { + test("accepts the stable job contract", () => { + const parsed = jobSchema.parse({ + schemaVersion: 1, + id: jobId, + name: "Morning review", + enabled: true, + cron: "0 9 * * 1-5", + timezone: "America/Chicago", + workspacePath: "D:\\project", + prompt: "Review open work", + mode: "plan", + overlapPolicy: "skip", + missedPolicy: "skip", + createdAt: "2026-07-19T12:00:00.000Z", + updatedAt: "2026-07-19T12:00:00.000Z", + }); + expect(parsed.maxParallel).toBe(4); + expect(parsed.graceMs).toBe(60_000); + }); + + test("keeps legacy run records readable while persisting workspace paths for new runs", () => { + const base = { + id: "0388dc64-18aa-4bb6-9876-0903bd72ec10", + jobId, + jobName: "Morning review", + source: "cron" as const, + scheduledAt: "2026-07-19T12:00:00.000Z", + status: "succeeded" as const, + sessionId: "session-1", + }; + expect(runRecordSchema.parse(base).workspacePath).toBeUndefined(); + expect(runRecordSchema.parse({...base, workspacePath: "D:\\project"}).workspacePath).toBe("D:\\project"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6cbf089 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "types": ["bun", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "sdk/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"] +} From 11c4c0910541ec5ad1150d1225718bd1259ecf77 Mon Sep 17 00:00:00 2001 From: Mike Orozco Date: Sun, 19 Jul 2026 22:53:20 -0500 Subject: [PATCH 2/2] fix: ignore pull request refs in release checks --- scripts/package-release.ts | 9 +++++++-- scripts/release-helpers.ts | 9 +++++++++ tests/release.test.ts | 9 ++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/scripts/package-release.ts b/scripts/package-release.ts index 0091f60..8242ef4 100644 --- a/scripts/package-release.ts +++ b/scripts/package-release.ts @@ -3,7 +3,7 @@ import {createReadStream} from "node:fs"; import {copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import path from "node:path"; -import {assertReleaseVersion, releaseBaseName} from "./release-helpers.ts"; +import {assertReleaseVersion, releaseBaseName, resolveReleaseTag} from "./release-helpers.ts"; const root = path.resolve(import.meta.dir, ".."); const args = process.argv.slice(2); @@ -17,7 +17,12 @@ const pluginManifest = await readJson(path.join(root, ".zdp", "plugin.json")) as if (typeof packageJson.version !== "string") throw new Error("package.json has no version"); if (typeof pluginManifest.version !== "string") throw new Error(".zdp/plugin.json has no version"); -const tag = valueAfter("--tag") ?? process.env.GITHUB_REF_NAME ?? `v${packageJson.version}`; +const tag = resolveReleaseTag( + valueAfter("--tag"), + process.env.GITHUB_REF_TYPE, + process.env.GITHUB_REF_NAME, + packageJson.version, +); const version = assertReleaseVersion(tag, packageJson.version, pluginManifest.version); assertManifest(pluginManifest); diff --git a/scripts/release-helpers.ts b/scripts/release-helpers.ts index e8d7087..7cfbd81 100644 --- a/scripts/release-helpers.ts +++ b/scripts/release-helpers.ts @@ -1,3 +1,12 @@ +export function resolveReleaseTag( + explicitTag: string | undefined, + refType: string | undefined, + refName: string | undefined, + version: string, +): string { + return explicitTag ?? (refType === "tag" ? refName : undefined) ?? `v${version}`; +} + export function assertReleaseVersion(tag: string, packageVersion: string, manifestVersion: string): string { const match = /^v(\d+\.\d+\.\d+)$/.exec(tag); if (!match) throw new Error(`Release tag ${tag} must use strict vX.Y.Z syntax`); diff --git a/tests/release.test.ts b/tests/release.test.ts index c71bf1c..1e71da3 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -1,5 +1,5 @@ import {describe, expect, test} from "bun:test"; -import {assertReleaseVersion, releaseBaseName} from "../scripts/release-helpers.ts"; +import {assertReleaseVersion, releaseBaseName, resolveReleaseTag} from "../scripts/release-helpers.ts"; describe("release metadata", () => { test("accepts a matching semantic version tag", () => { @@ -7,6 +7,13 @@ describe("release metadata", () => { expect(releaseBaseName("0.1.3")).toBe("zcode-scheduler-v0.1.3"); }); + test("ignores pull-request merge refs when inferring a release tag", () => { + expect(resolveReleaseTag(undefined, undefined, "1/merge", "0.1.3")).toBe("v0.1.3"); + expect(resolveReleaseTag(undefined, "branch", "main", "0.1.3")).toBe("v0.1.3"); + expect(resolveReleaseTag(undefined, "tag", "v0.1.3", "0.1.3")).toBe("v0.1.3"); + expect(resolveReleaseTag("v0.1.4", "tag", "v0.1.3", "0.1.3")).toBe("v0.1.4"); + }); + test("rejects malformed or inconsistent versions", () => { expect(() => assertReleaseVersion("release-0.1.3", "0.1.3", "0.1.3")).toThrow("strict vX.Y.Z"); expect(() => assertReleaseVersion("v0.1.4", "0.1.3", "0.1.3")).toThrow("package version");