* feat(setups): publish success feedback + published-setup discoverability (#227) Post-publish feedback (issue #227 Part 1): - POST /koolook/api/setups now returns the on-disk storagePath - new POST /koolook/api/setups/reveal opens the published-setups folder (shared _open_dir_in_file_manager helper; the snapshot reveal route was refactored onto it, behaviour-identical) - the publish modal renders a success card reusing the snapshot Save dialog's "Saved to ... Open folder" language (Open folder / Copy path / Close) instead of closing silently; a draft publish still gets a confirmation Published discoverability (reuses the tag system, no new schema): - publishing auto-tags the source workflow `published` -> a "published" badge in the Workflows tree + the Tags-section "published" pool - a new "P" toggle in the sidebar Tools row prunes the Workflows tree to published-only, folders force-expanded, so setups show in their origin structure; toggling off restores the full tree - workflowHasTag() shared helper (unit-tested) Deferred, still open on #227: separate destination/library setting (Part 2), relocating the storage folder beside the snapshot library, and clearing the filter when entering Compare mode. Tests: +server (storagePath, reveal route happy + 404), +client (reveal client, buildPublishSuccessView view-model), +sidebar (workflowHasTag). Full suite: 610 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(setups): store published setups beside the snapshot library (#227) - the published-setup registry path now derives from the configured snapshot library (`_configured_dir().parent / koolook-published-setups/setups.json`) instead of a fixed user-dir folder, so it follows libraryPath / KFORGELABS_PRESETS. The default config resolves to the same path as before (target == legacy), so existing default installs are unchanged. - non-destructive one-time migration: copy a legacy user-dir registry to the new location when the new one is absent, leaving the original in place. - sidebar: clear the "P" published filter when entering Compare mode so it doesn't render both compare columns filtered with no toggle to undo it. Tests: +tests/server/test_published_setups_relocation.py (path beside library; migration copy / no-op-when-present / no-op-when-no-legacy). Full suite: 614 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(tests): remove unused pathlib.Path import (ruff F401) The relocation test only uses the `tmp_path` fixture, not `Path` directly, so `from pathlib import Path` was unused and failed the Lint & Smoke (ruff) gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(setups): address PR #239 review — migration masking + tag persist - HIGH: a failed legacy->new migration no longer masks real setups with the bundled sample. The default registry keeps the legacy file as its primary storage when migration could not produce the new file, so stranded data stays visible. (+2 tests: keeps-legacy-on-copy-failure / uses-new-on-success) - MEDIUM/LOW: the publish flow now awaits the `published`-tag persist so the success card reflects a committed tag (persist failures already surface via persistMutation's critical toast), and warns when the source workflow could not be tagged (vanished mid-publish) rather than on a harmless republish. Full suite: 616 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(setups): harden migration against partial/corrupt relocation target Addresses the remaining HIGH from the PR #239 consolidated re-review: a zero-byte/partial/corrupt setups.json at the relocated path could shadow the intact legacy registry (existence was treated as proof of migration). - migration now copies via temp file + atomic os.replace (the same pattern FileSetupStorage.save_setups uses), so an interrupted copy can never leave a partial setups.json at the target; failed-copy temp files are cleaned up. - primary selection is corruption-aware: when the relocated file exists but does not parse as a setups document while the legacy file is intact, the registry keeps serving the legacy file and logs a loud diagnostic. The unreadable file is left untouched (never overwritten) as evidence. A valid relocated file stays authoritative; if both are corrupt, the relocated path stays primary so storage diagnostics point at the current location. Tests (+4): corrupt-target-prefers-intact-legacy (file left untouched), valid-target-stays-authoritative, both-corrupt-keeps-target, and interrupted-copy-leaves-no-partial-target-and-no-tmp-litter. Full suite: 620 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
7.1 KiB
Python
161 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
import koolook_routes
|
|
from koolook_setups import DEFAULT_SETUPS_FILENAME, DEFAULT_SETUPS_SUBDIR
|
|
|
|
|
|
def test_published_setups_path_sits_beside_snapshot_library(monkeypatch, tmp_path) -> None:
|
|
"""Published setups live as a sibling of the configured snapshot library
|
|
(so they follow libraryPath / KFORGELABS_PRESETS) instead of a fixed
|
|
user-dir folder (issue #227)."""
|
|
library = tmp_path / "kforge" / "koolook-presets"
|
|
monkeypatch.setattr(koolook_routes, "_configured_dir", lambda: (library, "settings"))
|
|
|
|
assert koolook_routes._published_setups_path() == (
|
|
tmp_path / "kforge" / DEFAULT_SETUPS_SUBDIR / DEFAULT_SETUPS_FILENAME
|
|
)
|
|
|
|
|
|
def test_migrate_legacy_published_setups_copies_when_target_absent(tmp_path) -> None:
|
|
"""A pre-relocation registry at the old user-dir path is copied to the new
|
|
location when the new one has none yet — non-destructively (old file kept)."""
|
|
legacy = tmp_path / "user" / "koolook-published-setups" / "setups.json"
|
|
legacy.parent.mkdir(parents=True)
|
|
legacy.write_text('{"setups": [{"id": "carried-over"}]}', encoding="utf-8")
|
|
target = tmp_path / "kforge" / "koolook-published-setups" / "setups.json"
|
|
|
|
koolook_routes._migrate_legacy_published_setups(legacy, target)
|
|
|
|
assert target.is_file()
|
|
assert target.read_text(encoding="utf-8") == legacy.read_text(encoding="utf-8")
|
|
assert legacy.is_file() # non-destructive: original is left in place
|
|
|
|
|
|
def test_migrate_legacy_published_setups_noop_when_target_present(tmp_path) -> None:
|
|
"""Migration never overwrites an existing registry at the new location."""
|
|
legacy = tmp_path / "user" / "koolook-published-setups" / "setups.json"
|
|
legacy.parent.mkdir(parents=True)
|
|
legacy.write_text('{"setups": [{"id": "old"}]}', encoding="utf-8")
|
|
target = tmp_path / "kforge" / "koolook-published-setups" / "setups.json"
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text('{"setups": [{"id": "current"}]}', encoding="utf-8")
|
|
|
|
koolook_routes._migrate_legacy_published_setups(legacy, target)
|
|
|
|
assert '"current"' in target.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_migrate_legacy_published_setups_noop_when_no_legacy(tmp_path) -> None:
|
|
"""Fresh install (no old file) migrates nothing and does not create the
|
|
target — the registry's own fallback handles an empty location."""
|
|
legacy = tmp_path / "user" / "koolook-published-setups" / "setups.json"
|
|
target = tmp_path / "kforge" / "koolook-published-setups" / "setups.json"
|
|
|
|
koolook_routes._migrate_legacy_published_setups(legacy, target)
|
|
|
|
assert not target.exists()
|
|
|
|
|
|
def _point_registry(monkeypatch, tmp_path):
|
|
"""Wire the default registry factory at tmp paths and return (legacy, target)."""
|
|
library = tmp_path / "lib" / "koolook-presets"
|
|
monkeypatch.setattr(koolook_routes, "_configured_dir", lambda: (library, "settings"))
|
|
legacy = tmp_path / "user" / "koolook-published-setups" / "setups.json"
|
|
legacy.parent.mkdir(parents=True)
|
|
legacy.write_text('{"setups": []}', encoding="utf-8")
|
|
monkeypatch.setattr(koolook_routes, "default_storage_path", lambda: legacy)
|
|
target = tmp_path / "lib" / "koolook-published-setups" / "setups.json"
|
|
return legacy, target
|
|
|
|
|
|
def test_default_registry_keeps_legacy_data_when_migration_fails(monkeypatch, tmp_path) -> None:
|
|
"""If the legacy->new copy fails, the registry keeps reading the real legacy
|
|
file rather than masking it with the bundled sample (PR #239 review, HIGH)."""
|
|
legacy, target = _point_registry(monkeypatch, tmp_path)
|
|
|
|
def boom(*_a, **_k):
|
|
raise OSError("disk full")
|
|
|
|
monkeypatch.setattr(koolook_routes.shutil, "copy2", boom)
|
|
|
|
registry = koolook_routes._default_published_setup_registry()
|
|
|
|
# Migration could not produce the new file, so the primary storage stays on
|
|
# the legacy file (real data) — not the new path that would sample-fallback.
|
|
assert not target.exists()
|
|
assert registry.storage_path == legacy
|
|
|
|
|
|
def test_default_registry_uses_new_path_after_successful_migration(monkeypatch, tmp_path) -> None:
|
|
"""On a clean migration the registry uses the relocated path."""
|
|
legacy, target = _point_registry(monkeypatch, tmp_path)
|
|
|
|
registry = koolook_routes._default_published_setup_registry()
|
|
|
|
assert target.is_file()
|
|
assert registry.storage_path == target
|
|
|
|
|
|
def test_default_registry_prefers_intact_legacy_over_corrupt_target(monkeypatch, tmp_path) -> None:
|
|
"""A partial/corrupt relocated file (e.g. an interrupted copy) must not
|
|
mask intact legacy data: existence alone is not proof migration succeeded
|
|
(PR #239 re-review, HIGH). The corrupt file is left in place untouched."""
|
|
legacy, target = _point_registry(monkeypatch, tmp_path)
|
|
legacy.write_text('{"setups": [{"id": "real-data"}]}', encoding="utf-8")
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text('{"setups": [{"id": "trunc', encoding="utf-8")
|
|
|
|
registry = koolook_routes._default_published_setup_registry()
|
|
|
|
assert registry.storage_path == legacy
|
|
# Non-destructive: the corrupt file is evidence, not something to overwrite.
|
|
assert target.read_text(encoding="utf-8") == '{"setups": [{"id": "trunc'
|
|
|
|
|
|
def test_default_registry_keeps_valid_target_authoritative(monkeypatch, tmp_path) -> None:
|
|
"""A readable relocated registry stays primary even while the legacy file
|
|
still exists — preferring legacy is strictly a corruption fallback."""
|
|
legacy, target = _point_registry(monkeypatch, tmp_path)
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text('{"setups": [{"id": "migrated"}]}', encoding="utf-8")
|
|
|
|
registry = koolook_routes._default_published_setup_registry()
|
|
|
|
assert registry.storage_path == target
|
|
|
|
|
|
def test_default_registry_keeps_target_when_both_files_corrupt(monkeypatch, tmp_path) -> None:
|
|
"""If legacy is corrupt too there is nothing intact to protect; stay on the
|
|
relocated path so storage diagnostics point at the current location."""
|
|
legacy, target = _point_registry(monkeypatch, tmp_path)
|
|
legacy.write_text("not json either", encoding="utf-8")
|
|
target.parent.mkdir(parents=True)
|
|
target.write_text("not json", encoding="utf-8")
|
|
|
|
registry = koolook_routes._default_published_setup_registry()
|
|
|
|
assert registry.storage_path == target
|
|
|
|
|
|
def test_migration_interrupted_copy_leaves_no_partial_target(monkeypatch, tmp_path) -> None:
|
|
"""An interrupted copy must never leave a partial setups.json at the new
|
|
location (it would shadow the legacy registry on every later boot). The
|
|
migration writes via temp file + atomic replace and cleans up after a
|
|
failure."""
|
|
legacy, target = _point_registry(monkeypatch, tmp_path)
|
|
|
|
real_open = open
|
|
|
|
def partial_copy(src, dst, *args, **kwargs):
|
|
with real_open(dst, "w", encoding="utf-8") as f:
|
|
f.write('{"setups": [{"id"') # simulate dying mid-write
|
|
raise OSError("interrupted")
|
|
|
|
monkeypatch.setattr(koolook_routes.shutil, "copy2", partial_copy)
|
|
|
|
registry = koolook_routes._default_published_setup_registry()
|
|
|
|
assert not target.exists()
|
|
assert list(target.parent.glob("*.tmp")) == []
|
|
assert registry.storage_path == legacy
|