Reported from a Windows Server 2019 test: a "Windows Installer" dialog listing every msiexec /Option appeared, then the wizard reported that the bundled MySQL database could not be installed. That dialog is msiexec's usage help - it prints it when the command line does not parse - so the install never started. Cause: $MysqlRoot defaulted to 'C:\Program Files\MySQL\MySQL Server 8.4', which contains spaces. Invoke-Native wraps any argument containing whitespace in quotes, producing "INSTALLDIR=C:\Program Files\...". msiexec takes public properties as PROPERTY=value and expects the VALUE quoted - INSTALLDIR="C:\Program Files\..." - so it rejected the line, printed usage, and exited non-zero. This file already carried the rule, next to the Python target: "Never put a space in a path this installer controls." I broke it setting the 8.4 path. Two fixes, because one of them alone leaves the trap in place: - $MysqlRoot is now C:\MySQL84, space-free like C:\Python314. The MySQL client search paths in the installer, the preflight and the operator console all look there first, keeping backups working against the bundled server. - Invoke-Native now quotes PROPERTY=value correctly, so passing a spaced path explicitly no longer produces an unparseable command line. tests/test_installer_defaults.py fails if an installer-controlled path default ever contains a space again.
107 lines
4.3 KiB
Python
107 lines
4.3 KiB
Python
"""The installer wizard's pre-ticked feature list must match the plugin manifests.
|
|
|
|
The wizard cannot read manifest.json - Inno's Pascal Script has no JSON parser -
|
|
so the default set is a hardcoded list in ShopDBFlask.iss. It drifted: two plugins
|
|
that ship "default_enabled": false were pre-ticked, so every site taking the
|
|
defaults installed and enabled them against their own manifests.
|
|
|
|
A hardcoded list is fine; a hardcoded list nobody checks is not.
|
|
"""
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
ISS = REPO / 'deploy' / 'windows' / 'installer' / 'ShopDBFlask.iss'
|
|
PLUGINS = REPO / 'plugins'
|
|
|
|
pytestmark = pytest.mark.skipif(not ISS.exists(), reason='installer not in this tree')
|
|
|
|
|
|
def manifests_defaulting_off():
|
|
off = set()
|
|
for manifest in PLUGINS.glob('*/manifest.json'):
|
|
try:
|
|
data = json.loads(manifest.read_text())
|
|
except (ValueError, OSError):
|
|
continue
|
|
if data.get('default_enabled') is False:
|
|
off.add(manifest.parent.name)
|
|
return off
|
|
|
|
|
|
def wizard_excludes():
|
|
"""The names PluginDefault returns False for."""
|
|
body = re.search(r'function PluginDefault.*?\nend;', ISS.read_text(), re.S)
|
|
assert body, 'PluginDefault not found in ShopDBFlask.iss'
|
|
return set(re.findall(r"Name <> '([a-z_]+)'", body.group(0)))
|
|
|
|
|
|
def test_wizard_defaults_match_the_manifests():
|
|
off = manifests_defaulting_off()
|
|
assert off, 'no plugin declares default_enabled false - has the field moved?'
|
|
assert wizard_excludes() == off, (
|
|
'ShopDBFlask.iss PluginDefault disagrees with the manifests.\n'
|
|
' manifests default_enabled=false: %s\n'
|
|
' wizard leaves unticked: %s' % (sorted(off), sorted(wizard_excludes())))
|
|
|
|
|
|
def test_every_wizard_exclusion_is_a_real_plugin():
|
|
"""A typo in the .iss list silently pre-ticks the plugin it meant to exclude."""
|
|
for name in wizard_excludes():
|
|
assert (PLUGINS / name / 'manifest.json').exists(), (
|
|
'%s is excluded by the wizard but has no manifest' % name)
|
|
|
|
|
|
# The preflight blocks the wizard on any FAIL. That is correct for something the
|
|
# operator must go and fix, and wrong for anything the installer carries in its
|
|
# own bundle - there, blocking stops the wizard over something it was about to do
|
|
# itself, with no way forward.
|
|
BUNDLE_SUPPLIED = [
|
|
'HttpPlatformHandler', # httpplatformhandler\*.msi, installed by stage 4
|
|
'URL Rewrite', # urlrewrite\*.msi, installed by stage 4
|
|
]
|
|
|
|
PREFLIGHT = REPO / 'deploy' / 'windows' / 'installer' / 'shopdb-preflight.ps1'
|
|
|
|
|
|
def test_nothing_the_bundle_supplies_is_a_blocker():
|
|
"""Regression: HttpPlatformHandler was a FAIL, so a server without it could
|
|
not get past the preflight page - to install the very thing that was
|
|
missing."""
|
|
text = PREFLIGHT.read_text(encoding='utf-8', errors='replace')
|
|
offenders = []
|
|
for line in text.splitlines():
|
|
if "'FAIL'" not in line:
|
|
continue
|
|
for component in BUNDLE_SUPPLIED:
|
|
if component.lower() in line.lower():
|
|
offenders.append(line.strip())
|
|
assert not offenders, (
|
|
'the installer supplies these, so they must not block the wizard:\n %s'
|
|
% '\n '.join(offenders))
|
|
|
|
|
|
def test_installer_controlled_paths_have_no_spaces():
|
|
"""msiexec and bootstrapper command lines split on spaces.
|
|
|
|
The installer passes these as PROPERTY=value / TargetDir=value on a command
|
|
line. A value containing a space has to be quoted as PROPERTY="C:\\..." -
|
|
quoting the whole token instead makes the tool reject the line and print its
|
|
usage dialog, which is what 'the bundled MySQL database could not be
|
|
installed' looked like from the outside. The Python target was already
|
|
space-free for this exact reason; MySQL was not.
|
|
"""
|
|
text = PREFLIGHT.parent.joinpath('shopdb-install.ps1').read_text(
|
|
encoding='utf-8', errors='replace')
|
|
offenders = []
|
|
for line in text.splitlines():
|
|
match = re.match(r"\s*\[string\]\s*\$(Mysql\w*|PyTarget)\s*=\s*'([^']+)'", line)
|
|
if match and ' ' in match.group(2):
|
|
offenders.append('%s = %s' % (match.group(1), match.group(2)))
|
|
assert not offenders, (
|
|
'these are passed on a command line and must not contain spaces:\n %s'
|
|
% '\n '.join(offenders))
|