mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-10 14:53:09 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3efc72bab9 | |||
| b8a4c824e2 |
@@ -0,0 +1,82 @@
|
|||||||
|
name: Finalise Release Tags
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Release version, for example 0.25.81
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release_branch:
|
||||||
|
description: Release PR branch. Defaults to the version with dots replaced by underscores.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
finalise:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: release
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Resolve release branch
|
||||||
|
id: branch
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BRANCH="${RELEASE_BRANCH_INPUT}"
|
||||||
|
if [[ -z "${BRANCH}" ]]; then
|
||||||
|
BRANCH="${VERSION//./_}"
|
||||||
|
fi
|
||||||
|
echo "name=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.branch.outputs.name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24.x"
|
||||||
|
|
||||||
|
- name: Validate release head
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node utils/release-notes.mjs validate "${VERSION}"
|
||||||
|
git fetch --tags --force
|
||||||
|
if git rev-parse --verify --quiet "refs/tags/${VERSION}" >/dev/null; then
|
||||||
|
echo "Tag already exists: ${VERSION}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git rev-parse --verify --quiet "refs/tags/${VERSION}-cli" >/dev/null; then
|
||||||
|
echo "Tag already exists: ${VERSION}-cli" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create and push release tags
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git tag "${VERSION}"
|
||||||
|
git tag "${VERSION}-cli"
|
||||||
|
git push origin "${VERSION}" "${VERSION}-cli"
|
||||||
|
|
||||||
|
- name: Summarise next steps
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "Created tags \`${VERSION}\` and \`${VERSION}-cli\`."
|
||||||
|
echo ""
|
||||||
|
echo "The plug-in release workflow is triggered by the \`${VERSION}\` tag and creates a draft release by default."
|
||||||
|
echo "The CLI Docker workflow is triggered by the \`${VERSION}-cli\` tag and publishes the version, major-minor, latest, and SHA-qualified image tags."
|
||||||
|
echo ""
|
||||||
|
echo "To create a pre-release instead of the default draft flow, run \`Release Obsidian Plugin\` manually with \`tag=${VERSION}\`, \`draft=false\`, and \`prerelease=true\`."
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: Prepare Release PR
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Release version, for example 0.25.81
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
base_branch:
|
||||||
|
description: Base branch for the release PR
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: main
|
||||||
|
release_branch:
|
||||||
|
description: Release branch name. Defaults to the version with dots replaced by underscores.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
allow_empty_updates:
|
||||||
|
description: Allow an empty Unreleased section
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.base_branch }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24.x"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Prepare release changes
|
||||||
|
id: prepare
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE_BRANCH_INPUT: ${{ inputs.release_branch }}
|
||||||
|
ALLOW_EMPTY_UPDATES: ${{ inputs.allow_empty_updates }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
BRANCH="${RELEASE_BRANCH_INPUT}"
|
||||||
|
if [[ -z "${BRANCH}" ]]; then
|
||||||
|
BRANCH="${VERSION//./_}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then
|
||||||
|
echo "Release branch already exists: ${BRANCH}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git switch -c "${BRANCH}"
|
||||||
|
npm version "${VERSION}" --no-git-tag-version
|
||||||
|
node utils/release-notes.mjs prepare "${VERSION}"
|
||||||
|
npm run pretty:json
|
||||||
|
|
||||||
|
git add package.json package-lock.json manifest.json versions.json updates.md src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "Releasing ${VERSION}"
|
||||||
|
git push --set-upstream origin "${BRANCH}"
|
||||||
|
|
||||||
|
echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create draft release PR
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||||
|
RELEASE_BRANCH: ${{ steps.prepare.outputs.branch }}
|
||||||
|
run: |
|
||||||
|
cat > /tmp/release-pr-body.md <<EOF
|
||||||
|
## Release checklist
|
||||||
|
|
||||||
|
- [ ] Review and polish \`updates.md\`
|
||||||
|
- [ ] Confirm \`manifest.json\`, \`versions.json\`, and workspace package versions
|
||||||
|
- [ ] Confirm CI has passed
|
||||||
|
- [ ] Run the finalise release workflow after the PR head is fixed
|
||||||
|
- [ ] Merge this PR after the draft or pre-release has been created
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh pr create \
|
||||||
|
--base "${BASE_BRANCH}" \
|
||||||
|
--head "${RELEASE_BRANCH}" \
|
||||||
|
--draft \
|
||||||
|
--title "Releasing ${VERSION}" \
|
||||||
|
--body-file /tmp/release-pr-body.md
|
||||||
@@ -6,10 +6,26 @@ on:
|
|||||||
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
|
- '*' # Push events to matching any tag format, i.e. 1.0, 20.15.10
|
||||||
- '!*-cli' # Exclude command-line interface tags
|
- '!*-cli' # Exclude command-line interface tags
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Release tag to build
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
draft:
|
||||||
|
description: Create the GitHub Release as a draft
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
prerelease:
|
||||||
|
description: Mark the GitHub Release as a pre-release
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment: release
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -19,6 +35,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
|
fetch-depth: 0 # otherwise, you will failed to push refs to dest repo
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -27,7 +44,18 @@ jobs:
|
|||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
echo "tag=$(git describe --abbrev=0 --tags)" >> $GITHUB_OUTPUT
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
DRAFT="${{ inputs.draft }}"
|
||||||
|
PRERELEASE="${{ inputs.prerelease }}"
|
||||||
|
else
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
DRAFT="true"
|
||||||
|
PRERELEASE="false"
|
||||||
|
fi
|
||||||
|
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
|
echo "draft=${DRAFT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
|
||||||
# Build the plugin
|
# Build the plugin
|
||||||
- name: Build
|
- name: Build
|
||||||
id: build
|
id: build
|
||||||
@@ -58,4 +86,5 @@ jobs:
|
|||||||
styles.css
|
styles.css
|
||||||
name: ${{ steps.version.outputs.tag }}
|
name: ${{ steps.version.outputs.tag }}
|
||||||
tag_name: ${{ steps.version.outputs.tag }}
|
tag_name: ${{ steps.version.outputs.tag }}
|
||||||
draft: true
|
draft: ${{ steps.version.outputs.draft }}
|
||||||
|
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||||
|
|||||||
@@ -252,6 +252,47 @@ export class ModuleExample extends AbstractObsidianModule {
|
|||||||
|
|
||||||
In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding!
|
In short, the situation remains unchanged for me, but it means you all become a little safer. Thank you for your understanding!
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
|
||||||
|
- Keep the top section of `updates.md` as `## Unreleased` during normal development.
|
||||||
|
- When opening a feature or fix PR, update `## Unreleased` in the same PR if the change is user-facing.
|
||||||
|
- Add only user-facing changes that help users understand what they gain, what has changed, or what they may need to do after updating.
|
||||||
|
- Avoid listing purely internal refactors, maintenance chores, generated-file changes, and dependency updates unless they affect users; group and label them when they are included.
|
||||||
|
- When preparing a release, replace `## Unreleased` with the target version heading (for example, `## 0.25.81`) and add a fresh empty `## Unreleased` section above it for the next cycle.
|
||||||
|
- Review and polish the released section in the release PR before tagging, because the content is embedded into the plug-in and may be reused as the GitHub Release notes.
|
||||||
|
|
||||||
|
## Release Workflow
|
||||||
|
|
||||||
|
This workflow is for maintainers. Contributors should update `## Unreleased` for user-facing feature or fix PRs, but do not need to run the release workflows.
|
||||||
|
The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `release` GitHub Environment. Configure Environment protection in the repository settings so tag creation and release publication require maintainer approval.
|
||||||
|
|
||||||
|
- Run the `Prepare Release PR` workflow with the target version. It creates the release branch, updates versions, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR.
|
||||||
|
- Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`.
|
||||||
|
- Once the release PR head is fixed, run the `Finalise Release Tags` workflow. It validates the release branch and pushes both the plug-in tag (for example, `0.25.81`) and the CLI tag (for example, `0.25.81-cli`) to the same commit.
|
||||||
|
- The plug-in tag triggers the release workflow and creates a draft GitHub Release by default. The CLI tag triggers the Docker workflow and publishes the fixed version tag, the major-minor moving tag (for example, `0.25-cli`), `latest`, and the SHA-qualified tag.
|
||||||
|
- If a pre-release is needed, run the `Release Obsidian Plugin` workflow manually with the target tag, `draft=false`, and `prerelease=true`.
|
||||||
|
|
||||||
|
### Release Cheat Sheet
|
||||||
|
|
||||||
|
1. Before starting, add user-facing notes under `## Unreleased` in `updates.md`.
|
||||||
|
2. Run `Prepare Release PR` from GitHub Actions.
|
||||||
|
- `version`: the target version, for example `0.25.81`.
|
||||||
|
- `base_branch`: normally `main`.
|
||||||
|
- `release_branch`: leave blank to use the default branch name, for example `0_25_81`.
|
||||||
|
- `allow_empty_updates`: leave disabled unless the release intentionally has no user-facing notes.
|
||||||
|
3. Review the generated draft PR.
|
||||||
|
- Polish `updates.md`.
|
||||||
|
- Confirm `package.json`, `manifest.json`, `versions.json`, and workspace package versions.
|
||||||
|
- Confirm that `manifest.json` has the intended `minAppVersion`.
|
||||||
|
- Wait for the necessary CI checks.
|
||||||
|
4. When the PR head is fixed, run `Finalise Release Tags`.
|
||||||
|
- `version`: the same target version.
|
||||||
|
- `release_branch`: leave blank unless the release branch used a custom name.
|
||||||
|
5. Check the generated draft GitHub Release for the plug-in tag.
|
||||||
|
6. Check the CLI Docker workflow started from the `*-cli` tag.
|
||||||
|
7. Publish the draft GitHub Release when ready, then merge the release PR into `main`.
|
||||||
|
8. If the release should be a pre-release instead of a draft release, run `Release Obsidian Plugin` manually with the target `tag`, `draft=false`, and `prerelease=true`.
|
||||||
|
|
||||||
## Contribution Guidelines
|
## Contribution Guidelines
|
||||||
|
|
||||||
- Follow existing code style and conventions
|
- Follow existing code style and conventions
|
||||||
|
|||||||
Generated
-14
@@ -58,7 +58,6 @@
|
|||||||
"@vitest/browser": "^4.1.8",
|
"@vitest/browser": "^4.1.8",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
|
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
@@ -4945,19 +4944,6 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vrtmrz/obsidian-test-session": {
|
|
||||||
"version": "0.0.0",
|
|
||||||
"resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
|
|
||||||
"integrity": "sha512-XHWFORR8Q7vQsbKxrj8RBgpyC7DHFw5PSUXkBOKJoHnx9abgCkuQp4gYYa64RO7sOdRx/Xj799JOop+McQSqsg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"playwright": ">=1.50.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@wdio/config": {
|
"node_modules/@wdio/config": {
|
||||||
"version": "9.27.0",
|
"version": "9.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz",
|
||||||
|
|||||||
@@ -105,7 +105,6 @@
|
|||||||
"@vitest/browser": "^4.1.8",
|
"@vitest/browser": "^4.1.8",
|
||||||
"@vitest/browser-playwright": "^4.1.8",
|
"@vitest/browser-playwright": "^4.1.8",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.8",
|
||||||
"@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz",
|
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
This directory contains the experimental real Obsidian end-to-end runner.
|
This directory contains the experimental real Obsidian end-to-end runner.
|
||||||
|
|
||||||
The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository.
|
|
||||||
|
|
||||||
The current smoke runner verifies only the launch path:
|
The current smoke runner verifies only the launch path:
|
||||||
|
|
||||||
1. create a temporary vault,
|
1. create a temporary vault,
|
||||||
@@ -20,7 +18,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R
|
|||||||
|
|
||||||
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
|
Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available.
|
||||||
|
|
||||||
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here.
|
Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence.
|
||||||
|
|
||||||
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,103 @@
|
|||||||
export {
|
import { spawn } from "node:child_process";
|
||||||
evalObsidianJson,
|
|
||||||
openVaultWithObsidianCli,
|
export type ObsidianCliResult = {
|
||||||
runObsidianCli,
|
code: number | null;
|
||||||
type ObsidianCliResult,
|
signal: NodeJS.Signals | null;
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseEvalJson(stdout: string): unknown {
|
||||||
|
const marker = "=> ";
|
||||||
|
const markerIndex = stdout.indexOf(marker);
|
||||||
|
const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout;
|
||||||
|
return JSON.parse(text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runObsidianCli(
|
||||||
|
cliBinary: string,
|
||||||
|
args: string[],
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000)
|
||||||
|
): Promise<ObsidianCliResult> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(cliBinary, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ code, signal, stdout, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openVaultWithObsidianCli(
|
||||||
|
cliBinary: string,
|
||||||
|
vaultPath: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evalObsidianJson<T>(
|
||||||
|
cliBinary: string,
|
||||||
|
code: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
timeoutMs?: number
|
||||||
|
): Promise<T> {
|
||||||
|
const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parseEvalJson(result.stdout) as T;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`,
|
||||||
|
error instanceof Error ? `parse error: ${error.message}` : undefined,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,149 @@
|
|||||||
export {
|
import { accessSync, constants, existsSync } from "node:fs";
|
||||||
discoverObsidianBinary,
|
import { resolve } from "node:path";
|
||||||
discoverObsidianCli,
|
import { platform } from "node:process";
|
||||||
requireObsidianBinary,
|
|
||||||
requireObsidianCli,
|
export type ObsidianDiscoveryResult = {
|
||||||
type ObsidianDiscoveryResult,
|
binary?: string;
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
source?: string;
|
||||||
|
checked: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||||
|
aix: [],
|
||||||
|
android: [],
|
||||||
|
darwin: [
|
||||||
|
"/Applications/Obsidian.app/Contents/MacOS/Obsidian",
|
||||||
|
"/Applications/Obsidian.app/Contents/MacOS/obsidian",
|
||||||
|
],
|
||||||
|
freebsd: [],
|
||||||
|
haiku: [],
|
||||||
|
linux: [
|
||||||
|
"_testdata/obsidian/squashfs-root/obsidian",
|
||||||
|
"_testdata/obsidian/squashfs-root/AppRun",
|
||||||
|
"_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage",
|
||||||
|
"_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage",
|
||||||
|
"/usr/bin/obsidian",
|
||||||
|
"/usr/local/bin/obsidian",
|
||||||
|
"/snap/bin/obsidian",
|
||||||
|
"/opt/Obsidian/obsidian",
|
||||||
|
"/opt/obsidian/obsidian",
|
||||||
|
"/app/bin/obsidian",
|
||||||
|
],
|
||||||
|
openbsd: [],
|
||||||
|
sunos: [],
|
||||||
|
win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"],
|
||||||
|
cygwin: [],
|
||||||
|
netbsd: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultCliCandidatesByPlatform: Record<NodeJS.Platform, string[]> = {
|
||||||
|
aix: [],
|
||||||
|
android: [],
|
||||||
|
darwin: [
|
||||||
|
"/Applications/Obsidian.app/Contents/MacOS/obsidian-cli",
|
||||||
|
"/Applications/Obsidian.app/Contents/Resources/obsidian-cli",
|
||||||
|
],
|
||||||
|
freebsd: [],
|
||||||
|
haiku: [],
|
||||||
|
linux: [
|
||||||
|
"_testdata/obsidian/squashfs-root/obsidian-cli",
|
||||||
|
"/usr/bin/obsidian-cli",
|
||||||
|
"/usr/local/bin/obsidian-cli",
|
||||||
|
"/snap/bin/obsidian-cli",
|
||||||
|
"/opt/Obsidian/obsidian-cli",
|
||||||
|
"/opt/obsidian/obsidian-cli",
|
||||||
|
],
|
||||||
|
openbsd: [],
|
||||||
|
sunos: [],
|
||||||
|
win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"],
|
||||||
|
cygwin: [],
|
||||||
|
netbsd: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUsableFile(path: string): boolean {
|
||||||
|
const resolvedPath = resolve(path);
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (platform === "win32") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
accessSync(resolvedPath, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||||
|
const checked: string[] = [];
|
||||||
|
const envBinary = env.OBSIDIAN_BINARY?.trim();
|
||||||
|
if (envBinary) {
|
||||||
|
checked.push(envBinary);
|
||||||
|
if (isUsableFile(envBinary)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(envBinary),
|
||||||
|
source: "OBSIDIAN_BINARY",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = defaultCandidatesByPlatform[platform] ?? [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
checked.push(candidate);
|
||||||
|
if (isUsableFile(candidate)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(candidate),
|
||||||
|
source: "default-path",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checked };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const result = discoverObsidianBinary(env);
|
||||||
|
if (!result.binary) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"Could not find an Obsidian executable.",
|
||||||
|
"Set OBSIDIAN_BINARY to the installed Obsidian executable path.",
|
||||||
|
`Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`,
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult {
|
||||||
|
const checked: string[] = [];
|
||||||
|
const envBinary = env.OBSIDIAN_CLI?.trim();
|
||||||
|
if (envBinary) {
|
||||||
|
checked.push(envBinary);
|
||||||
|
if (isUsableFile(envBinary)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(envBinary),
|
||||||
|
source: "OBSIDIAN_CLI",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = defaultCliCandidatesByPlatform[platform] ?? [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
checked.push(candidate);
|
||||||
|
if (isUsableFile(candidate)) {
|
||||||
|
return {
|
||||||
|
binary: resolve(candidate),
|
||||||
|
source: "default-path",
|
||||||
|
checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { checked };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,196 @@
|
|||||||
import {
|
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||||
cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses,
|
import { once } from "node:events";
|
||||||
launchObsidian as launchObsidianSession,
|
import { existsSync } from "node:fs";
|
||||||
type LaunchObsidianOptions,
|
import { dirname } from "node:path";
|
||||||
type ObsidianProcess,
|
import { platform } from "node:process";
|
||||||
type ObsidianProcessOutput,
|
import { promisify } from "node:util";
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
|
||||||
export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput };
|
export type ObsidianProcess = {
|
||||||
|
process: ChildProcess;
|
||||||
|
output: () => { stdout: string; stderr: string };
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state";
|
export type LaunchObsidianOptions = {
|
||||||
|
binary: string;
|
||||||
|
vaultPath: string;
|
||||||
|
homePath?: string;
|
||||||
|
xdgConfigPath?: string;
|
||||||
|
xdgCachePath?: string;
|
||||||
|
xdgDataPath?: string;
|
||||||
|
userDataPath?: string;
|
||||||
|
startupGraceMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
function splitArgs(args: string): string[] {
|
||||||
|
return args.split(" ").filter((arg) => arg.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchArgs(options: LaunchObsidianOptions): string[] {
|
||||||
|
const explicitArgs = process.env.E2E_OBSIDIAN_ARGS;
|
||||||
|
if (explicitArgs) {
|
||||||
|
return splitArgs(explicitArgs);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-software-rasterizer",
|
||||||
|
...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath
|
||||||
|
? [`--user-data-dir=${options.userDataPath}`]
|
||||||
|
: []),
|
||||||
|
...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT
|
||||||
|
? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`]
|
||||||
|
: []),
|
||||||
|
`obsidian://open?path=${encodeURIComponent(options.vaultPath)}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseXvfb(): boolean {
|
||||||
|
if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return platform === "linux" && existsSync("/usr/bin/xvfb-run");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listChildPids(pid: number): Promise<number[]> {
|
||||||
|
if (platform === "win32") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({
|
||||||
|
stdout: "",
|
||||||
|
}));
|
||||||
|
const directChildren = stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => Number(line.trim()))
|
||||||
|
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
|
||||||
|
const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid)));
|
||||||
|
return [...directChildren, ...descendants.flat()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killPids(pids: number[], signal: NodeJS.Signals): Promise<void> {
|
||||||
|
for (const pid of pids) {
|
||||||
|
if (pid === process.pid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
process.kill(pid, signal);
|
||||||
|
} catch {
|
||||||
|
// The process may have exited between discovery and signalling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExit(exitPromise: Promise<unknown>, timeoutMs: number): Promise<"exited" | "timeout"> {
|
||||||
|
const stopTimer = new Promise<"timeout">((resolve) => {
|
||||||
|
setTimeout(() => resolve("timeout"), timeoutMs);
|
||||||
|
});
|
||||||
|
const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]);
|
||||||
|
return stopResult;
|
||||||
|
}
|
||||||
|
|
||||||
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
|
export async function cleanupStaleObsidianE2EProcesses(): Promise<void> {
|
||||||
await cleanupStaleProcesses(STALE_PROCESS_PATTERN);
|
if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({
|
||||||
|
stdout: "",
|
||||||
|
}));
|
||||||
|
const pids = stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => Number(line.trim()))
|
||||||
|
.filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
||||||
|
if (pids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await killPids(pids, "SIGTERM");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
await killPids(pids, "SIGKILL");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
export async function launchObsidian(options: LaunchObsidianOptions): Promise<ObsidianProcess> {
|
||||||
const configuredPort =
|
await cleanupStaleObsidianE2EProcesses();
|
||||||
options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT;
|
const startupGraceMs = options.startupGraceMs ?? 1000;
|
||||||
return await launchObsidianSession({
|
const args = launchArgs(options);
|
||||||
...options,
|
const useXvfb = shouldUseXvfb();
|
||||||
remoteDebuggingPort:
|
const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary;
|
||||||
options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)),
|
const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args;
|
||||||
staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN,
|
const child = spawn(command, commandArgs, {
|
||||||
|
cwd: dirname(options.binary),
|
||||||
|
detached: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...(options.homePath ? { HOME: options.homePath } : {}),
|
||||||
|
...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}),
|
||||||
|
...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}),
|
||||||
|
...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}),
|
||||||
|
OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
let stdout = "";
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal }));
|
||||||
|
const timer = new Promise<"timeout">((resolve) => {
|
||||||
|
setTimeout(() => resolve("timeout"), startupGraceMs);
|
||||||
|
});
|
||||||
|
const firstResult = await Promise.race([exitPromise, timer]);
|
||||||
|
if (firstResult !== "timeout") {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`,
|
||||||
|
stdout ? `stdout:\n${stdout}` : undefined,
|
||||||
|
stderr ? `stderr:\n${stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
process: child,
|
||||||
|
output: () => ({ stdout, stderr }),
|
||||||
|
stop: async () => {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const descendantPids = child.pid ? await listChildPids(child.pid) : [];
|
||||||
|
if (child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
await killPids(descendantPids.reverse(), "SIGTERM");
|
||||||
|
const stopResult = await waitForExit(exitPromise, 5000);
|
||||||
|
if (stopResult === "timeout") {
|
||||||
|
if (child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
await killPids(descendantPids, "SIGKILL");
|
||||||
|
await exitPromise;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
import {
|
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
||||||
installBuiltPlugin as installGenericBuiltPlugin,
|
import { existsSync } from "node:fs";
|
||||||
type PluginInstallResult,
|
import { join, resolve } from "node:path";
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
|
||||||
export type { PluginInstallResult };
|
export type PluginInstallResult = {
|
||||||
|
pluginDir: string;
|
||||||
|
copied: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pluginId = "obsidian-livesync";
|
||||||
|
|
||||||
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise<PluginInstallResult> {
|
||||||
return await installGenericBuiltPlugin(vaultPath, {
|
const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId);
|
||||||
pluginId: "obsidian-livesync",
|
const copied: string[] = [];
|
||||||
artifactRoot: rootDir,
|
await mkdir(pluginDir, { recursive: true });
|
||||||
});
|
|
||||||
|
const requiredArtifacts = ["main.js", "manifest.json"];
|
||||||
|
for (const artifact of requiredArtifacts) {
|
||||||
|
const source = resolve(rootDir, artifact);
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
throw new Error(`Required plug-in artifact is missing: ${source}`);
|
||||||
|
}
|
||||||
|
await copyFile(source, join(pluginDir, artifact));
|
||||||
|
copied.push(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalArtifacts = ["styles.css"];
|
||||||
|
for (const artifact of optionalArtifacts) {
|
||||||
|
const source = resolve(rootDir, artifact);
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await copyFile(source, join(pluginDir, artifact));
|
||||||
|
copied.push(artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4));
|
||||||
|
return { pluginDir, copied };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,41 @@
|
|||||||
export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session";
|
import { evalObsidianJson } from "./cli.ts";
|
||||||
|
|
||||||
|
export type PluginReadiness = {
|
||||||
|
status: "ready";
|
||||||
|
pluginId: string;
|
||||||
|
pluginVersion: string;
|
||||||
|
vaultName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function waitForPluginReady(
|
||||||
|
cliBinary: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000)
|
||||||
|
): Promise<PluginReadiness> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastOutput = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const readiness = await evalObsidianJson<PluginReadiness>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>JSON.stringify({",
|
||||||
|
"status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',",
|
||||||
|
"pluginId:'obsidian-livesync',",
|
||||||
|
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,",
|
||||||
|
"vaultName:app.vault.getName()",
|
||||||
|
"}))()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
if (readiness.status === "ready") {
|
||||||
|
return readiness;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastOutput = error instanceof Error ? error.message : String(error);
|
||||||
|
// Keep polling until Obsidian exposes the vault-side CLI and plug-in state.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
|
import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts";
|
||||||
|
import { launchObsidian, type ObsidianProcess } from "./launch.ts";
|
||||||
|
import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts";
|
||||||
|
import { waitForPluginReady, type PluginReadiness } from "./readiness.ts";
|
||||||
import type { TemporaryVault } from "./vault.ts";
|
import type { TemporaryVault } from "./vault.ts";
|
||||||
|
import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts";
|
||||||
|
|
||||||
export type ObsidianLiveSyncSession = ObsidianPluginSession;
|
export type ObsidianLiveSyncSession = {
|
||||||
|
app: ObsidianProcess;
|
||||||
|
cliEnv: NodeJS.ProcessEnv;
|
||||||
|
install: PluginInstallResult;
|
||||||
|
readiness: PluginReadiness;
|
||||||
|
};
|
||||||
|
|
||||||
export type StartObsidianLiveSyncSessionOptions = {
|
export type StartObsidianLiveSyncSessionOptions = {
|
||||||
binary: string;
|
binary: string;
|
||||||
@@ -10,15 +19,101 @@ export type StartObsidianLiveSyncSessionOptions = {
|
|||||||
startupGraceMs?: number;
|
startupGraceMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000);
|
||||||
|
let lastOutput = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const result = await evalObsidianJson<{ hasLiveSync: boolean }>(
|
||||||
|
cliBinary,
|
||||||
|
["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
lastOutput = JSON.stringify(result);
|
||||||
|
if (result.hasLiveSync) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastOutput = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env);
|
||||||
|
if (result.code !== 0 || result.stdout.includes("Error:")) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`,
|
||||||
|
result.stdout ? `stdout:\n${result.stdout}` : undefined,
|
||||||
|
result.stderr ? `stderr:\n${result.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||||
|
const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env);
|
||||||
|
if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`,
|
||||||
|
reload.stdout ? `stdout:\n${reload.stdout}` : undefined,
|
||||||
|
reload.stderr ? `stderr:\n${reload.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function startObsidianLiveSyncSession(
|
export async function startObsidianLiveSyncSession(
|
||||||
options: StartObsidianLiveSyncSessionOptions
|
options: StartObsidianLiveSyncSessionOptions
|
||||||
): Promise<ObsidianLiveSyncSession> {
|
): Promise<ObsidianLiveSyncSession> {
|
||||||
return await startObsidianPluginSession({
|
const install = await installBuiltPlugin(options.vault.path);
|
||||||
|
const remoteDebuggingPort = obsidianRemoteDebuggingPort();
|
||||||
|
const app = await launchObsidian({
|
||||||
binary: options.binary,
|
binary: options.binary,
|
||||||
cliBinary: options.cliBinary,
|
vaultPath: options.vault.path,
|
||||||
vault: options.vault,
|
homePath: options.vault.homePath,
|
||||||
pluginId: "obsidian-livesync",
|
xdgConfigPath: options.vault.xdgConfigPath,
|
||||||
artifactRoot: process.cwd(),
|
xdgCachePath: options.vault.xdgCachePath,
|
||||||
|
xdgDataPath: options.vault.xdgDataPath,
|
||||||
|
userDataPath: options.vault.userDataPath,
|
||||||
startupGraceMs: options.startupGraceMs,
|
startupGraceMs: options.startupGraceMs,
|
||||||
});
|
});
|
||||||
|
const cliEnv = {
|
||||||
|
...process.env,
|
||||||
|
HOME: options.vault.homePath,
|
||||||
|
XDG_CONFIG_HOME: options.vault.xdgConfigPath,
|
||||||
|
XDG_CACHE_HOME: options.vault.xdgCachePath,
|
||||||
|
XDG_DATA_HOME: options.vault.xdgDataPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id);
|
||||||
|
await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv);
|
||||||
|
await trustVaultIfPrompted(remoteDebuggingPort);
|
||||||
|
await waitForPluginCatalogue(options.cliBinary, cliEnv);
|
||||||
|
await enableCommunityPlugins(options.cliBinary, cliEnv);
|
||||||
|
await reloadLiveSyncPlugin(options.cliBinary, cliEnv);
|
||||||
|
const readiness = await waitForPluginReady(options.cliBinary, cliEnv);
|
||||||
|
return { app, cliEnv, install, readiness };
|
||||||
|
} catch (error) {
|
||||||
|
const output = app.output();
|
||||||
|
await app.stop();
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined,
|
||||||
|
output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,74 @@
|
|||||||
import { withObsidianPage } from "@vrtmrz/obsidian-test-session";
|
import { chromium, type Page } from "playwright";
|
||||||
|
|
||||||
export {
|
export function obsidianRemoteDebuggingPort(): number {
|
||||||
obsidianRemoteDebuggingPort,
|
const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222);
|
||||||
preseedTrustedVaultState,
|
process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port);
|
||||||
trustVaultIfPrompted,
|
return port;
|
||||||
withObsidianPage,
|
}
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
async function waitForCdp(port: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000);
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling until Obsidian exposes the debugging endpoint.
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
|
||||||
|
await waitForCdp(port);
|
||||||
|
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
||||||
|
try {
|
||||||
|
const context = browser.contexts()[0];
|
||||||
|
const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 }));
|
||||||
|
return await operation(page);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preseedTrustedVaultState(port: number, vaultId: string): Promise<void> {
|
||||||
|
await withObsidianPage(port, async (page) => {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
localStorage.setItem(`enable-plugin-${id}`, "true");
|
||||||
|
}, vaultId);
|
||||||
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trustVaultIfPrompted(port: number): Promise<void> {
|
||||||
|
await withObsidianPage(port, async (page) => {
|
||||||
|
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000);
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const yesButton = page.getByRole("button", { name: "Yes" });
|
||||||
|
if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
|
await yesButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustButton = page.getByText("Trust author and enable plugins");
|
||||||
|
if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
|
await trustButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = page.locator(".workspace");
|
||||||
|
if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise<void> {
|
||||||
await withObsidianPage(port, async (page) => {
|
await withObsidianPage(port, async (page) => {
|
||||||
|
|||||||
@@ -1,14 +1,94 @@
|
|||||||
import {
|
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
createTemporaryVault as createGenericTemporaryVault,
|
import { join } from "node:path";
|
||||||
type TemporaryVault,
|
import { tmpdir } from "node:os";
|
||||||
} from "@vrtmrz/obsidian-test-session";
|
|
||||||
|
|
||||||
export type { TemporaryVault };
|
export type TemporaryVault = {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
homePath: string;
|
||||||
|
xdgConfigPath: string;
|
||||||
|
xdgCachePath: string;
|
||||||
|
xdgDataPath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
dispose: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise<TemporaryVault> {
|
||||||
return await createGenericTemporaryVault({
|
const vaultPath = await mkdtemp(join(tmpdir(), prefix));
|
||||||
prefix,
|
const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`));
|
||||||
pluginIds: ["obsidian-livesync"],
|
const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e";
|
||||||
idPrefix: "livesync-e2e",
|
await mkdir(join(vaultPath, ".obsidian"), { recursive: true });
|
||||||
});
|
const homePath = join(statePath, "home");
|
||||||
|
const xdgConfigPath = join(statePath, "xdg-config");
|
||||||
|
const xdgCachePath = join(statePath, "xdg-cache");
|
||||||
|
const xdgDataPath = join(statePath, "xdg-data");
|
||||||
|
const userDataPath = join(statePath, "user-data");
|
||||||
|
const id = `livesync-e2e-${Date.now()}`;
|
||||||
|
await mkdir(homePath, { recursive: true });
|
||||||
|
await mkdir(xdgConfigPath, { recursive: true });
|
||||||
|
await mkdir(xdgCachePath, { recursive: true });
|
||||||
|
await mkdir(xdgDataPath, { recursive: true });
|
||||||
|
await mkdir(userDataPath, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(vaultPath, ".obsidian", "app.json"),
|
||||||
|
JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4)
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(vaultPath, ".obsidian", "community-plugins.json"),
|
||||||
|
JSON.stringify(["obsidian-livesync"], null, 4)
|
||||||
|
);
|
||||||
|
await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: vaultPath,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
homePath,
|
||||||
|
xdgConfigPath,
|
||||||
|
xdgCachePath,
|
||||||
|
xdgDataPath,
|
||||||
|
userDataPath,
|
||||||
|
dispose: async () => {
|
||||||
|
if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") {
|
||||||
|
console.log(`Keeping temporary vault: ${vaultPath}`);
|
||||||
|
console.log(`Keeping temporary Obsidian state: ${statePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
||||||
|
rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeObsidianVaultRegistry(
|
||||||
|
vaultId: string,
|
||||||
|
vaultPath: string,
|
||||||
|
vaultName: string,
|
||||||
|
homePath: string,
|
||||||
|
xdgConfigPath: string,
|
||||||
|
userDataPath: string
|
||||||
|
): Promise<void> {
|
||||||
|
const vaultRecord = {
|
||||||
|
path: vaultPath,
|
||||||
|
ts: Date.now(),
|
||||||
|
open: true,
|
||||||
|
name: vaultName,
|
||||||
|
};
|
||||||
|
const registry = {
|
||||||
|
cli: true,
|
||||||
|
vaults: {
|
||||||
|
[vaultId]: vaultRecord,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const registryText = JSON.stringify(registry, null, 4);
|
||||||
|
for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) {
|
||||||
|
const obsidianConfigDir = join(configRoot, "obsidian");
|
||||||
|
await mkdir(obsidianConfigDir, { recursive: true });
|
||||||
|
await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText);
|
||||||
|
}
|
||||||
|
await writeFile(join(userDataPath, "obsidian.json"), registryText);
|
||||||
|
await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
|||||||
|
|
||||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
## 0.25.80
|
## 0.25.80
|
||||||
|
|
||||||
7th July, 2026
|
7th July, 2026
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const updatesPath = "updates.md";
|
||||||
|
|
||||||
|
// Utility used by the release workflows to rotate and validate `updates.md`.
|
||||||
|
// It intentionally keeps the Markdown format simple: top-level `##` headings
|
||||||
|
// are treated as release boundaries, and only one `## Unreleased` section is
|
||||||
|
// allowed.
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(path) {
|
||||||
|
return JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertVersion(version) {
|
||||||
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||||
|
fail(`Invalid release version: ${version}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReleaseDate(date = new Date()) {
|
||||||
|
const day = date.getUTCDate();
|
||||||
|
const suffix = day % 10 === 1 && day !== 11 ? "st" : day % 10 === 2 && day !== 12 ? "nd" : day % 10 === 3 && day !== 13 ? "rd" : "th";
|
||||||
|
const month = new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC" }).format(date);
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
return `${day}${suffix} ${month}, ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingPattern(heading) {
|
||||||
|
return new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a `##` section body without interpreting lower-level headings.
|
||||||
|
function findSection(markdown, heading) {
|
||||||
|
const pattern = headingPattern(heading);
|
||||||
|
const match = pattern.exec(markdown);
|
||||||
|
if (!match) return undefined;
|
||||||
|
|
||||||
|
const headingStart = match.index;
|
||||||
|
const bodyStart = match.index + match[0].length;
|
||||||
|
const rest = markdown.slice(bodyStart);
|
||||||
|
const next = /^## .+$/m.exec(rest);
|
||||||
|
const end = next ? bodyStart + next.index : markdown.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
headingStart,
|
||||||
|
bodyStart,
|
||||||
|
end,
|
||||||
|
body: markdown.slice(bodyStart, end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSingleUnreleased(markdown) {
|
||||||
|
const matches = markdown.match(/^## Unreleased\s*$/gm) || [];
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
fail(`Expected exactly one '## Unreleased' section in ${updatesPath}, found ${matches.length}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepare(version) {
|
||||||
|
assertVersion(version);
|
||||||
|
const markdown = readFileSync(updatesPath, "utf8");
|
||||||
|
assertSingleUnreleased(markdown);
|
||||||
|
|
||||||
|
if (headingPattern(version).test(markdown)) {
|
||||||
|
fail(`Release notes for ${version} already exist in ${updatesPath}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unreleased = findSection(markdown, "Unreleased");
|
||||||
|
if (!unreleased) fail(`Could not find '## Unreleased' in ${updatesPath}.`);
|
||||||
|
|
||||||
|
const allowEmpty = process.env.ALLOW_EMPTY_UPDATES === "true";
|
||||||
|
if (!allowEmpty && unreleased.body.trim() === "") {
|
||||||
|
fail(`The '## Unreleased' section is empty. Set ALLOW_EMPTY_UPDATES=true if this is intentional.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a fresh empty Unreleased section above the newly dated release notes.
|
||||||
|
const releasedBody = `${unreleased.body.trim()}\n\n`;
|
||||||
|
const releaseDate = process.env.RELEASE_DATE || formatReleaseDate();
|
||||||
|
const replacement = `## Unreleased\n\n## ${version}\n\n${releaseDate}\n\n${releasedBody}`;
|
||||||
|
const nextMarkdown = markdown.slice(0, unreleased.headingStart) + replacement + markdown.slice(unreleased.end);
|
||||||
|
writeFileSync(updatesPath, nextMarkdown, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(version) {
|
||||||
|
assertVersion(version);
|
||||||
|
|
||||||
|
const rootPackage = readJson("package.json");
|
||||||
|
const manifest = readJson("manifest.json");
|
||||||
|
if (rootPackage.version !== version) {
|
||||||
|
fail(`package.json version is ${rootPackage.version}, expected ${version}.`);
|
||||||
|
}
|
||||||
|
if (manifest.version !== version) {
|
||||||
|
fail(`manifest.json version is ${manifest.version}, expected ${version}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const workspace of ["cli", "webpeer", "webapp"]) {
|
||||||
|
const workspacePackage = readJson(`src/apps/${workspace}/package.json`);
|
||||||
|
const expected = `${version}-${workspace}`;
|
||||||
|
if (workspacePackage.version !== expected) {
|
||||||
|
fail(`src/apps/${workspace}/package.json version is ${workspacePackage.version}, expected ${expected}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = readJson("versions.json");
|
||||||
|
if (versions[version] !== manifest.minAppVersion) {
|
||||||
|
fail(`versions.json does not map ${version} to manifest minAppVersion ${manifest.minAppVersion}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = readFileSync(updatesPath, "utf8");
|
||||||
|
assertSingleUnreleased(markdown);
|
||||||
|
|
||||||
|
const releaseSection = findSection(markdown, version);
|
||||||
|
if (!releaseSection) {
|
||||||
|
fail(`Could not find '## ${version}' in ${updatesPath}.`);
|
||||||
|
}
|
||||||
|
if (releaseSection.body.trim() === "") {
|
||||||
|
fail(`The release notes for ${version} are empty.`);
|
||||||
|
}
|
||||||
|
if (/\b(?:TODO|WIP)\b/i.test(releaseSection.body)) {
|
||||||
|
fail(`The release notes for ${version} still contain TODO or WIP markers.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [command, version] = process.argv.slice(2);
|
||||||
|
if (!command || !version) {
|
||||||
|
fail("Usage: node utils/release-notes.mjs <prepare|validate> <version>");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "prepare") {
|
||||||
|
prepare(version);
|
||||||
|
} else if (command === "validate") {
|
||||||
|
validate(version);
|
||||||
|
} else {
|
||||||
|
fail(`Unknown command: ${command}`);
|
||||||
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, 4));
|
|||||||
// update versions.json with target version and minAppVersion from manifest.json
|
// update versions.json with target version and minAppVersion from manifest.json
|
||||||
// but only if the target version is not already in versions.json
|
// but only if the target version is not already in versions.json
|
||||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||||
if (!Object.values(versions).includes(minAppVersion)) {
|
if (!(targetVersion in versions)) {
|
||||||
versions[targetVersion] = minAppVersion;
|
versions[targetVersion] = minAppVersion;
|
||||||
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
|
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user