From 8b074c57be5989350cd2e3087b08e141b6d9130e Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:03:39 +0100 Subject: [PATCH 1/2] test(plugin-contract): prove reload after unload The lifecycle suite proved repeated source and exact unload but never loaded a plugin again after unloading it, so a plugin broken only on its second load passed every case. Add a reload scenario that loads, unloads and loads again in a clean shell, compares the second load with the first, and calls the restored callback to observe its effect. Give the fixture callback an observable effect, and add two negative fixtures: one whose unload leaves an inert stub (caught by the surface comparison) and one whose callback survives byte-identical but has lost its effect (caught only by the effect check). Document reload in the README lifecycle example. Refs #34 --- README.md | 29 ++++++++-- .../plugin-contract/fixture.plugin.zsh | 4 +- .../plugin-contract/reload-broken.plugin.zsh | 57 +++++++++++++++++++ .../plugin-contract/reload-stale.plugin.zsh | 36 ++++++++++++ tests/_support/plugin-contract/scenario.zsh | 20 ++++++- tests/plugin-contract.zunit | 27 +++++++++ 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 tests/_support/plugin-contract/reload-broken.plugin.zsh create mode 100644 tests/_support/plugin-contract/reload-stale.plugin.zsh diff --git a/README.md b/README.md index b993852..37b5d50 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,31 @@ assert before plugin_unloaded loaded user_changed after For each resource, this assertion restores the pre-load value when the user did not change the plugin-owned value, otherwise it requires unload to preserve the user's newer value. Use separate clean-shell tests for hostile initial state, -partial initialization failure, and interactive behavior. The repository's -`tests/_support/plugin-contract/scenario.zsh` demonstrates repeated source, -partial failure, hostile state, post-load changes, and both `zsh -f` and -`zsh -f -i` execution. +partial initialization failure, and interactive behavior. + +Repeated source does not prove that a plugin works after it was unloaded. In +another clean shell, load, unload, and load again, then compare the second +load with the first and observe that a restored resource still does its job; +its presence or a zero status alone would accept an inert stub: + +```zsh +zunit_plugin_contract_prime +source ./example.plugin.zsh +zunit_plugin_contract_snapshot loaded + +example_plugin_unload +source ./example.plugin.zsh +zunit_plugin_contract_snapshot reloaded +assert loaded plugin_restored reloaded + +run example_refresh +assert $state equals 0 +assert "$output" same_as 'refreshed' +``` + +The repository's `tests/_support/plugin-contract/scenario.zsh` demonstrates +repeated source, partial failure, hostile state, post-load changes, reload +after unload, and both `zsh -f` and `zsh -f -i` execution. ## 📖 Documentation diff --git a/tests/_support/plugin-contract/fixture.plugin.zsh b/tests/_support/plugin-contract/fixture.plugin.zsh index aebde60..207916f 100644 --- a/tests/_support/plugin-contract/fixture.plugin.zsh +++ b/tests/_support/plugin-contract/fixture.plugin.zsh @@ -15,7 +15,9 @@ _contract_fixture_callback() { builtin emulate -L zsh - : + # Observable effect, so a reload test can prove the callback still works. + (( ${+parameters[_contract_fixture_effect]} )) && _contract_fixture_effect=ran + return 0 } typeset -g _contract_fixture_callback_body=${functions[_contract_fixture_callback]} typeset -g _contract_fixture_value=plugin diff --git a/tests/_support/plugin-contract/reload-broken.plugin.zsh b/tests/_support/plugin-contract/reload-broken.plugin.zsh new file mode 100644 index 0000000..37dc62a --- /dev/null +++ b/tests/_support/plugin-contract/reload-broken.plugin.zsh @@ -0,0 +1,57 @@ +# Negative fixture: the shipped fixture with the reload defect of +# z-shell/z-a-meta-plugins#54. Load defines its callback only when the name +# is free, and unload leaves the callback as an inert stub instead of removing +# it (as a plugin must when a manager can still dispatch to the name). The +# first load and repeated source are correct; after unload and a second load +# the stub survives, so the callback exists and returns 0 but does nothing. +() { + builtin emulate -L zsh + builtin setopt local_options typeset_silent + + (( ${+parameters[_contract_fixture_active]} )) && return 0 + + (( ${+functions[_contract_fixture_callback]} )) || _contract_fixture_callback() { + builtin emulate -L zsh + (( ${+parameters[_contract_fixture_effect]} )) && _contract_fixture_effect=ran + return 0 + } + typeset -g _contract_fixture_value=plugin + + typeset -gi _contract_fixture_owned_hook=0 + if (( ${precmd_functions[(Ie)_contract_fixture_callback]:-0} == 0 )); then + precmd_functions+=(_contract_fixture_callback) + _contract_fixture_owned_hook=1 + fi + + typeset -gi _contract_fixture_interactive=0 + if [[ -o interactive ]]; then + zle -N contract-fixture-widget _contract_fixture_callback + bindkey -N contract-fixture-map emacs + bindkey -M contract-fixture-map '^Xz' contract-fixture-widget + _contract_fixture_interactive=1 + fi + + contract_fixture_plugin_unload() { + builtin emulate -L zsh + builtin setopt local_options typeset_silent + + [[ ${_contract_fixture_value-} == plugin ]] && unset _contract_fixture_value + if (( _contract_fixture_owned_hook )); then + precmd_functions=(${precmd_functions:#_contract_fixture_callback}) + (( ${#precmd_functions} )) || unset precmd_functions + fi + if (( _contract_fixture_interactive )); then + bindkey -D contract-fixture-map + zle -D contract-fixture-widget + fi + + # The defect: keep the name defined but inert. + functions[_contract_fixture_callback]='return 0' + + unset _contract_fixture_active _contract_fixture_owned_hook + unset _contract_fixture_interactive + unfunction contract_fixture_plugin_unload + } + + typeset -gi _contract_fixture_active=1 +} diff --git a/tests/_support/plugin-contract/reload-stale.plugin.zsh b/tests/_support/plugin-contract/reload-stale.plugin.zsh new file mode 100644 index 0000000..c1b3309 --- /dev/null +++ b/tests/_support/plugin-contract/reload-stale.plugin.zsh @@ -0,0 +1,36 @@ +# Negative fixture: a plugin whose reloaded surface matches its first load +# while its callback no longer works. A one-time setup is guarded by a flag +# that unload forgets to clear, so a second load skips it. The setup's state +# lives under a name the contract observer excludes (`_zunit_*`), standing +# in for state no snapshot family covers, such as a file or a daemon. After +# unload and a second load the callback exists, returns 0 and is +# byte-identical, but its effect is gone, so only an effect check catches it. +() { + builtin emulate -L zsh + builtin setopt local_options typeset_silent + + (( ${+parameters[_contract_fixture_active]} )) && return 0 + + if (( ! ${+parameters[_zunit_reload_stale_setup_done]} )); then + typeset -g _zunit_reload_stale_ready=1 + typeset -gi _zunit_reload_stale_setup_done=1 + fi + + _contract_fixture_callback() { + builtin emulate -L zsh + (( ${_zunit_reload_stale_ready:-0} )) || return 0 + (( ${+parameters[_contract_fixture_effect]} )) && _contract_fixture_effect=ran + return 0 + } + + contract_fixture_plugin_unload() { + builtin emulate -L zsh + unset _zunit_reload_stale_ready + unfunction _contract_fixture_callback + # The defect: _zunit_reload_stale_setup_done is left set. + unset _contract_fixture_active + unfunction contract_fixture_plugin_unload + } + + typeset -gi _contract_fixture_active=1 +} diff --git a/tests/_support/plugin-contract/scenario.zsh b/tests/_support/plugin-contract/scenario.zsh index d29e54a..7483cd1 100644 --- a/tests/_support/plugin-contract/scenario.zsh +++ b/tests/_support/plugin-contract/scenario.zsh @@ -7,9 +7,11 @@ builtin setopt extended_glob typeset_silent 0="${${(M)0:#/*}:-$PWD/$0}" typeset scenario=${1:-ordinary} typeset expected_mode=${2:-noninteractive} +typeset fixture_name=${3:-fixture} +[[ $fixture_name == [A-Za-z0-9_-]## ]] || { print -u2 -r -- "invalid fixture: $fixture_name"; exit 2 } typeset support_dir=${0:A:h} typeset repository_dir=${support_dir:h:h:h} -typeset fixture=${support_dir}/fixture.plugin.zsh +typeset fixture=${support_dir}/${fixture_name}.plugin.zsh source "${repository_dir}/src/plugin-contract.zsh" || exit zunit_plugin_contract_prime || exit @@ -62,6 +64,22 @@ case $scenario in zunit_plugin_contract_snapshot after || exit _zunit_assert_plugin_unloaded before loaded user after ;; + reload) + # Load, unload, and load again in one clean shell. The second load must + # restore the first load's surface, and its callback must still do its + # job: presence or a zero status alone would accept an inert stub. Exact + # unload restoration is the ordinary scenario's job, so it is not + # repeated here. + source "$fixture" || exit + zunit_plugin_contract_snapshot loaded || exit + contract_fixture_plugin_unload || exit + source "$fixture" || exit + zunit_plugin_contract_snapshot reloaded || exit + _zunit_assert_plugin_restored loaded reloaded || exit + typeset -g _contract_fixture_effect= + _contract_fixture_callback || exit 23 + [[ $_contract_fixture_effect == ran ]] || exit 24 + ;; *) print -u2 -r -- "unknown scenario: $scenario" exit 2 diff --git a/tests/plugin-contract.zunit b/tests/plugin-contract.zunit index b7b9e67..f9bd630 100644 --- a/tests/plugin-contract.zunit +++ b/tests/plugin-contract.zunit @@ -135,3 +135,30 @@ assert $state equals 0 assert "$output" is_empty } + +@test 'Fixture restores its surface and behavior after reload' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive + + assert $state equals 0 + assert "$output" is_empty +} + +@test 'Fixture reloads in an interactive shell' { + run zsh -f -i tests/_support/plugin-contract/scenario.zsh reload interactive + + assert $state equals 0 + assert "$output" is_empty +} + +@test 'Reload rejects a plugin that unload leaves as an inert stub' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive reload-broken + + assert $state equals 1 + assert "$output" contains 'function:_contract_fixture_callback' +} + +@test 'Reload rejects a restored callback that no longer has its effect' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive reload-stale + + assert $state equals 24 +} From 8799b23c9059376e6a85a14b1b0fde43aa75dcd2 Mon Sep 17 00:00:00 2001 From: Sal <59910950+ss-o@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:20:01 +0100 Subject: [PATCH 2/2] test(plugin-contract): act on the review of the reload case Refuse a fixture name that has no file with the scenario's own exit 2 message instead of a raw source error, and cover it with a case. Assert empty output for the reload-stale case as the positive cases do. Say in the README that each restored handler, hook or widget needs its own effect check, and that the scenario checks the fixture's one callback. Refs #34 --- README.md | 10 +++++++--- tests/_support/plugin-contract/scenario.zsh | 1 + tests/plugin-contract.zunit | 8 ++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 37b5d50..69f330a 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,10 @@ partial initialization failure, and interactive behavior. Repeated source does not prove that a plugin works after it was unloaded. In another clean shell, load, unload, and load again, then compare the second -load with the first and observe that a restored resource still does its job; -its presence or a zero status alone would accept an inert stub: +load with the first and observe that each restored handler, hook, widget or +other resource still does its job; its presence or a zero status alone would +accept an inert stub. Every restored resource with its own behavior needs its +own check: ```zsh zunit_plugin_contract_prime @@ -71,7 +73,9 @@ assert "$output" same_as 'refreshed' The repository's `tests/_support/plugin-contract/scenario.zsh` demonstrates repeated source, partial failure, hostile state, post-load changes, reload -after unload, and both `zsh -f` and `zsh -f -i` execution. +after unload, and both `zsh -f` and `zsh -f -i` execution. Its reload +scenario checks the fixture's one callback, which the fixture's hook and +widget both dispatch to. ## 📖 Documentation diff --git a/tests/_support/plugin-contract/scenario.zsh b/tests/_support/plugin-contract/scenario.zsh index 7483cd1..ba5d8d1 100644 --- a/tests/_support/plugin-contract/scenario.zsh +++ b/tests/_support/plugin-contract/scenario.zsh @@ -12,6 +12,7 @@ typeset fixture_name=${3:-fixture} typeset support_dir=${0:A:h} typeset repository_dir=${support_dir:h:h:h} typeset fixture=${support_dir}/${fixture_name}.plugin.zsh +[[ -r $fixture ]] || { print -u2 -r -- "missing fixture: $fixture_name"; exit 2 } source "${repository_dir}/src/plugin-contract.zsh" || exit zunit_plugin_contract_prime || exit diff --git a/tests/plugin-contract.zunit b/tests/plugin-contract.zunit index f9bd630..bb64cc1 100644 --- a/tests/plugin-contract.zunit +++ b/tests/plugin-contract.zunit @@ -161,4 +161,12 @@ run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive reload-stale assert $state equals 24 + assert "$output" is_empty +} + +@test 'Scenario refuses a fixture that does not exist' { + run zsh -f tests/_support/plugin-contract/scenario.zsh reload noninteractive does-not-exist + + assert $state equals 2 + assert "$output" same_as 'missing fixture: does-not-exist' }