Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cbf5fa26 | ||
|
|
fc920b4d3c | ||
|
|
d604deb6a5 | ||
|
|
8cf40241cf | ||
|
|
72e96ec73f | ||
|
|
5d593a1700 | ||
|
|
81495ea530 | ||
|
|
494b53192d | ||
|
|
8c0f3e418e | ||
|
|
d2f548d0e2 |
+124
@@ -1,3 +1,127 @@
|
||||
## 🎉 Wildcard Pipeline 2.13.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Firefox: the editor no longer dies when you insert a nested reference
|
||||
|
||||
Picking a wildcard from the `@` autocomplete could take down the whole editor —
|
||||
the field stopped accepting text, Backspace did nothing, and Save and Cancel
|
||||
stopped responding until the page was reloaded. Select-all then cut inside an
|
||||
option value did the same thing.
|
||||
|
||||
Both were the same cause. Firefox removes the empty text nodes that Vue uses to
|
||||
mark where each chip and text run begins and ends, and once they are gone any
|
||||
edit that adds or removes a chip runs off the end of the field's contents.
|
||||
Chromium keeps those markers, which is why this only ever happened on Firefox
|
||||
and never reproduced during development.
|
||||
|
||||
The editor now notices when those markers have been removed and rebuilds the
|
||||
field instead of tripping over them. This affects every rich text field —
|
||||
option values, combine templates, derivation rules and injector bindings, on
|
||||
the canvas and in the manager alike.
|
||||
|
||||
### Your library is protected against version mismatches
|
||||
|
||||
If a database has been through migrations that this build does not recognise —
|
||||
after downgrading, hand-installing an older copy, or switching versions in
|
||||
ComfyUI Manager — Wildcard Pipeline now stops with a clear message instead of
|
||||
quietly reading a library it does not understand. Previously it carried on, and
|
||||
every later edit was written through assumptions that no longer held.
|
||||
|
||||
### Release archives now include the interface translations
|
||||
|
||||
The release zip was missing `locales/`, so node names, descriptions and setting
|
||||
labels fell back to raw identifiers for anyone installing from it. Installs
|
||||
from ComfyUI Manager and the Comfy Registry were unaffected.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.1…v2.13.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.1...v2.13.2)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
A security fix for the database location setting. Update when you can.
|
||||
|
||||
> **Already on 2.13.0?** This is the same release. 2.13.0 reached GitHub but not the
|
||||
> ComfyUI Registry, so it never arrived for anyone installing through ComfyUI Manager.
|
||||
> 2.13.1 is that same build under a version number the registry will accept — nothing
|
||||
> in it differs.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
|
||||
|
||||
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
|
||||
|
||||
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
|
||||
7. Security fixes say what an attacker could have done, in plain language, and
|
||||
whether the reader must act. Never ship one as "hardening" or "internal
|
||||
improvements" — someone deciding whether to update tonight needs the facts,
|
||||
and vagueness reads as a cover-up once the diff is public anyway.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.0…v2.13.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.0...v2.13.1)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
+107
-9
@@ -14,6 +14,7 @@ import graph.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -26,6 +27,24 @@ _SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
|
||||
|
||||
|
||||
class SchemaAheadError(RuntimeError):
|
||||
"""The database was migrated by a build this one cannot safely read.
|
||||
|
||||
Raised instead of letting the runner's "skip anything already applied"
|
||||
rule quietly hand a foreign schema to code that expects its own.
|
||||
"""
|
||||
|
||||
|
||||
def _checksum(path: Path) -> str:
|
||||
"""sha256 of a migration file's bytes.
|
||||
|
||||
Bytes, not parsed content: a `.py` migration's behaviour lives in code the
|
||||
runner `exec`s, so any edit at all is a different migration regardless of
|
||||
whether the SQL it emits looks the same.
|
||||
"""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
@@ -75,8 +94,93 @@ def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
|
||||
return up # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def head_version() -> int:
|
||||
"""Highest migration version this build ships."""
|
||||
pairs = _discover()
|
||||
return pairs[-1][0] if pairs else 0
|
||||
|
||||
|
||||
def _has_checksum_column(conn: sqlite3.Connection) -> bool:
|
||||
cur = conn.execute("PRAGMA table_info(migrations);")
|
||||
return any(row[1] == "checksum" for row in cur.fetchall())
|
||||
|
||||
|
||||
def _record(conn: sqlite3.Connection, version: int, mig_file: Path) -> None:
|
||||
"""Insert the applied-migration row, with a checksum when we can store one.
|
||||
|
||||
Migration 017 is what adds the column, so on a fresh database every
|
||||
migration before it inserts without one. Those rows stay NULL forever —
|
||||
see the 017 docblock for why backfilling them would defeat the check.
|
||||
"""
|
||||
if _has_checksum_column(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at, checksum) VALUES(?, ?, ?);",
|
||||
(version, now_iso(), _checksum(mig_file)),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def assert_schema_compatible(conn: sqlite3.Connection) -> None:
|
||||
"""Refuse to touch a database some other build's migrations shaped.
|
||||
|
||||
Two distinct failures, both silent before this existed:
|
||||
|
||||
1. The database is AHEAD — migrated by a newer build. Every migration is
|
||||
`<= MAX(version)`, so the runner does nothing and the caller reads a
|
||||
schema it has never seen.
|
||||
2. The database is at the SAME version but a DIFFERENT shape, because a
|
||||
migration file changed after it had already run somewhere. Version
|
||||
numbers match, so nothing compares anything.
|
||||
|
||||
Both are cumulative: each subsequent write goes through code whose
|
||||
assumptions about the row shape are wrong. Raising is recoverable;
|
||||
continuing is not.
|
||||
"""
|
||||
if not _migrations_table_exists(conn):
|
||||
return
|
||||
|
||||
applied = current_version(conn)
|
||||
head = head_version()
|
||||
if applied > head:
|
||||
raise SchemaAheadError(
|
||||
f"This database was migrated to version {applied}, but this build only "
|
||||
f"knows up to {head}. It was almost certainly written by a newer "
|
||||
f"version of Wildcard Pipeline. Reading it here could corrupt your "
|
||||
f"library — install the newer version again, or restore a backup."
|
||||
)
|
||||
|
||||
if not _has_checksum_column(conn):
|
||||
return
|
||||
|
||||
known = dict(_discover())
|
||||
rows = conn.execute(
|
||||
"SELECT version, checksum FROM migrations WHERE checksum IS NOT NULL;"
|
||||
).fetchall()
|
||||
for version, checksum in rows:
|
||||
mig_file = known.get(int(version))
|
||||
# Unknown version is the `applied > head` case, already handled above.
|
||||
if mig_file is None:
|
||||
continue
|
||||
if _checksum(mig_file) != checksum:
|
||||
raise SchemaAheadError(
|
||||
f"Migration {int(version):03d} was applied by a build whose copy of "
|
||||
f"it differs from this one's. The version numbers match but the "
|
||||
f"schemas may not, so this build cannot safely read your library. "
|
||||
f"Restore a backup taken before that build ran."
|
||||
)
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply all pending migrations. Safe to call repeatedly."""
|
||||
"""Apply all pending migrations. Safe to call repeatedly.
|
||||
|
||||
Guards first: applying this build's migrations on top of a schema another
|
||||
build shaped is how a mismatch becomes permanent.
|
||||
"""
|
||||
assert_schema_compatible(conn)
|
||||
applied = current_version(conn)
|
||||
for version, mig_file in _discover():
|
||||
if version <= applied:
|
||||
@@ -85,10 +189,7 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
up = _load_py_migration(mig_file)
|
||||
with conn:
|
||||
up(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
else:
|
||||
sql = mig_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
@@ -96,7 +197,4 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Migration 017 — record WHICH migration ran, not just how many.
|
||||
|
||||
``migrations`` tracked only ``version``, and the runner skips anything
|
||||
``<= MAX(version)``. That makes an integer the entire identity of a
|
||||
schema, which it is not:
|
||||
|
||||
- A build whose head is 015 opened against a DB migrated to 016 skips
|
||||
every migration, finds nothing to do, and reads a schema it has never
|
||||
seen. Additive columns survive; a shape change does not.
|
||||
- Worse, two builds can both call something "016" and mean different
|
||||
things — a reworked migration, a hand-edited file, a branch that never
|
||||
shipped. The version matches, so nothing anywhere notices.
|
||||
|
||||
Both are silent, and both corrupt cumulatively: every subsequent write
|
||||
goes through code whose assumptions about the row shape are wrong.
|
||||
|
||||
``checksum`` fixes it by storing the sha256 of the migration file that
|
||||
actually ran. ``assert_schema_compatible`` then compares applied
|
||||
checksums against the files this build ships, so a divergence is a
|
||||
loud, recoverable error instead of silence.
|
||||
|
||||
Nullable on purpose: rows applied before this migration existed have no
|
||||
recorded checksum and cannot retroactively grow one. The guard skips
|
||||
them rather than guessing — a NULL means "unknown", never "mismatched".
|
||||
|
||||
Idempotent via ``PRAGMA table_info`` so tests that rewind the
|
||||
``migrations`` table can re-run it against a populated DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
if not _has_column(conn, "migrations", "checksum"):
|
||||
conn.execute("ALTER TABLE migrations ADD COLUMN checksum TEXT;")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.13.0",
|
||||
"version": "2.13.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.13.0"
|
||||
version = "2.13.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* - web/ (built manager SPA)
|
||||
* - public/ (favicons + doc images referenced by the SPA)
|
||||
* - docs/help/ (per-node help markdown the canvas reads)
|
||||
* - locales/ (node display names + settings labels ComfyUI reads)
|
||||
* - README.md
|
||||
* - LICENSE
|
||||
*
|
||||
@@ -59,8 +60,55 @@ const SHIP_PATHS = [
|
||||
"web",
|
||||
"public",
|
||||
"docs/help",
|
||||
// ComfyUI reads `custom_nodes/<pack>/locales/<lang>/` for node display
|
||||
// names, descriptions and settings labels (app/custom_node_manager.py).
|
||||
// The registry archive picks this up automatically because it ships
|
||||
// everything git-tracked that `.comfyignore` doesn't exclude; this list is
|
||||
// hand-maintained, so it did not. The two artifacts silently disagreed —
|
||||
// see DEV_ONLY below for the guard that now stops that recurring.
|
||||
"locales",
|
||||
];
|
||||
|
||||
/**
|
||||
* Top-level tracked entries that deliberately do NOT ship.
|
||||
*
|
||||
* Exists to make the ship list total: every top-level path in git is either
|
||||
* shipped or listed here, and `assertShipListIsTotal` fails the release if a
|
||||
* new one appears in neither. Without it, adding a runtime directory and
|
||||
* forgetting this file produces a zip that installs and silently misbehaves —
|
||||
* which is exactly how `locales/` went missing.
|
||||
*/
|
||||
const DEV_ONLY = new Set([
|
||||
".comfyignore", ".gitattributes", ".github", ".gitignore", ".husky", ".npmrc",
|
||||
".releaserc.js", "CHANGELOG.md", "CLAUDE.md", "config", "conftest.py",
|
||||
"eslint.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
||||
"scripts", "src", "tests", "tsconfig.json", "tsconfig.node.json",
|
||||
"vite.config.mts", "vitest.config.ts",
|
||||
]);
|
||||
|
||||
function assertShipListIsTotal() {
|
||||
const ls = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" });
|
||||
if (ls.status !== 0) {
|
||||
console.warn("pack-release: `git ls-files` unavailable — skipping ship-list completeness check");
|
||||
return;
|
||||
}
|
||||
// A ship path may name a subtree (`docs/help`); its top-level segment is
|
||||
// what makes the parent directory "accounted for".
|
||||
const covered = new Set(SHIP_PATHS.map((p) => p.split("/")[0]));
|
||||
const tracked = new Set(
|
||||
ls.stdout.split("\n").map((line) => line.trim().split("/")[0]).filter(Boolean),
|
||||
);
|
||||
const unaccounted = [...tracked].filter((e) => !covered.has(e) && !DEV_ONLY.has(e)).sort();
|
||||
if (unaccounted.length > 0) {
|
||||
console.error("pack-release: top-level paths in git that are neither shipped nor dev-only:");
|
||||
for (const p of unaccounted) console.error(` - ${p}`);
|
||||
console.error("Add each to SHIP_PATHS (ships to users) or DEV_ONLY (stays in the repo).");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
assertShipListIsTotal();
|
||||
|
||||
// Sanity: every path must exist before zipping. If `js/` or `web/`
|
||||
// is missing the release would ship a broken bundle — catch here so the
|
||||
// release workflow fails fast rather than uploading a half-baked zip.
|
||||
@@ -93,6 +141,13 @@ const args = [
|
||||
];
|
||||
|
||||
const result = spawnSync("zip", args, { stdio: "inherit", cwd: root });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
// Windows has no `zip` by default, so a maintainer running this locally
|
||||
// otherwise gets a bare "exited with status null".
|
||||
console.error("pack-release: the `zip` CLI was not found on PATH.");
|
||||
console.error("The release workflow runs on ubuntu-latest where it is always present.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.error(`pack-release: zip exited with status ${result.status}`);
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
@@ -244,7 +244,14 @@ export function useGrowableField(
|
||||
window.removeEventListener("pointercancel", onPointerUp);
|
||||
}
|
||||
|
||||
/** Idempotent: callers re-attach when the element they own is REPLACED
|
||||
* rather than mutated (RichTextInput swaps its contenteditable host to
|
||||
* repair a browser-mangled DOM). Without the disconnect the old observer
|
||||
* would keep watching a detached node and leak, and the hint would go
|
||||
* permanently stale on the live one. */
|
||||
function attach(): void {
|
||||
obs?.disconnect();
|
||||
obs = null;
|
||||
const el = getEl();
|
||||
if (!el) return;
|
||||
lastHeight = el.getBoundingClientRect().height;
|
||||
|
||||
@@ -302,6 +302,113 @@ describe("RichTextInput.vue", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("survives a blur re-parse that drops an atom after the anchors went missing", async () => {
|
||||
// The sibling of the picker-Apply crash: same missing `<template v-for>`
|
||||
// fragment anchors, reached from the plain re-parse path (no `rebuild`).
|
||||
// Shrinking the list unmounts an item's Fragment, and `removeFragment`
|
||||
// would walk off the end exactly as it does on a full re-key.
|
||||
// Here `hostDomIsStale()` DOES fire — the deleted chip drops the atom-node
|
||||
// count below the list length — so the host swap already covers it. This
|
||||
// pins that: the two repairs must not diverge, since only one of them was
|
||||
// ever reachable when the anchors are gone but the elements are intact.
|
||||
const errors: string[] = [];
|
||||
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
|
||||
window.addEventListener("error", onErr);
|
||||
const ref = "@{aabbccdd#pose}";
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: `a ${ref} b ${ref} c`,
|
||||
surface: "wildcard",
|
||||
uuidToName: new Map([["aabbccdd", "pose"]]),
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
[...host.childNodes]
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "")
|
||||
.forEach((n) => (n as Text).remove());
|
||||
// User deletes the second chip; the surviving atom nodes stay put.
|
||||
const chips = host.querySelectorAll(".wp-refchip");
|
||||
chips[1].remove();
|
||||
await wrap.find(".wp-rt__host").trigger("blur");
|
||||
await flushPromises();
|
||||
window.removeEventListener("error", onErr);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(wrap.findAll(".wp-refchip")).toHaveLength(1);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("survives Ctrl+A Ctrl+X after the anchors went missing", async () => {
|
||||
// Reported on Firefox: select-all + cut inside an option value bricked the
|
||||
// field — text input, Backspace and even Save/Cancel stopped responding —
|
||||
// with `insertBefore: Child to insert before is not a child of this node`.
|
||||
//
|
||||
// `onHostCut` already routes the deletion through `applyAtoms` (2.13.1) so
|
||||
// the browser never removes the spans itself. But the atom list still
|
||||
// SHRINKS from N to 1, and shrinking a keyed list unmounts the surplus
|
||||
// `<template v-for>` Fragments. With the fragment anchors normalised away
|
||||
// by Firefox, that teardown walks off the end of the child list and takes
|
||||
// the whole editor down — the same wound as the ref-picker crash, reached
|
||||
// through a path that never asked for a rebuild.
|
||||
const errors: string[] = [];
|
||||
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
|
||||
window.addEventListener("error", onErr);
|
||||
const ref = "@{aabbccdd#pose}";
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: `a ${ref} b ${ref} c`,
|
||||
surface: "wildcard",
|
||||
uuidToName: new Map([["aabbccdd", "pose"]]),
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
[...host.childNodes]
|
||||
.filter((n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "")
|
||||
.forEach((n) => (n as Text).remove());
|
||||
// Ctrl+A.
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(host);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
// Ctrl+X.
|
||||
await wrap.find(".wp-rt__host").trigger("cut");
|
||||
await flushPromises();
|
||||
window.removeEventListener("error", onErr);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
// Emptied, and still a live editor rather than a dead subtree.
|
||||
const events = wrap.emitted("update:modelValue") ?? [];
|
||||
expect(events[events.length - 1]?.[0]).toBe("");
|
||||
const fresh = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
expect(fresh.querySelectorAll("[data-atom-index]").length).toBeGreaterThan(0);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does not remount the host for a structural apply while the anchors are intact", async () => {
|
||||
// The other half of the anchor check: it must stay OFF on the browser
|
||||
// where the anchors survive. Settling a chip is a structural apply — it
|
||||
// splits one text atom into text + chip + text — and it happens on nearly
|
||||
// every keystroke that closes a token. Swapping the host there would cost
|
||||
// a caret restore per settle on Chromium for no reason at all.
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: { modelValue: "", varSuggestions: [] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
const before = wrap.find(".wp-rt__host").element;
|
||||
const span = (before as HTMLElement).querySelector(".wp-rt__text") as HTMLElement;
|
||||
span.textContent = "$runtimeVar ";
|
||||
await wrap.find(".wp-rt__host").trigger("input", { inputType: "insertText", data: " " });
|
||||
await flushPromises();
|
||||
// The settle landed...
|
||||
expect(wrap.findAll(".wp-refchip--var")).toHaveLength(1);
|
||||
// ...on the very same host element.
|
||||
expect(wrap.find(".wp-rt__host").element).toBe(before);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does not remount the host for the normal typing case (orphan text node)", async () => {
|
||||
// Guard against over-triggering the stale-DOM repair: browsers routinely
|
||||
// insert user-typed characters as raw text nodes directly under the host.
|
||||
@@ -681,6 +788,61 @@ describe("RichTextInput.vue", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("survives a picker Apply after the browser dropped the v-for fragment anchors", async () => {
|
||||
// Regression (Firefox): `<template v-for>` mounts each atom inside a
|
||||
// Fragment, and a Fragment is delimited in the DOM by two EMPTY text
|
||||
// nodes. Those anchors live inside a contenteditable, and Firefox
|
||||
// normalises empty text nodes away as the user types — Chromium keeps
|
||||
// them, which is why this only ever reproduced on Firefox.
|
||||
//
|
||||
// The picker-Apply path then remounts the atom v-for, and Vue's
|
||||
// `removeFragment` walks `nextSibling` from the start anchor until it
|
||||
// reaches the end anchor. With the end anchor gone the walk runs off the
|
||||
// end of the child list and derefs null: "can't access property
|
||||
// nextSibling, e is null", followed by the unmount cascade that leaves
|
||||
// the editor dead behind the ErrorBoundary.
|
||||
//
|
||||
// `hostDomIsStale()` cannot see this — it counts `[data-atom-index]`
|
||||
// ELEMENTS, and the destroyed nodes are anchors, not atoms.
|
||||
const errors: string[] = [];
|
||||
const onErr = (e: ErrorEvent): void => { errors.push(String(e.error ?? e.message)); };
|
||||
window.addEventListener("error", onErr);
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "hair ",
|
||||
surface: "wildcard",
|
||||
refSuggestions: ["aabbccdd"],
|
||||
uuidToName: new Map([["aabbccdd", "color"]]),
|
||||
uuidToSubCategories: new Map([["aabbccdd", ["warm", "cool"]]]),
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
// What Firefox does to the host while the user types `@hair`.
|
||||
const anchors = [...host.childNodes].filter(
|
||||
(n) => n.nodeType === Node.TEXT_NODE && (n as Text).data === "",
|
||||
);
|
||||
expect(anchors.length).toBeGreaterThan(0);
|
||||
anchors.forEach((n) => (n as Text).remove());
|
||||
|
||||
await (wrap.vm as unknown as { __triggerAutocompleteForTest: (t: "@" | "$") => Promise<void> })
|
||||
.__triggerAutocompleteForTest("@");
|
||||
await (wrap.vm as unknown as { __applyAutocompleteForTest: (label: string) => Promise<void> })
|
||||
.__applyAutocompleteForTest("aabbccdd");
|
||||
(document.querySelector('[data-test="subcat-chip"][data-value="warm"]') as HTMLElement).click();
|
||||
await flushPromises();
|
||||
(document.querySelector('[data-test="picker-apply"]') as HTMLElement).click();
|
||||
await flushPromises();
|
||||
window.removeEventListener("error", onErr);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
// And the insert actually landed — the editor is still alive, not a
|
||||
// half-torn subtree.
|
||||
const events = wrap.emitted("update:modelValue") ?? [];
|
||||
expect(events[events.length - 1]?.[0]).toContain("@{aabbccdd#color:warm}");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("subcat picker Skip inserts plain @{uuid#name} chip", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
|
||||
@@ -542,20 +542,27 @@ function parseForSurface(text: string): Atom[] {
|
||||
}
|
||||
|
||||
const atoms = ref<Atom[]>(padAtoms(parseForSurface(props.modelValue || "")));
|
||||
// Bumped only on structural-insert applies (autocomplete / ref-picker) to
|
||||
// force a clean teardown+remount of the atom v-for instead of patching the
|
||||
// new structure over a hand-mutated, out-of-sync contenteditable DOM. Folded
|
||||
// into the v-for :key. See applyAtoms() for why the typing path never bumps it.
|
||||
const renderEpoch = ref(0);
|
||||
// Bumped when the browser destroyed rendered atom nodes behind Vue's back
|
||||
// (see `hostDomIsStale`). Keyed on the HOST ELEMENT, not the atom v-for:
|
||||
// once Vue's child vnodes hold detached `el`s, ANY patch of that subtree —
|
||||
// including the full key-churn teardown `renderEpoch` triggers — derefs a
|
||||
// null `el` and throws `insertBefore: Child to insert before is not a child
|
||||
// of this node`. Replacing the host element itself is the only safe repair:
|
||||
// Vue unmounts an element vnode with a single `hostRemove(el)` on a node
|
||||
// that IS still attached, tearing children down with `doRemove: false` so
|
||||
// no stale child el is ever touched.
|
||||
// Bumped whenever the atom v-for has to be rebuilt rather than patched:
|
||||
// the browser destroyed rendered nodes behind Vue's back (see
|
||||
// `hostDomIsStale`), or a structural insert is landing on a hand-mutated
|
||||
// contenteditable (autocomplete / ref-picker apply).
|
||||
//
|
||||
// Keyed on the HOST ELEMENT, never on the atom v-for. Re-keying the v-for
|
||||
// looks like the lighter option and is not: the host is a contenteditable,
|
||||
// so every node inside it — including the two EMPTY TEXT NODES Vue uses to
|
||||
// delimit each `<template v-for>` Fragment — belongs to the browser, which
|
||||
// deletes and merges them at will (Firefox normalises empty text nodes away
|
||||
// as you type; Chromium keeps them, which is why this only ever reproduced
|
||||
// on Firefox). Once an anchor is gone, tearing the v-for down runs Vue's
|
||||
// `removeFragment` walk off the end of the child list and throws
|
||||
// `nextSibling of null`, and patching over detached child els throws
|
||||
// `insertBefore: Child to insert before is not a child of this node`.
|
||||
//
|
||||
// Replacing the host element sidesteps both: Vue unmounts an element vnode
|
||||
// with a single `hostRemove(el)` on a node that IS still attached, tearing
|
||||
// its children down with `doRemove: false` so no stale child el or missing
|
||||
// anchor is ever touched, then mounts the new tree into an empty element
|
||||
// with no anchor to insert before.
|
||||
const hostEpoch = ref(0);
|
||||
let lastEmittedValue = props.modelValue || "";
|
||||
|
||||
@@ -1359,6 +1366,17 @@ onMounted(() => {
|
||||
attach();
|
||||
});
|
||||
|
||||
/** A host swap replaces the observed element, so the ResizeObserver has to
|
||||
* follow it — otherwise the overflow fade and grip-follow silently stop
|
||||
* working for the rest of the session on any field that ever had to repair
|
||||
* its DOM. */
|
||||
watch(hostEpoch, () => {
|
||||
void nextTick(() => {
|
||||
attach();
|
||||
scheduleOverflowHint();
|
||||
});
|
||||
});
|
||||
|
||||
/** True when the live host has FEWER rendered atom nodes than `atoms.value`
|
||||
* expects — i.e. the browser destroyed nodes Vue still holds `el` pointers
|
||||
* for. Contenteditable hands the browser ownership of these children, and
|
||||
@@ -1377,24 +1395,87 @@ function hostDomIsStale(): boolean {
|
||||
return host.querySelectorAll("[data-atom-index]").length < atoms.value.length;
|
||||
}
|
||||
|
||||
/** True while every Fragment anchor Vue needs is still in the host.
|
||||
*
|
||||
* Vue delimits each Fragment with two EMPTY TEXT NODES, and `<template
|
||||
* v-for>` builds one Fragment per atom plus one for the list itself — so a
|
||||
* healthy host holds `2N + 2` empty text nodes as direct children. They sit
|
||||
* inside a contenteditable, which makes them the browser's to destroy:
|
||||
* Firefox normalises empty text nodes away as the user types, Chromium keeps
|
||||
* them. That difference is why every crash in this family has been
|
||||
* Firefox-only.
|
||||
*
|
||||
* Each anchor is load-bearing. Removing a list item walks `nextSibling` from
|
||||
* its start anchor until it reaches its end anchor (`removeFragment`), and
|
||||
* appending one inserts before the LIST's end anchor. With either gone the
|
||||
* render throws — `nextSibling of null`, or `insertBefore: Child to insert
|
||||
* before is not a child of this node` — and leaves a half-torn subtree that
|
||||
* no longer responds to anything, Save and Cancel included.
|
||||
*
|
||||
* `hostDomIsStale` cannot stand in for this: it counts `[data-atom-index]`
|
||||
* ELEMENTS, and anchors are neither elements nor atoms.
|
||||
*
|
||||
* Counts EMPTY text nodes only — characters the browser drops directly under
|
||||
* the host are non-empty (see `reconcileOrphanTextNodes`), and a surplus is
|
||||
* harmless, so the test is `>=`. */
|
||||
function hostAnchorsIntact(): boolean {
|
||||
const host = hostEl.value;
|
||||
if (!host) return true;
|
||||
let empties = 0;
|
||||
for (const node of host.childNodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE && (node as Text).data === "") empties += 1;
|
||||
}
|
||||
return empties >= 2 * atoms.value.length + 2;
|
||||
}
|
||||
|
||||
/** True when applying `next` would ADD or REMOVE atom nodes, or flip an atom
|
||||
* between its chip and its text branch.
|
||||
*
|
||||
* Those are exactly the renders that make Vue insert or unmount list items,
|
||||
* and therefore the only ones that touch a fragment anchor. A same-shape
|
||||
* apply patches text and props in place and cannot trip over a missing one,
|
||||
* which is what keeps ordinary typing on the cheap path. */
|
||||
function isStructuralApply(next: Atom[]): boolean {
|
||||
const cur = atoms.value;
|
||||
if (cur.length !== next.length) return true;
|
||||
return cur.some((atom, i) => (atom.kind === "text") !== (next[i].kind === "text"));
|
||||
}
|
||||
|
||||
function applyAtoms(next: Atom[], opts?: { rebuild?: boolean }): void {
|
||||
// `rebuild` forces a full teardown+remount of the atom v-for (via the
|
||||
// renderEpoch key) instead of an in-place patch. Used ONLY on the
|
||||
// structural-insert paths (autocomplete / ref-picker apply): there the
|
||||
// contenteditable already lost focus to the popover/modal, so no live
|
||||
// caret or typing is disturbed, AND the host DOM may have been hand-mutated
|
||||
// (orphan text nodes, in-place span textContent) out of sync with Vue's
|
||||
// vdom. Patching the new atom structure over that stale DOM is what throws
|
||||
// `insertBefore: Child to insert before is not a child of this node`.
|
||||
// `rebuild` forces a full teardown+remount instead of an in-place patch.
|
||||
// Used ONLY on the structural-insert paths (autocomplete / ref-picker
|
||||
// apply): there the contenteditable already lost focus to the popover/modal,
|
||||
// so no live caret or typing is disturbed, AND the host DOM may have been
|
||||
// hand-mutated (orphan text nodes, in-place span textContent, fragment
|
||||
// anchors normalised away) out of sync with Vue's vdom. Patching the new
|
||||
// atom structure over that stale DOM is what throws `insertBefore: Child to
|
||||
// insert before is not a child of this node`.
|
||||
// The raw typing / Backspace paths deliberately do NOT rebuild — they keep
|
||||
// the imperative syncTextSpansToAtoms approach for caret stability (the
|
||||
// removed global renderTick churned those paths; this scopes it to inserts).
|
||||
// Stale-DOM repair takes precedence: bumping `renderEpoch` here would NOT
|
||||
// help (verified — key churn still walks the destroyed els and throws on
|
||||
// `nextSibling` of null). Replace the host element instead.
|
||||
if (hostDomIsStale()) hostEpoch.value += 1;
|
||||
else if (opts?.rebuild) renderEpoch.value += 1;
|
||||
atoms.value = padAtoms(next);
|
||||
//
|
||||
// Both routes go through the HOST ELEMENT swap, never a v-for re-key:
|
||||
// re-keying was verified NOT to help — the key churn still walks destroyed
|
||||
// els and missing `<template v-for>` fragment anchors and throws on
|
||||
// `nextSibling` of null. See the `hostEpoch` docblock.
|
||||
//
|
||||
// The third trigger is the general one, and the reason `rebuild` is no
|
||||
// longer load-bearing on its own: ANY apply that adds, removes or re-branches
|
||||
// an atom needs the fragment anchors, and the browser may have taken them.
|
||||
// Marking individual call sites `rebuild` was a whitelist, and the whitelist
|
||||
// kept missing entries — the ref picker, then Ctrl+A Ctrl+X, each crashing
|
||||
// the same way from a path nobody had flagged. Asking the DOM instead covers
|
||||
// all thirteen call sites at once, and costs nothing on Chromium, where the
|
||||
// anchors are never missing in the first place.
|
||||
const padded = padAtoms(next);
|
||||
if (
|
||||
hostDomIsStale() ||
|
||||
opts?.rebuild ||
|
||||
(isStructuralApply(padded) && !hostAnchorsIntact())
|
||||
) {
|
||||
hostEpoch.value += 1;
|
||||
}
|
||||
atoms.value = padded;
|
||||
isEmpty.value = serialiseAtomsLocal(next).length === 0;
|
||||
void nextTick(() => {
|
||||
syncTextSpansToAtoms();
|
||||
@@ -2640,7 +2721,7 @@ function onHostKeydown(ev: KeyboardEvent): void {
|
||||
@copy="onHostCopy"
|
||||
@cut="onHostCut"
|
||||
>
|
||||
<template v-for="(atom, idx) in atoms" :key="renderEpoch + ':' + idx">
|
||||
<template v-for="(atom, idx) in atoms" :key="idx">
|
||||
<RefChip
|
||||
v-if="atom.kind === 'ref' || atom.kind === 'var'"
|
||||
:kind="atom.kind"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Migration 017 — checksums, and the guard they enable.
|
||||
|
||||
The bug being fixed: ``migrate()`` skips every migration ``<= MAX(version)``,
|
||||
so an integer was the entire identity of a schema. A database migrated by
|
||||
another build was either silently skipped past (it is "ahead") or silently
|
||||
accepted (same number, different shape). Both let code read and then write a
|
||||
schema it does not actually understand.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.db.connection import get_connection
|
||||
from engine.db.migrations import (
|
||||
SchemaAheadError,
|
||||
_discover,
|
||||
assert_schema_compatible,
|
||||
current_version,
|
||||
head_version,
|
||||
migrate,
|
||||
)
|
||||
|
||||
|
||||
def test_checksum_column_exists_after_migrate(tmp_path):
|
||||
conn = get_connection(tmp_path / "c.db")
|
||||
migrate(conn)
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(migrations);").fetchall()}
|
||||
assert "checksum" in cols
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_rerunning_migrate_is_a_noop(tmp_path):
|
||||
"""The guard runs on every migrate(); a healthy DB must sail through it."""
|
||||
db = tmp_path / "idem.db"
|
||||
conn = get_connection(db)
|
||||
migrate(conn)
|
||||
first = current_version(conn)
|
||||
migrate(conn)
|
||||
assert current_version(conn) == first
|
||||
rows = conn.execute("SELECT COUNT(*) FROM migrations;").fetchone()[0]
|
||||
assert rows == first
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migration_017_records_its_own_checksum(tmp_path):
|
||||
"""017 adds the column inside its own transaction, so its row can carry one.
|
||||
|
||||
Everything before it cannot — the column does not exist yet — which is why
|
||||
the guard treats NULL as "unknown" rather than "mismatched".
|
||||
"""
|
||||
conn = get_connection(tmp_path / "own.db")
|
||||
migrate(conn)
|
||||
row = conn.execute("SELECT checksum FROM migrations WHERE version = 17;").fetchone()
|
||||
assert row is not None
|
||||
assert isinstance(row[0], str) and len(row[0]) == 64
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_pre_017_migrations_have_null_checksum(tmp_path):
|
||||
conn = get_connection(tmp_path / "null.db")
|
||||
migrate(conn)
|
||||
nulls = conn.execute(
|
||||
"SELECT COUNT(*) FROM migrations WHERE version < 17 AND checksum IS NULL;"
|
||||
).fetchone()[0]
|
||||
assert nulls == 16
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_database_ahead_of_this_build_is_refused(tmp_path):
|
||||
"""A DB migrated by a newer build must not be read as if it were ours."""
|
||||
db = tmp_path / "ahead.db"
|
||||
conn = get_connection(db)
|
||||
migrate(conn)
|
||||
# A future build applied something this one has never heard of.
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(head_version() + 1, "2026-08-09T00:00:00Z"),
|
||||
)
|
||||
with pytest.raises(SchemaAheadError) as exc:
|
||||
assert_schema_compatible(conn)
|
||||
assert "newer version" in str(exc.value)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrate_refuses_an_ahead_database(tmp_path):
|
||||
"""The guard is inside migrate(), so no caller can forget it."""
|
||||
conn = get_connection(tmp_path / "ahead2.db")
|
||||
migrate(conn)
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(head_version() + 5, "2026-08-09T00:00:00Z"),
|
||||
)
|
||||
with pytest.raises(SchemaAheadError):
|
||||
migrate(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_same_version_different_shape_is_refused(tmp_path):
|
||||
"""The case an integer version cannot catch.
|
||||
|
||||
A migration is reworked after it has already run somewhere — a pre-release
|
||||
that got revised before shipping, a hand-edited file, an abandoned branch.
|
||||
The version still matches, so before checksums nothing compared anything.
|
||||
"""
|
||||
conn = get_connection(tmp_path / "diverged.db")
|
||||
migrate(conn)
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE migrations SET checksum = ? WHERE version = 17;",
|
||||
("0" * 64,),
|
||||
)
|
||||
with pytest.raises(SchemaAheadError) as exc:
|
||||
assert_schema_compatible(conn)
|
||||
assert "017" in str(exc.value)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_null_checksums_are_not_treated_as_mismatched(tmp_path):
|
||||
"""NULL means "we never recorded one", which is not evidence of divergence.
|
||||
|
||||
Every database that existed before 017 is in this state for versions 1-16;
|
||||
treating unknown as bad would lock every existing user out of their library.
|
||||
"""
|
||||
conn = get_connection(tmp_path / "unknown.db")
|
||||
migrate(conn)
|
||||
with conn:
|
||||
conn.execute("UPDATE migrations SET checksum = NULL;")
|
||||
assert_schema_compatible(conn) # must not raise
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_guard_is_silent_on_a_database_with_no_migrations_table(tmp_path):
|
||||
"""A fresh file has nothing to be incompatible with."""
|
||||
conn = sqlite3.connect(tmp_path / "empty.db")
|
||||
assert_schema_compatible(conn) # must not raise
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_every_shipped_migration_hashes_distinctly():
|
||||
"""Guards the guard: identical files would make divergence undetectable."""
|
||||
from engine.db.migrations import _checksum
|
||||
|
||||
sums = [_checksum(path) for _, path in _discover()]
|
||||
assert len(set(sums)) == len(sums)
|
||||
@@ -2,7 +2,7 @@
|
||||
import sqlite3
|
||||
|
||||
from engine.db.connection import get_connection
|
||||
from engine.db.migrations import _discover, current_version, migrate
|
||||
from engine.db.migrations import _discover, current_version, head_version, migrate
|
||||
|
||||
|
||||
def test_initial_version_is_zero(tmp_path):
|
||||
@@ -30,9 +30,10 @@ def test_migrate_records_version(tmp_path):
|
||||
conn = get_connection(tmp_path / "v2.db")
|
||||
migrate(conn)
|
||||
# Keep this assertion in sync with the highest-numbered migration in
|
||||
# ``engine/db/migrations_sql``. (016_wildcard_multi_subcategory_v2.py
|
||||
# is current head.)
|
||||
assert current_version(conn) == 16
|
||||
# ``engine/db/migrations_sql``. (017_migration_checksums.py is current
|
||||
# head.)
|
||||
assert current_version(conn) == 17
|
||||
assert head_version() == 17
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -282,7 +283,7 @@ def test_004_is_idempotent(tmp_path):
|
||||
conn = get_connection(tmp_path / "i.db")
|
||||
migrate(conn)
|
||||
migrate(conn) # second call should be a no-op
|
||||
assert current_version(conn) == 16
|
||||
assert current_version(conn) == head_version()
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user