#!/usr/bin/env python3 """PhotoGIMP test runs on Linux (GIMP AppImages under Xvfb + openbox). python3 verify/linux_run.py must contain: gimp326/squashfs-root extracted GIMP-3.2.6-x86_64.AppImage gimp308/squashfs-root extracted GIMP-3.0.8-x86_64.AppImage release31/ PhotoGIMP 3.1 release assets (PhotoGIMP.zip, PhotoGIMP-linux.zip) upstream/PhotoGIMP clone of Diolinux/PhotoGIMP at master (fc61a8f) moraine-lake.jpg sample photo (Wikimedia Commons, public domain) Every scenario starts from a fresh home folder, so the runs do not affect each other. Writes screenshots, GIMP's console output, config listings and facts.json to . """ import json, os, shutil, subprocess, sys, time, zipfile, hashlib, re from pathlib import Path WORK, OUT = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve() OUT.mkdir(parents=True, exist_ok=True) DISPLAY = ':55' W, H = 1600, 1000 IMG = WORK / 'moraine-lake.jpg' G326 = WORK / 'gimp326/squashfs-root/AppRun' G308 = WORK / 'gimp308/squashfs-root/AppRun' facts = {'date': time.strftime('%Y-%m-%d %H:%M'), 'screen': f'{W}x{H}', 'scenarios': {}} if (OUT / 'facts.json').exists(): # partial reruns add to the facts of earlier scenarios instead of replacing them facts['scenarios'] = json.loads((OUT / 'facts.json').read_text()).get('scenarios', {}) LOG = open(OUT / 'run-log.txt', 'a') def log(m): line = time.strftime('[%H:%M:%S] ') + m print(line, flush=True); LOG.write(line + '\n'); LOG.flush() def sh(cmd, env=None, timeout=60, check=False): r = subprocess.run(cmd, shell=isinstance(cmd, str), env=env, capture_output=True, text=True, timeout=timeout) if check and r.returncode: raise RuntimeError(f'{cmd}: {r.stderr}') return r.stdout.strip() def xenv(home=None): e = dict(os.environ, DISPLAY=DISPLAY, GDK_BACKEND='x11', NO_AT_BRIDGE='1') if home: e['HOME'] = str(home); e['XDG_CONFIG_HOME'] = str(Path(home) / '.config') if home and (Path(home) / 'GIMP3_DIRECTORY').exists(): # portable-style profile for this home e['GIMP3_DIRECTORY'] = (Path(home) / 'GIMP3_DIRECTORY').read_text().strip() return e def sha(p): return hashlib.sha256(Path(p).read_bytes()).hexdigest() if Path(p).exists() else None def shot(name): sh(['import', '-display', DISPLAY, '-window', 'root', str(OUT / f'{name}.png')]) log(f'screenshot {name}') def windows(): out = sh(['wmctrl', '-l'], env=xenv()) return [l.split(None, 3)[-1] if len(l.split(None, 3)) == 4 else '' for l in out.splitlines()] def wait_window(pattern, timeout=180): end = time.time() + timeout while time.time() < end: for t in windows(): if re.search(pattern, t): return t time.sleep(0.5) return None def key(win_pattern, keys): ids = sh(['xdotool', 'search', '--name', win_pattern], env=xenv()).split() for i in ids[:1]: sh(['xdotool', 'windowactivate', '--sync', i], env=xenv()); time.sleep(0.4) sh(['xdotool', 'key', '--clearmodifiers', keys], env=xenv()) return bool(ids) class Gimp: def __init__(self, apprun, home, tag, image=True, splash=False, settle=12): self.tag = tag args = [str(apprun), '--console-messages'] + ([str(IMG)] if image else []) self.err = open(OUT / f'{tag}-console.txt', 'w') self.p = subprocess.Popen(args, env=xenv(home), stdout=self.err, stderr=subprocess.STDOUT) t0 = time.time() if splash: for s in (1.5, 3, 4.5, 6, 8): while time.time() - t0 < s: time.sleep(0.05) shot(f'{tag}-splash-{s:g}s') self.title = wait_window(r'[–-] GIMP$' if image else r'^GNU Image Manipulation Program$', 240) log(f'{tag}: window after {time.time() - t0:.0f}s: {self.title!r}; all: {windows()}') time.sleep(settle) def welcome(self): t = [w for w in windows() if 'Welcome' in w] return t[0] if t else None def close_welcome(self): if self.welcome(): key('Welcome to GIMP', 'Escape'); time.sleep(2) if self.welcome(): # Escape did not close it: close the window sh(['wmctrl', '-c', 'Welcome to GIMP'], env=xenv()); time.sleep(2) log(f'{self.tag}: welcome closed ({not self.welcome()})') def maximize(self): if self.title: sh(['wmctrl', '-r', self.title, '-b', 'add,maximized_vert,maximized_horz'], env=xenv()) sh(['wmctrl', '-a', self.title], env=xenv()); time.sleep(3) def quit(self): """Quit like a user (Ctrl+Q). GIMP then writes sessionrc, toolrc, gimprc ...""" if self.p.poll() is None and self.title: self.close_welcome() key('GIMP$|^GNU Image Manipulation Program$', 'ctrl+q') for _ in range(30): if self.p.poll() is not None: break if any('Quit GIMP' in w for w in windows()): key('Quit GIMP', 'Return'); log(f'{self.tag}: answered Quit GIMP dialog') time.sleep(1) graceful = self.p.poll() is not None if not graceful: self.p.terminate() try: self.p.wait(10) except subprocess.TimeoutExpired: self.p.kill() subprocess.run('pkill -9 -f squashfs-root/usr/bin', shell=True) self.err.close(); time.sleep(1.5) log(f'{self.tag}: quit {"gracefully" if graceful else "by kill"}') return graceful def warnings_of(tag): txt = (OUT / f'{tag}-console.txt').read_text(errors='replace') keep = [l for l in txt.splitlines() if re.search(r'toolrc|shortcutsrc|sessionrc|gimprc|not existing action|no entry registered|parsing|Error while', l)] return [re.sub(r'/[^\s\']*/\.config', '~/.config', l) for l in keep] def cfg_state(home, ver): d = Path(home) / '.config/GIMP' / ver rc = (d / 'gimprc').read_text(errors='replace') if (d / 'gimprc').exists() else '' cv = re.search(r'\(config-version "([^"]+)"\)', rc) return {'folders': sorted(p.name for p in (Path(home) / '.config/GIMP').iterdir()) if (Path(home) / '.config/GIMP').exists() else [], 'config_version': cv.group(1) if cv else None, 'toolrc_sha': sha(d / 'toolrc'), 'toolrc_has_vector': ('gimp-vector-tool' in (d / 'toolrc').read_text()) if (d / 'toolrc').exists() else None, 'sessionrc_sha': sha(d / 'sessionrc'), 'shortcutsrc_sha': sha(d / 'shortcutsrc')} def fresh_home(name): h = WORK / 'homes' / name shutil.rmtree(h, ignore_errors=True); h.mkdir(parents=True) return h def first_run(apprun, home, tag): g = Gimp(apprun, home, tag, image=False, settle=8) g.quit() def extract(zip_path, dest): shutil.rmtree(dest, ignore_errors=True) with zipfile.ZipFile(zip_path) as z: z.extractall(dest) return Path(dest) def copy_contents(src, dst): Path(dst).mkdir(parents=True, exist_ok=True) subprocess.run(['cp', '-a', f'{src}/.', str(dst)], check=True) def repair(d): for f, a, b in (('toolrc', '"gimp-vector-tool"', '"gimp-path-tool"'), ('sessionrc', '"gimp-vectors-list"', '"gimp-path-list"'), ('shortcutsrc', '"tools-vector"', '"tools-path"')): p = Path(d) / f; p.write_text(p.read_text().replace(a, b)) def scenario(name, fn): log(f'===== {name}') try: facts['scenarios'][name] = fn() except Exception as e: log(f'{name} FAILED: {e!r}'); facts['scenarios'][name] = {'error': repr(e)} subprocess.run('pkill -9 -f squashfs-root/usr/bin', shell=True) (OUT / 'facts.json').write_text(json.dumps(facts, indent=1)) # ── scenarios ─────────────────────────────────────────────────────────── REL = extract(WORK / 'release31/PhotoGIMP.zip', WORK / 'x/rel31') SRC31 = REL / '3.0' MASTER = WORK / 'upstream/PhotoGIMP' def s_stock(): h = fresh_home('stock') g = Gimp(G326, h, '01-stock-first-run', image=False, settle=10) shot('01-stock-first-run'); g.quit() after_first = cfg_state(h, '3.2') g = Gimp(G326, h, '02-stock', settle=10); g.close_welcome(); g.maximize(); shot('02-stock-gimp-326-dark'); g.quit() rc = h / '.config/GIMP/3.2/gimprc'; rc.write_text((rc.read_text() if rc.exists() else '') + '\n(theme-color-scheme light)\n') g = Gimp(G326, h, '03-stock-light', settle=10); g.close_welcome(); g.maximize(); shot('03-stock-gimp-326-light'); g.quit() return {'after_first_run': after_first, 'after_quit': cfg_state(h, '3.2')} def s_readme31(): h = fresh_home('readme31') first_run(G326, h, '04-pre') d = h / '.config/GIMP/3.2'; before = cfg_state(h, '3.2') copy_contents(SRC31, d) pasted = cfg_state(h, '3.2') g = Gimp(G326, h, '04-pg31-on-326', splash=True, settle=10) welcome = bool(g.welcome()); shot('04-pg31-on-326-welcome'); g.close_welcome(); g.maximize(); shot('05-pg31-on-326') graceful = g.quit() after = cfg_state(h, '3.2') g = Gimp(G326, h, '06-pg31-on-326-second-start', settle=10); welcome2 = bool(g.welcome()); g.close_welcome(); g.maximize() shot('06-pg31-on-326-second-start'); g.quit() return {'stock': before, 'pasted': pasted, 'welcome_shown': welcome, 'warnings': warnings_of('04-pg31-on-326'), 'graceful_quit': graceful, 'after_quit': after, 'welcome_second_start': welcome2, 'warnings_second_start': warnings_of('06-pg31-on-326-second-start'), 'after_second_quit': cfg_state(h, '3.2')} def s_repaired(): h = fresh_home('repaired') first_run(G326, h, '07-pre') d = h / '.config/GIMP/3.2' copy_contents(SRC31, d); repair(d) g = Gimp(G326, h, '07-repaired', splash=True, settle=10); welcome = bool(g.welcome()); g.close_welcome(); g.maximize(); shot('07-pg31-repaired-on-326') g.quit() after = cfg_state(h, '3.2') g = Gimp(G326, h, '08-repaired-second-start', settle=10); g.close_welcome(); g.maximize(); shot('08-pg31-repaired-second-start') rc = d / 'gimprc'; txt = rc.read_text(); g.quit() rc.write_text(rc.read_text() + '\n(theme-color-scheme light)\n') g = Gimp(G326, h, '08b-repaired-light', settle=10); g.close_welcome(); g.maximize(); shot('08b-pg31-repaired-light'); g.quit() return {'welcome_shown': welcome, 'warnings': warnings_of('07-repaired'), 'after_quit': after} def s_master_installsh(): h = fresh_home('master') first_run(G326, h, '09-pre') # install.sh looks for gimp-3.* on PATH; the AppImage has none, so give it one. b = WORK / 'bin'; b.mkdir(exist_ok=True) (b / 'gimp-3.2').unlink(missing_ok=True); (b / 'gimp-3.2').symlink_to(G326) env = xenv(h); env['PATH'] = f'{b}:{env["PATH"]}' r = subprocess.run(['bash', str(MASTER / 'install.sh')], env=env, capture_output=True, text=True, timeout=300, cwd=str(MASTER)) (OUT / '09-install-sh-output.txt').write_text(r.stdout + r.stderr) backups = sorted(p.name for p in (h / '.config/GIMP').iterdir()) g = Gimp(G326, h, '10-master-on-326', splash=True, settle=10); welcome = bool(g.welcome()); shot('10-master-on-326-welcome') g.close_welcome(); g.maximize(); shot('10-master-on-326'); g.quit() after = cfg_state(h, '3.2') # uninstall: delete the version folder, GIMP recreates defaults shutil.rmtree(h / '.config/GIMP/3.2') g = Gimp(G326, h, '11-uninstalled', settle=10); g.close_welcome(); g.maximize(); shot('11-uninstalled'); g.quit() return {'install_sh_exit': r.returncode, 'folders_after_install': backups, 'welcome_shown': welcome, 'warnings': warnings_of('10-master-on-326'), 'after_quit': after, 'after_uninstall': cfg_state(h, '3.2')} def s_on_308(): h = fresh_home('g308') first_run(G308, h, '12-pre') d = h / '.config/GIMP/3.0' stock = cfg_state(h, '3.0') copy_contents(SRC31, d) g = Gimp(G308, h, '12-pg31-on-308', splash=True, settle=10); welcome = bool(g.welcome()); g.close_welcome(); g.maximize(); shot('12-pg31-on-308') g.quit() return {'stock': stock, 'welcome_shown': welcome, 'warnings': warnings_of('12-pg31-on-308'), 'after_quit': cfg_state(h, '3.0'), 'version': sh([str(G308), '--version'])} def s_wrong_folder(): h = fresh_home('wrong') first_run(G326, h, '13-pre') shutil.copytree(SRC31, h / '.config/GIMP/3.0') g = Gimp(G326, h, '13-wrong-folder', settle=10); g.close_welcome(); g.maximize(); shot('13-wrong-folder-30-next-to-32'); g.quit() return {'after_quit': cfg_state(h, '3.2'), 'folders': sorted(p.name for p in (h / '.config/GIMP').iterdir())} def press(keys, wait=3): sh(['xdotool', 'key', '--clearmodifiers', keys], env=xenv()); time.sleep(wait) def s_keys(): """Which action wins when PhotoGIMP's key is also a GIMP 3.2 default? Uses the repaired 3.1 setup.""" h = fresh_home('keys') first_run(G326, h, '20-pre') d = h / '.config/GIMP/3.2'; copy_contents(SRC31, d); repair(d) g = Gimp(G326, h, '20-keys', settle=10); g.close_welcome(); g.maximize() r = {} def state(tag): w = windows(); shot(tag); return w sh(['xdotool', 'mousemove', '700', '500'], env=xenv()) press('ctrl+a'); r['ctrl_a'] = state('20-ctrl-a-select-all') press('ctrl+d', 4); r['ctrl_d'] = state('21-ctrl-d') images = [w for w in r['ctrl_d'] if w.endswith('GIMP')] r['ctrl_d_duplicated_image'] = any('-2.0' in w or '-2.' in w for w in images) or len(images) > 1 press('Escape', 1) press('ctrl+e', 4); r['ctrl_e'] = state('22-ctrl-e') r['ctrl_e_dialog'] = [w for w in r['ctrl_e'] if 'Export' in w or 'Merge' in w] press('Escape', 2) press('ctrl+j', 3); r['ctrl_j'] = state('23-ctrl-j-duplicate-layer') press('b', 1); press('bracketright', 0.5); press('bracketright', 0.5); press('bracketright', 1); shot('24-brackets-size-up') press('ctrl+t', 3); r['ctrl_t'] = state('25-ctrl-t-unified-transform'); press('Escape', 1) press('v', 1); shot('26-v-move-tool') g.quit() return r def s_migrate(): """PhotoGIMP 3.1's 3.0 folder in place BEFORE GIMP 3.2's first start: GIMP migrates it to 3.2.""" h = fresh_home('migrate') shutil.copytree(SRC31, h / '.config/GIMP/3.0') g = Gimp(G326, h, '50-migrate-first-start', settle=10); welcome = bool(g.welcome()); g.close_welcome(); g.maximize(); shot('50-migrate-first-start') res = {'welcome_first_start': welcome, 'after_first_start': cfg_state(h, '3.2'), 'warnings': warnings_of('50-migrate-first-start')} res['graceful_quit'] = g.quit(); res['after_quit'] = cfg_state(h, '3.2') g = Gimp(G326, h, '51-migrate-second-start', settle=10); res['welcome_second_start'] = bool(g.welcome()); g.close_welcome(); g.maximize(); shot('51-migrate-second-start') res['warnings_second'] = warnings_of('51-migrate-second-start'); g.quit(); res['after_second_quit'] = cfg_state(h, '3.2') sc = (h / '.config/GIMP/3.2/shortcutsrc').read_text(); ss = (h / '.config/GIMP/3.2/sessionrc').read_text() res['shortcutsrc_tools_path_line'] = '(action "tools-path")' in sc; res['sessionrc_path_list'] = 'gimp-path-list' in ss return res def physical_combo(keysym): """The key combination a user types for keysym in the current X layout (from xmodmap -pke).""" out = subprocess.run(['xmodmap', '-pke'], env=xenv(), capture_output=True, text=True).stdout for line in out.splitlines(): parts = line.split('=', 1) if len(parts) < 2: continue syms = parts[1].split() if keysym in syms[:6]: i = syms.index(keysym); base = syms[0] return [base, 'shift+' + base, base, 'shift+' + base, 'ISO_Level3_Shift+' + base, 'shift+ISO_Level3_Shift+' + base][i], i return None, None def tool_value(home, tool, key): f = Path(home) / '.config/GIMP/3.2/tool-options' / tool m = re.search(r'\(' + key + r' ([0-9.]+)\)', f.read_text()) if f.exists() else None return float(m.group(1)) if m else None def s_layouts(): """PhotoGIMP's ] (bigger brush) typed on non-US keyboard layouts, with the physical keys a user presses there.""" res = {} for lay in ('us', 'gb', 'de', 'fr', 'es', 'it', 'br'): h = fresh_home('layout-' + lay) first_run(G326, h, f'70-pre-{lay}') d = h / '.config/GIMP/3.2'; copy_contents(SRC31, d); repair(d) sh(['setxkbmap', lay], env=xenv()) combo, level = physical_combo('bracketright') g = Gimp(G326, h, f'70-layout-{lay}', settle=10); g.close_welcome(); g.maximize() sh(['xdotool', 'mousemove', '700', '500'], env=xenv()) press('b' if lay != 'fr' else 'b', 1) for _ in range(3): press(combo, 0.6) shot(f'70-layout-{lay}') g.quit() res[lay] = {'combo_for_bracketright': combo, 'xkb_level': level, 'size_before': 52.0, 'size_after': tool_value(h, 'gimp-paintbrush-tool', 'brush-size')} log(f'layout {lay}: {combo} -> size {res[lay]["size_after"]}') sh(['setxkbmap', 'us'], env=xenv()) return res def s_portable(): """A portable-style profile: GIMP3_DIRECTORY points GIMP at a folder of our choice (what portable launchers do).""" h = fresh_home('portable'); prof = h / 'stick/profile'; prof.mkdir(parents=True) (h / 'GIMP3_DIRECTORY').write_text(str(prof)) first_run(G326, h, '60-portable-first-run') res = {'profile_files_after_first_run': sorted(p.name for p in prof.iterdir()), 'home_config_gimp_exists': (h / '.config/GIMP').exists(), 'version_subfolder_created': (prof / '3.2').exists()} copy_contents(SRC31, prof); repair(prof) g = Gimp(G326, h, '61-portable-installed', settle=10); g.close_welcome(); g.maximize(); shot('61-portable-installed') res['warnings'] = warnings_of('61-portable-installed'); res['graceful_quit'] = g.quit() res['toolrc_sha_after_quit'] = sha(prof / 'toolrc'); res['home_config_gimp_exists_after'] = (h / '.config/GIMP').exists() return res def s_sitescripts(): """Run the exact commands photogimp.org's path finder shows, the way users run them: pasted into an interactive shell (zsh, like macOS's default, and bash). Scripts are written by src/scripts/pf_scripts.mjs.""" PF = Path(os.environ['PF_DIR']) h = fresh_home('site') first_run(G326, h, '40-pre') env = xenv(h) shells = iter(['zsh', 'bash'] * 20) def run(name): sh_ = next(shells) r = subprocess.run([sh_, '-i', '-c', (PF / name).read_text()], env=env, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL) (OUT / f'40-{name}.txt').write_text(f'# pasted into {sh_} -i\n' + r.stdout + r.stderr); log(f'{name} ({sh_}): exit {r.returncode}') (OUT / f'40-{name}.txt').write_text(r.stdout + r.stderr); log(f'{name}: exit {r.returncode}') return r.returncode res = {'install': run('linux-install-release-32.sh'), 'after_install': cfg_state(h, '3.2')} g = Gimp(G326, h, '41-site-installed', settle=10); g.close_welcome(); g.maximize(); shot('41-site-installed'); g.quit() res['warnings_installed'] = warnings_of('41-site-installed'); res['after_first_quit'] = cfg_state(h, '3.2') res['uninstall_files'] = run('linux-uninstall-files-32.sh'); res['after_uninstall_files'] = cfg_state(h, '3.2') res['folders_after_uninstall'] = sorted(p.name for p in (h / '.config/GIMP').iterdir()) g = Gimp(G326, h, '42-site-uninstalled', settle=10); g.close_welcome(); g.maximize(); shot('42-site-uninstalled'); g.quit() res['after_uninstalled_quit'] = cfg_state(h, '3.2') res['install2'] = run('linux-install-release-32.sh'); res['restore'] = run('linux-uninstall-backup-32.sh') res['after_restore'] = cfg_state(h, '3.2') g = Gimp(G326, h, '43-site-restored', settle=10); g.close_welcome(); g.maximize(); shot('43-site-restored'); g.quit() # Refuses while GIMP is running, and changes nothing before = sorted(p.name for p in (h / '.config/GIMP').iterdir()) g = Gimp(G326, h, '44-site-running', image=False, settle=6) res['install_while_running'] = run('linux-install-release-32.sh') res['folders_unchanged_while_running'] = before == sorted(p.name for p in (h / '.config/GIMP').iterdir()) g.quit() # Master through the path finder, then quit and start again res['install_master'] = run('linux-install-master-32.sh'); res['after_master'] = cfg_state(h, '3.2') g = Gimp(G326, h, '45-site-master', settle=10); g.close_welcome(); g.maximize(); shot('45-site-master') res['warnings_master'] = warnings_of('45-site-master'); res['master_graceful_quit'] = g.quit(); res['after_master_quit'] = cfg_state(h, '3.2') # Repair: copy the 3.1 files the README way, then run the path finder's repair command before starting GIMP copy_contents(SRC31, h / '.config/GIMP/3.2'); res['repair'] = run('linux-repair-32.sh'); res['after_repair'] = cfg_state(h, '3.2') # Reset completely res['reset_all'] = run('linux-uninstall-all-32.sh'); res['folders_after_reset'] = sorted(p.name for p in (h / '.config/GIMP').iterdir()) g = Gimp(G326, h, '46-site-reset', image=False, settle=8); g.quit(); res['after_reset_start'] = cfg_state(h, '3.2') return res if __name__ == '__main__': only = sys.argv[3].split(',') if len(sys.argv) > 3 else None xvfb = subprocess.Popen(['Xvfb', DISPLAY, '-screen', '0', f'{W}x{H}x24', '-nolisten', 'tcp']) time.sleep(2) wm = subprocess.Popen(['openbox'], env=xenv(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) time.sleep(2) facts['gimp_326'] = sh([str(G326), '--version']); facts['gimp_308'] = sh([str(G308), '--version']) try: for name, fn in (('stock', s_stock), ('readme31', s_readme31), ('repaired', s_repaired), ('master_installsh', s_master_installsh), ('on_308', s_on_308), ('wrong_folder', s_wrong_folder), ('keys', s_keys), ('migrate', s_migrate), ('portable', s_portable), ('layouts', s_layouts), ('sitescripts', s_sitescripts)): if only and name not in only: continue scenario(name, fn) finally: wm.terminate(); xvfb.terminate() log('done')