From c9ff15f3e47f9a3537be7db7a482eb717dca2f89 Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 20:38:39 +0100
Subject: [PATCH 01/10] docs: select four surgical fork fixes and reject
feature imports
---
ROADMAP.md | 31 ++++++++++++++++++++++---------
1 file changed, 22 insertions(+), 9 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index ad4f6fc0..edecc701 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -66,14 +66,27 @@ demonstrates a concrete benefit.
## Fork review backlog
-GitHub issues hold the review notes, source commit links, acceptance criteria,
-and eventual decisions. These are candidates for selective changes, not approved
-imports; none was built or runtime-tested during the initial review.
+Three independent agent reviews and a maintainer cross-check selected four
+surgical corrections. These are approved implementation candidates, not imported
+code. Reproduce/validate each defect before porting, retain attribution, and use
+one focused commit per fix. No dependency upgrade, new subsystem, shortcut change,
+or cosmetic feature is included. No fork was built or runtime-tested in this review.
-| Issue | Candidate | Review focus |
+| Priority | Accepted correction | Source |
| --- | --- | --- |
-| [#1](https://github.com/itlezy/notepad2-mod/issues/1) | antiufo | Recovery/autosave; preserve upstream fixes and recovery buffer-fix chain. |
-| [#2](https://github.com/itlezy/notepad2-mod/issues/2) | johnwait | Selective editor fixes; exclude wholesale regex/Scintilla migration. |
-| [#3](https://github.com/itlezy/notepad2-mod/issues/3) | mercury233 | Small find/replace, reload, and context-menu improvements. |
-| [#4](https://github.com/itlezy/notepad2-mod/issues/4) | tnotstar | Initial CMake build; Debug support was explicitly unfinished. |
-| [#5](https://github.com/itlezy/notepad2-mod/issues/5) | waskosky | Aggressive reload/append merging; require data-preservation review. |
+| First | [#9: save prompting for nonempty untitled documents](https://github.com/itlezy/notepad2-mod/issues/9) | johnwait; remove C-string content sniffing that misclassifies leading NUL and whitespace. |
+| Next | [#10: skip empty MRU slots](https://github.com/itlezy/notepad2-mod/issues/10) | antiufo; one null guard before path comparison, without recovery features. |
+| Next | [#11: explicit UTF-8 source charset](https://github.com/itlezy/notepad2-mod/issues/11) | mercury233; all four app configurations, unchanged execution charset/resources/vendor code. |
+| Lower | [#12: elevated-window show-state flags](https://github.com/itlezy/notepad2-mod/issues/12) | johnwait; default to visible only when no explicit show-state flag was supplied. |
+
+Closed review decisions remain in [#1](https://github.com/itlezy/notepad2-mod/issues/1),
+[#2](https://github.com/itlezy/notepad2-mod/issues/2), and
+[#3](https://github.com/itlezy/notepad2-mod/issues/3). Recovery/autosave, regex-engine
+replacement, Scintilla migration, broad refactors, notification redesign, and
+optional context-menu/focus/shortcut changes are excluded. Several apparent fixes
+repair code introduced by those forks and do not apply here.
+
+[CMake (#4)](https://github.com/itlezy/notepad2-mod/issues/4) and
+[aggressive reload/append merging (#5)](https://github.com/itlezy/notepad2-mod/issues/5)
+are rejected and closed as not planned. Keep the existing build and file-watching
+models; revisit only if a concrete product need changes that decision.
From c8152f5b1e79d768e3b726349d03e0872b1351a8 Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:17:10 +0100
Subject: [PATCH 02/10] fix: prompt before discarding nonempty untitled buffers
Adapted from johnwait/notepad2-mod-jrb 54c798a770df8efd5e052e6077698fcc7515d124. Fixes #9. Reproduced nine data-loss paths on the pre-fix binary; fixed x64 passes all save-prompt scenarios.
---
build/test-save-prompts.ps1 | 154 ++++++++++++++++++++++++++++++++++++
src/Notepad2.c | 7 --
2 files changed, 154 insertions(+), 7 deletions(-)
create mode 100644 build/test-save-prompts.ps1
diff --git a/build/test-save-prompts.ps1 b/build/test-save-prompts.ps1
new file mode 100644
index 00000000..436b9a4e
--- /dev/null
+++ b/build/test-save-prompts.ps1
@@ -0,0 +1,154 @@
+[CmdletBinding()]
+param(
+ [string]$Executable = "$PSScriptRoot/../bin/VS2022/Release_x64/Notepad2.exe",
+ [switch]$ExpectLegacyFailure
+)
+
+$ErrorActionPreference = 'Stop'
+Add-Type @'
+using System;
+using System.Text;
+using System.Runtime.InteropServices;
+public static class SavePromptNative {
+ public delegate bool Callback(IntPtr hwnd, IntPtr state);
+ [DllImport("user32.dll")] public static extern bool EnumWindows(Callback cb, IntPtr state);
+ [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr hwnd, Callback cb, IntPtr state);
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, StringBuilder text, int size);
+ [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr hwnd, int id);
+ [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l);
+ [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l);
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr VirtualAllocEx(IntPtr process, IntPtr address, UIntPtr size, uint allocationType, uint protection);
+ [DllImport("kernel32.dll")] public static extern bool VirtualFreeEx(IntPtr process, IntPtr address, UIntPtr size, uint freeType);
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern bool WriteProcessMemory(IntPtr process, IntPtr address, byte[] bytes, UIntPtr size, out UIntPtr written);
+ [DllImport("kernel32.dll", SetLastError=true)] public static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] bytes, UIntPtr size, out UIntPtr read);
+ [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
+}
+'@
+
+function Assert($condition, $message) { if (-not $condition) { throw $message } }
+function Sci($message, $w = 0, $l = 0) {
+ [SavePromptNative]::SendMessage($script:editor, $message, [IntPtr]$w, [IntPtr]$l).ToInt64()
+}
+function Find-Windows {
+ $script:dialog = [IntPtr]::Zero
+ $script:window = [IntPtr]::Zero
+ $processId = $script:app.Id
+ $callback = [SavePromptNative+Callback]{ param($handle, $state)
+ [uint32]$owner = 0
+ $null = [SavePromptNative]::GetWindowThreadProcessId($handle, [ref]$owner)
+ if ($owner -eq $processId) {
+ $name = New-Object Text.StringBuilder 128
+ $null = [SavePromptNative]::GetClassName($handle, $name, 128)
+ if ($name.ToString() -like 'Notepad2*') { $script:window = $handle }
+ if ($name.ToString() -eq '#32770') { $script:dialog = $handle }
+ }
+ return $true
+ }
+ $null = [SavePromptNative]::EnumWindows($callback, [IntPtr]::Zero)
+}
+function Start-Editor($named) {
+ $arguments = @('/n', '/f', ('"'+$ini+'"'))
+ if ($named) { $arguments += ('"'+$namedFile+'"') }
+ $script:app = Start-Process -FilePath $Executable -ArgumentList $arguments -WindowStyle Hidden -PassThru
+ Assert ($script:app.WaitForInputIdle(60000)) 'Editor startup timed out'
+ Find-Windows
+ Assert ($script:window -ne [IntPtr]::Zero) 'Main window missing'
+ $script:editor = [IntPtr]::Zero
+ $child = [SavePromptNative+Callback]{ param($handle, $state)
+ $name = New-Object Text.StringBuilder 128
+ $null = [SavePromptNative]::GetClassName($handle, $name, 128)
+ if ($name.ToString() -eq 'Scintilla') { $script:editor = $handle }
+ return $true
+ }
+ $null = [SavePromptNative]::EnumChildWindows($script:window, $child, [IntPtr]::Zero)
+ Assert ($script:editor -ne [IntPtr]::Zero) 'Editor window missing'
+ $script:processHandle = [SavePromptNative]::OpenProcess(0x38, $false, $script:app.Id)
+ Assert ($script:processHandle -ne [IntPtr]::Zero) 'Cannot access test-process memory'
+}
+function Add-Bytes([byte[]]$bytes) {
+ if (-not $bytes.Length) { return }
+ $remote = [SavePromptNative]::VirtualAllocEx($script:processHandle, [IntPtr]::Zero, [UIntPtr]$bytes.Length, 0x3000, 4)
+ Assert ($remote -ne [IntPtr]::Zero) 'Cannot allocate test-process text'
+ try {
+ [UIntPtr]$written = [UIntPtr]::Zero
+ Assert ([SavePromptNative]::WriteProcessMemory($script:processHandle, $remote, $bytes, [UIntPtr]$bytes.Length, [ref]$written)) 'Cannot write test text'
+ $null = Sci 2001 $bytes.Length $remote
+ } finally { $null = [SavePromptNative]::VirtualFreeEx($script:processHandle, $remote, [UIntPtr]::Zero, 0x8000) }
+}
+function Read-Bytes {
+ $length = [int](Sci 2006)
+ $remote = [SavePromptNative]::VirtualAllocEx($script:processHandle, [IntPtr]::Zero, [UIntPtr]($length+1), 0x3000, 4)
+ Assert ($remote -ne [IntPtr]::Zero) 'Cannot allocate read buffer'
+ try {
+ $null = Sci 2182 ($length+1) $remote
+ $bytes = New-Object byte[] $length
+ [UIntPtr]$read = [UIntPtr]::Zero
+ Assert ([SavePromptNative]::ReadProcessMemory($script:processHandle, $remote, $bytes, [UIntPtr]$length, [ref]$read)) 'Cannot read test text'
+ return ,$bytes
+ } finally { $null = [SavePromptNative]::VirtualFreeEx($script:processHandle, $remote, [UIntPtr]::Zero, 0x8000) }
+}
+
+$Executable = (Resolve-Path $Executable).Path
+$testDir = Join-Path ([IO.Path]::GetTempPath()) ('np2-save-prompts-'+[Guid]::NewGuid().ToString('N'))
+$null = New-Item -ItemType Directory -Path $testDir
+$ini = Join-Path $testDir 'settings.ini'
+$namedFile = Join-Path $testDir 'named.txt'
+[IO.File]::WriteAllText($ini, "[Settings]`r`nSaveSettings=0`r`nSaveRecentFiles=0`r`n", [Text.Encoding]::Unicode)
+[IO.File]::WriteAllText($namedFile, 'named file')
+$shortNul = [byte[]]@(0,65,66)
+$boundary1022 = New-Object byte[] 1022
+$boundary1022[1] = 65
+$boundary1023 = New-Object byte[] 1023
+$boundary1023[1] = 65
+$cases = @(
+ @{Name='leading-NUL'; Bytes=$shortNul; LegacyDrops=$true},
+ @{Name='whitespace'; Bytes=[Text.Encoding]::UTF8.GetBytes(" `t`r`n"); LegacyDrops=$true},
+ @{Name='1022-byte-NUL'; Bytes=$boundary1022; LegacyDrops=$true},
+ @{Name='1023-byte-NUL'; Bytes=$boundary1023; LegacyDrops=$false},
+ @{Name='empty'; Bytes=[byte[]]@(); LegacyDrops=$false},
+ @{Name='named-NUL'; Bytes=$shortNul; LegacyDrops=$false; Named=$true}
+)
+$observedLoss = 0
+foreach ($case in $cases) {
+ foreach ($operation in @('New','Open','Close')) {
+ $script:processHandle = [IntPtr]::Zero
+ try {
+ Start-Editor $case.Named
+ Add-Bytes $case.Bytes
+ $before = Read-Bytes
+ $main = $script:window
+ if ($operation -eq 'Close') { $null = [SavePromptNative]::PostMessage($main, 0x10, [IntPtr]::Zero, [IntPtr]::Zero) }
+ else {
+ $command = if ($operation -eq 'New') { 40000 } else { 40001 }
+ $null = [SavePromptNative]::PostMessage($main, 0x111, [IntPtr]$command, [IntPtr]::Zero)
+ }
+ $prompt = $false
+ for ($attempt = 0; $attempt -lt 100; ++$attempt) {
+ $script:app.Refresh()
+ if ($script:app.HasExited) { break }
+ Find-Windows
+ if ($script:dialog -ne [IntPtr]::Zero) {
+ $prompt = [SavePromptNative]::GetDlgItem($script:dialog,6) -ne [IntPtr]::Zero -and [SavePromptNative]::GetDlgItem($script:dialog,7) -ne [IntPtr]::Zero
+ break
+ }
+ if ($operation -eq 'New' -and (Sci 2006) -eq 0) { break }
+ Start-Sleep -Milliseconds 20
+ }
+ $expected = $case.Bytes.Length -gt 0 -and -not ($ExpectLegacyFailure -and $case.LegacyDrops)
+ Assert ($prompt -eq $expected) "$($case.Name)/${operation}: save prompt=$prompt, expected=$expected"
+ if ($prompt) {
+ $null = [SavePromptNative]::SendMessage($script:dialog,0x111,[IntPtr]2,[IntPtr]::Zero)
+ $after = Read-Bytes
+ Assert ([Convert]::ToBase64String($before) -eq [Convert]::ToBase64String($after)) "$($case.Name)/${operation}: Cancel lost content"
+ } elseif ($case.LegacyDrops) { ++$observedLoss }
+ Write-Output "Passed $($case.Name)/$operation (prompt=$prompt)"
+ } finally {
+ if ($script:processHandle -ne [IntPtr]::Zero) { $null = [SavePromptNative]::CloseHandle($script:processHandle) }
+ if ($script:app -and -not $script:app.HasExited) { Stop-Process -Id $script:app.Id }
+ }
+ }
+}
+if ($ExpectLegacyFailure) { Assert ($observedLoss -eq 9) 'Legacy data-loss cases were not reproduced' }
+Write-Output "Save-prompt integration passed. Legacy loss cases: $observedLoss. Fixtures: $testDir"
diff --git a/src/Notepad2.c b/src/Notepad2.c
index dfed1cdb..b8b37cec 100644
--- a/src/Notepad2.c
+++ b/src/Notepad2.c
@@ -7021,13 +7021,6 @@ BOOL FileSave(BOOL bSaveAlways,BOOL bAsk,BOOL bSaveAs,BOOL bSaveCopy)
int cchText = (int)SendMessage(hwndEdit,SCI_GETLENGTH,0,0);
if (cchText == 0)
bIsEmptyNewFile = TRUE;
- else if (cchText < 1023) {
- char tchText[2048];
- SendMessage(hwndEdit,SCI_GETTEXT,(WPARAM)2047,(LPARAM)tchText);
- StrTrimA(tchText," \t\n\r");
- if (lstrlenA(tchText) == 0)
- bIsEmptyNewFile = TRUE;
- }
}
if (!bSaveAlways && (!bModified && iEncoding == iOriginalEncoding || bIsEmptyNewFile) && !bSaveAs)
From 6c0f304035ffd1413bcce40afadaffbb92e66ffc Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:19:34 +0100
Subject: [PATCH 03/10] fix: skip null recent-file slots before comparing paths
Adapted from antiufo/notepad2-mod 5f47e551dab65c258abc6eea8ccccb8d9d38c2e7. Fixes #10. Production MRU functions tested with empty, partial, duplicate, relative, environment-expanded and full lists on x86/x64.
---
src/Helpers.c | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/Helpers.c b/src/Helpers.c
index 3870c26e..950b2ef2 100644
--- a/src/Helpers.c
+++ b/src/Helpers.c
@@ -1656,6 +1656,7 @@ BOOL MRU_AddFile(LPMRULIST pmru,LPCWSTR pszFile,BOOL bRelativePath,BOOL bUnexpan
int i;
for (i = 0; i < pmru->iSize; i++) {
+ if (!pmru->pszItems[i]) continue;
if (lstrcmpi(pmru->pszItems[i],pszFile) == 0) {
LocalFree(pmru->pszItems[i]);
break;
From 06920b5c2763f9cd0c682dae2b4abccd4fe17b62 Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:19:35 +0100
Subject: [PATCH 04/10] build: interpret application sources consistently as
UTF-8
Narrow adaptation of mercury233/notepad2-mod eed2b24512e83245497216171a7b9bc718dfb27f. Fixes #11. Set only the source charset for all four configurations; leave execution charset, resources, source bytes and Scintilla unchanged.
---
src/Notepad2.vcxproj | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/Notepad2.vcxproj b/src/Notepad2.vcxproj
index ccd68ebd..0b066a48 100644
--- a/src/Notepad2.vcxproj
+++ b/src/Notepad2.vcxproj
@@ -102,6 +102,7 @@
WIN32;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions)
MultiThreadedDebug
Level3
+ /source-charset:utf-8 %(AdditionalOptions)
comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies)
@@ -128,6 +129,7 @@
_WIN64;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions)
MultiThreadedDebug
Level3
+ /source-charset:utf-8 %(AdditionalOptions)
comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies)
@@ -152,6 +154,7 @@
WIN32;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;NDEBUG;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions)
MultiThreaded
Level3
+ /source-charset:utf-8 %(AdditionalOptions)
comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies)
@@ -179,6 +182,7 @@
_WIN64;STATIC_BUILD;SCI_LEXER;BOOKMARK_EDITION;NDEBUG;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions)
MultiThreaded
Level3
+ /source-charset:utf-8 %(AdditionalOptions)
comctl32.lib;imm32.lib;shlwapi.lib;msimg32.lib;%(AdditionalDependencies)
From 7b0e406028cc6e6de9ce9e89fbfd696d99428580 Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:19:35 +0100
Subject: [PATCH 05/10] fix: respect startup flags during elevated relaunch
Adapted from johnwait/notepad2-mod-jrb 937b7ff93b534275f0fe0df4bedd21b7d8537c9d. Refs #12. Preserve explicit hidden/maximized/minimized states; otherwise use SW_SHOWNORMAL. Source-linked show-state contract checks pass; actual standard-user UAC check remains manual.
---
src/Notepad2.c | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Notepad2.c b/src/Notepad2.c
index b8b37cec..d5f758b9 100644
--- a/src/Notepad2.c
+++ b/src/Notepad2.c
@@ -7611,7 +7611,7 @@ BOOL RelaunchElevated() {
sei.lpFile = lpArg1;
sei.lpParameters = lpArg2;
sei.lpDirectory = g_wchWorkingDirectory;
- sei.nShow = si.wShowWindow;
+ sei.nShow = (si.dwFlags & STARTF_USESHOWWINDOW) ? si.wShowWindow : SW_SHOWNORMAL;
ShellExecuteEx(&sei);
}
From 6f33525184092db1852d5d84f18907dcd062ad51 Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:27:55 +0100
Subject: [PATCH 06/10] test: use pytest and pywinauto for native Windows
regression coverage
---
.editorconfig | 7 +
.gitattributes | 2 +
.github/workflows/build.yml | 20 +--
.gitignore | 3 +
AGENTS.md | 6 +
Readme.md | 20 ++-
build/test-fork-helpers.c | 65 ++++++++++
build/test-save-prompts.ps1 | 154 ----------------------
build/test-themes.ps1 | 227 ---------------------------------
pytest.ini | 3 +
tests/conftest.py | 43 +++++++
tests/requirements.in | 2 +
tests/requirements.txt | 61 +++++++++
tests/test_native_contracts.py | 54 ++++++++
tests/test_save_prompts.py | 69 ++++++++++
tests/test_themes.py | 87 +++++++++++++
tests/windows_app.py | 97 ++++++++++++++
17 files changed, 525 insertions(+), 395 deletions(-)
create mode 100644 build/test-fork-helpers.c
delete mode 100644 build/test-save-prompts.ps1
delete mode 100644 build/test-themes.ps1
create mode 100644 pytest.ini
create mode 100644 tests/conftest.py
create mode 100644 tests/requirements.in
create mode 100644 tests/requirements.txt
create mode 100644 tests/test_native_contracts.py
create mode 100644 tests/test_save_prompts.py
create mode 100644 tests/test_themes.py
create mode 100644 tests/windows_app.py
diff --git a/.editorconfig b/.editorconfig
index 7867892e..5119759f 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -16,6 +16,13 @@ trim_trailing_whitespace = true
[*.ps1]
indent_size = 4
+[tests/**]
+charset = utf-8
+indent_style = space
+indent_size = 4
+end_of_line = lf
+trim_trailing_whitespace = true
+
[*.{vcxproj,filters,props}]
charset = utf-8
diff --git a/.gitattributes b/.gitattributes
index cf59f17e..03145167 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -10,6 +10,8 @@
*.props text eol=crlf
*.md text eol=lf
*.ps1 text eol=lf
+*.py text eol=lf
+*.in text eol=lf
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bd4c6bca..2e4633bf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -32,14 +32,16 @@ jobs:
New-Item -ItemType Directory -Path bin -Force | Out-Null
./build/build.ps1 -Architecture '${{ matrix.architecture }}' -Configuration '${{ matrix.configuration }}' *> bin/build.log
if ($LASTEXITCODE -ne 0) { Get-Content bin/build.log -Tail 80; exit 1 }
- - name: Test editor themes
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
+ with:
+ python-version: '3.13'
+ architecture: ${{ matrix.architecture }}
+ - name: Install locked test dependencies
+ run: python -m pip install --require-hashes -r tests/requirements.txt
+ - name: Test native editor
run: |
- try {
- ./build/test-themes.ps1 -Executable './bin/VS2022/${{ matrix.configuration }}_${{ matrix.architecture }}/Notepad2.exe' *> bin/tests.log
- } catch {
- Get-Content bin/tests.log -Tail 80
- throw
- }
+ python -m pytest --executable './bin/VS2022/${{ matrix.configuration }}_${{ matrix.architecture }}/Notepad2.exe' --architecture '${{ matrix.architecture }}' --configuration '${{ matrix.configuration }}' --junitxml bin/test-results.xml *> bin/tests.log
+ if ($LASTEXITCODE -ne 0) { Get-Content bin/tests.log -Tail 100; exit 1 }
Get-Content bin/tests.log
- name: Package portable release
if: matrix.configuration == 'Release'
@@ -64,7 +66,9 @@ jobs:
if: always()
with:
name: logs-${{ matrix.configuration }}-${{ matrix.architecture }}
- path: bin/*.log
+ path: |
+ bin/*.log
+ bin/test-results.xml
retention-days: 14
release:
name: Publish release
diff --git a/.gitignore b/.gitignore
index 289c2296..8201b334 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@
*.p12
/.env
/.env.*
+/.venv/
+/.pytest_cache/
+**/__pycache__/
diff --git a/AGENTS.md b/AGENTS.md
index f49a12ea..0ea366ac 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,6 +14,12 @@
- Theme scope is the editor only: text, syntax colors, margins, caret, selections,
folding and highlights. Application chrome (menus, dialogs, toolbar, status bar,
title bar and scrollbars) is out of scope, not a planned follow-up.
+- Keep the single-document interface. Multi-tab editing and Notepad++ parity or
+ compatibility are not product goals. Standalone patches from Notepad3/Notepad4
+ may be evaluated surgically; their frameworks and feature stacks remain excluded.
+- Use pytest and pywinauto's Win32 backend for native UI regression tests, with
+ only a small adapter for Scintilla messages. Keep test dependencies separate
+ from the application. PowerShell remains appropriate for build/release scripts.
- Finish each logical change with a granular commit after relevant validation.
Do not ask again whether to commit completed, authorized work. Stage explicit
paths/hunks and inspect the staged diff. Keep dependency updates, build changes,
diff --git a/Readme.md b/Readme.md
index 584993fd..a623f13d 100644
--- a/Readme.md
+++ b/Readme.md
@@ -151,16 +151,24 @@ The packaging command is `./build/package.ps1 -Architecture x64` (or `x86`). It
uses Release output and writes into ignored `bin/packages`; supply a fresh
`-OutputDirectory` when repackaging an already-created version locally.
-To run the native theme integration checks against a built executable:
+Tests use Python 3.13, pytest, and pywinauto's Win32 backend. Test dependencies
+are locked with hashes and are not application dependencies. PowerShell is used
+for building, not UI automation. To test a built executable:
```powershell
-.\build\test-themes.ps1
-.\build\test-themes.ps1 -Executable .\bin\VS2022\Release_x86\Notepad2.exe
+py -3.13 -m venv .venv
+.\.venv\Scripts\python.exe -m pip install --require-hashes -r tests/requirements.txt
+.\.venv\Scripts\python.exe -m pytest
+.\.venv\Scripts\python.exe -m pytest --executable .\bin\VS2022\Release_x86\Notepad2.exe --architecture x86
```
The checks launch isolated instances with temporary settings, exercise all 38
schemes and customization Preview/Cancel/Reset, and verify palette/font separation,
switching, restart persistence, invalid defaults, and disabled settings saving.
-Use `-BaselineExecutable ` for a seven-run small-file startup/private-memory/
-numeric edit/idle comparison. These checks do not replace visual contrast review
-or establish large-file performance.
+They also exercise save prompts with embedded NUL/whitespace, exact-byte Cancel
+preservation, production MRU functions, source charset, and startup show-state
+contracts. The latter do not replace an interactive standard-user UAC test.
+Use `--configuration Debug` for the corresponding native helper runtime. A
+pre-fix binary can be checked with `-m pytest tests/test_save_prompts.py
+--legacy-save-behavior --executable ` to reproduce the nine loss paths.
+These tests do not replace visual review or establish large-file performance.
diff --git a/build/test-fork-helpers.c b/build/test-fork-helpers.c
new file mode 100644
index 00000000..e1caf750
--- /dev/null
+++ b/build/test-fork-helpers.c
@@ -0,0 +1,65 @@
+// Exercises production MRU/path functions, plus compiler and startup contracts.
+#include
+#include
+#include
+#include "Scintilla.h"
+#include "Helpers.h"
+#include "Version.h"
+#include "fork-contracts.h"
+
+HINSTANCE g_hInstance;
+UINT16 g_uWinVer;
+HMODULE hModUxTheme;
+WCHAR szIniFile[MAX_PATH];
+
+#define CHECK(condition) do { if (!(condition)) { printf("Failed at line %d\n",__LINE__); return 1; } } while (0)
+
+int main(void)
+{
+ WCHAR path[MAX_PATH],second[MAX_PATH],relative[] = L"test-file.txt";
+ LPMRULIST list = MRU_Create(L"Test",MRU_NOCASE,3);
+ STARTUPINFO si = {0};
+ int i;
+ g_hInstance = GetModuleHandle(NULL);
+ GetModuleFileName(NULL,path,MAX_PATH);
+ PathRemoveFileSpec(path);
+ lstrcpy(second,path);
+ PathAppend(path,L"test-file.txt");
+ PathAppend(second,L"second.txt");
+ CHECK(MRU_AddFile(list,path,FALSE,FALSE)); // Initially every slot is NULL.
+ CHECK(lstrcmpi(list->pszItems[0],path) == 0);
+ CHECK(MRU_AddFile(list,second,FALSE,FALSE)); // Partial list.
+ CHECK(MRU_AddFile(list,path,FALSE,FALSE)); // Existing absolute duplicate.
+ CHECK(lstrcmpi(list->pszItems[0],path) == 0 && lstrcmpi(list->pszItems[1],second) == 0);
+ MRU_Empty(list);
+ list->pszItems[1] = StrDup(relative); // Null gap before a relative duplicate.
+ CHECK(MRU_AddFile(list,path,FALSE,FALSE));
+ CHECK(lstrcmpi(list->pszItems[0],path) == 0);
+ MRU_Empty(list);
+ SetEnvironmentVariable(L"NP2_MRU_TEST",second);
+ list->pszItems[1] = StrDup(L"%NP2_MRU_TEST%");
+ CHECK(MRU_AddFile(list,second,FALSE,FALSE));
+ CHECK(lstrcmpi(list->pszItems[0],second) == 0);
+ MRU_Empty(list);
+ for (i = 0; i < 5; ++i) {
+ WCHAR name[MAX_PATH];
+ wsprintf(name,L"C:\\test-%d.txt",i);
+ CHECK(MRU_AddFile(list,name,FALSE,FALSE));
+ }
+ CHECK(lstrcmp(list->pszItems[0],L"C:\\test-4.txt") == 0);
+ CHECK(lstrcmp(list->pszItems[2],L"C:\\test-2.txt") == 0);
+ MRU_Destroy(list);
+ CHECK(VERSION_LEGALCOPYRIGHT_LONG[0] == 0x00A9);
+ CHECK(wcschr(titlePunctuation,0x0384) && wcschr(titlePunctuation,0x2019));
+ CHECK(StartupShowState(si) == SW_SHOWNORMAL);
+ si.wShowWindow = SW_SHOWMAXIMIZED;
+ CHECK(StartupShowState(si) == SW_SHOWNORMAL); // Value irrelevant without flag.
+ si.dwFlags = STARTF_USESHOWWINDOW;
+ CHECK(StartupShowState(si) == SW_SHOWMAXIMIZED);
+ si.wShowWindow = SW_HIDE;
+ CHECK(StartupShowState(si) == SW_HIDE);
+ si.wShowWindow = SW_SHOWMINIMIZED;
+ CHECK(StartupShowState(si) == SW_SHOWMINIMIZED);
+ puts("MRU, UTF-8 source and startup show-state contracts passed.");
+ return 0;
+}
diff --git a/build/test-save-prompts.ps1 b/build/test-save-prompts.ps1
deleted file mode 100644
index 436b9a4e..00000000
--- a/build/test-save-prompts.ps1
+++ /dev/null
@@ -1,154 +0,0 @@
-[CmdletBinding()]
-param(
- [string]$Executable = "$PSScriptRoot/../bin/VS2022/Release_x64/Notepad2.exe",
- [switch]$ExpectLegacyFailure
-)
-
-$ErrorActionPreference = 'Stop'
-Add-Type @'
-using System;
-using System.Text;
-using System.Runtime.InteropServices;
-public static class SavePromptNative {
- public delegate bool Callback(IntPtr hwnd, IntPtr state);
- [DllImport("user32.dll")] public static extern bool EnumWindows(Callback cb, IntPtr state);
- [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr hwnd, Callback cb, IntPtr state);
- [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
- [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, StringBuilder text, int size);
- [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr hwnd, int id);
- [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l);
- [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l);
- [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
- [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr VirtualAllocEx(IntPtr process, IntPtr address, UIntPtr size, uint allocationType, uint protection);
- [DllImport("kernel32.dll")] public static extern bool VirtualFreeEx(IntPtr process, IntPtr address, UIntPtr size, uint freeType);
- [DllImport("kernel32.dll", SetLastError=true)] public static extern bool WriteProcessMemory(IntPtr process, IntPtr address, byte[] bytes, UIntPtr size, out UIntPtr written);
- [DllImport("kernel32.dll", SetLastError=true)] public static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] bytes, UIntPtr size, out UIntPtr read);
- [DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr handle);
-}
-'@
-
-function Assert($condition, $message) { if (-not $condition) { throw $message } }
-function Sci($message, $w = 0, $l = 0) {
- [SavePromptNative]::SendMessage($script:editor, $message, [IntPtr]$w, [IntPtr]$l).ToInt64()
-}
-function Find-Windows {
- $script:dialog = [IntPtr]::Zero
- $script:window = [IntPtr]::Zero
- $processId = $script:app.Id
- $callback = [SavePromptNative+Callback]{ param($handle, $state)
- [uint32]$owner = 0
- $null = [SavePromptNative]::GetWindowThreadProcessId($handle, [ref]$owner)
- if ($owner -eq $processId) {
- $name = New-Object Text.StringBuilder 128
- $null = [SavePromptNative]::GetClassName($handle, $name, 128)
- if ($name.ToString() -like 'Notepad2*') { $script:window = $handle }
- if ($name.ToString() -eq '#32770') { $script:dialog = $handle }
- }
- return $true
- }
- $null = [SavePromptNative]::EnumWindows($callback, [IntPtr]::Zero)
-}
-function Start-Editor($named) {
- $arguments = @('/n', '/f', ('"'+$ini+'"'))
- if ($named) { $arguments += ('"'+$namedFile+'"') }
- $script:app = Start-Process -FilePath $Executable -ArgumentList $arguments -WindowStyle Hidden -PassThru
- Assert ($script:app.WaitForInputIdle(60000)) 'Editor startup timed out'
- Find-Windows
- Assert ($script:window -ne [IntPtr]::Zero) 'Main window missing'
- $script:editor = [IntPtr]::Zero
- $child = [SavePromptNative+Callback]{ param($handle, $state)
- $name = New-Object Text.StringBuilder 128
- $null = [SavePromptNative]::GetClassName($handle, $name, 128)
- if ($name.ToString() -eq 'Scintilla') { $script:editor = $handle }
- return $true
- }
- $null = [SavePromptNative]::EnumChildWindows($script:window, $child, [IntPtr]::Zero)
- Assert ($script:editor -ne [IntPtr]::Zero) 'Editor window missing'
- $script:processHandle = [SavePromptNative]::OpenProcess(0x38, $false, $script:app.Id)
- Assert ($script:processHandle -ne [IntPtr]::Zero) 'Cannot access test-process memory'
-}
-function Add-Bytes([byte[]]$bytes) {
- if (-not $bytes.Length) { return }
- $remote = [SavePromptNative]::VirtualAllocEx($script:processHandle, [IntPtr]::Zero, [UIntPtr]$bytes.Length, 0x3000, 4)
- Assert ($remote -ne [IntPtr]::Zero) 'Cannot allocate test-process text'
- try {
- [UIntPtr]$written = [UIntPtr]::Zero
- Assert ([SavePromptNative]::WriteProcessMemory($script:processHandle, $remote, $bytes, [UIntPtr]$bytes.Length, [ref]$written)) 'Cannot write test text'
- $null = Sci 2001 $bytes.Length $remote
- } finally { $null = [SavePromptNative]::VirtualFreeEx($script:processHandle, $remote, [UIntPtr]::Zero, 0x8000) }
-}
-function Read-Bytes {
- $length = [int](Sci 2006)
- $remote = [SavePromptNative]::VirtualAllocEx($script:processHandle, [IntPtr]::Zero, [UIntPtr]($length+1), 0x3000, 4)
- Assert ($remote -ne [IntPtr]::Zero) 'Cannot allocate read buffer'
- try {
- $null = Sci 2182 ($length+1) $remote
- $bytes = New-Object byte[] $length
- [UIntPtr]$read = [UIntPtr]::Zero
- Assert ([SavePromptNative]::ReadProcessMemory($script:processHandle, $remote, $bytes, [UIntPtr]$length, [ref]$read)) 'Cannot read test text'
- return ,$bytes
- } finally { $null = [SavePromptNative]::VirtualFreeEx($script:processHandle, $remote, [UIntPtr]::Zero, 0x8000) }
-}
-
-$Executable = (Resolve-Path $Executable).Path
-$testDir = Join-Path ([IO.Path]::GetTempPath()) ('np2-save-prompts-'+[Guid]::NewGuid().ToString('N'))
-$null = New-Item -ItemType Directory -Path $testDir
-$ini = Join-Path $testDir 'settings.ini'
-$namedFile = Join-Path $testDir 'named.txt'
-[IO.File]::WriteAllText($ini, "[Settings]`r`nSaveSettings=0`r`nSaveRecentFiles=0`r`n", [Text.Encoding]::Unicode)
-[IO.File]::WriteAllText($namedFile, 'named file')
-$shortNul = [byte[]]@(0,65,66)
-$boundary1022 = New-Object byte[] 1022
-$boundary1022[1] = 65
-$boundary1023 = New-Object byte[] 1023
-$boundary1023[1] = 65
-$cases = @(
- @{Name='leading-NUL'; Bytes=$shortNul; LegacyDrops=$true},
- @{Name='whitespace'; Bytes=[Text.Encoding]::UTF8.GetBytes(" `t`r`n"); LegacyDrops=$true},
- @{Name='1022-byte-NUL'; Bytes=$boundary1022; LegacyDrops=$true},
- @{Name='1023-byte-NUL'; Bytes=$boundary1023; LegacyDrops=$false},
- @{Name='empty'; Bytes=[byte[]]@(); LegacyDrops=$false},
- @{Name='named-NUL'; Bytes=$shortNul; LegacyDrops=$false; Named=$true}
-)
-$observedLoss = 0
-foreach ($case in $cases) {
- foreach ($operation in @('New','Open','Close')) {
- $script:processHandle = [IntPtr]::Zero
- try {
- Start-Editor $case.Named
- Add-Bytes $case.Bytes
- $before = Read-Bytes
- $main = $script:window
- if ($operation -eq 'Close') { $null = [SavePromptNative]::PostMessage($main, 0x10, [IntPtr]::Zero, [IntPtr]::Zero) }
- else {
- $command = if ($operation -eq 'New') { 40000 } else { 40001 }
- $null = [SavePromptNative]::PostMessage($main, 0x111, [IntPtr]$command, [IntPtr]::Zero)
- }
- $prompt = $false
- for ($attempt = 0; $attempt -lt 100; ++$attempt) {
- $script:app.Refresh()
- if ($script:app.HasExited) { break }
- Find-Windows
- if ($script:dialog -ne [IntPtr]::Zero) {
- $prompt = [SavePromptNative]::GetDlgItem($script:dialog,6) -ne [IntPtr]::Zero -and [SavePromptNative]::GetDlgItem($script:dialog,7) -ne [IntPtr]::Zero
- break
- }
- if ($operation -eq 'New' -and (Sci 2006) -eq 0) { break }
- Start-Sleep -Milliseconds 20
- }
- $expected = $case.Bytes.Length -gt 0 -and -not ($ExpectLegacyFailure -and $case.LegacyDrops)
- Assert ($prompt -eq $expected) "$($case.Name)/${operation}: save prompt=$prompt, expected=$expected"
- if ($prompt) {
- $null = [SavePromptNative]::SendMessage($script:dialog,0x111,[IntPtr]2,[IntPtr]::Zero)
- $after = Read-Bytes
- Assert ([Convert]::ToBase64String($before) -eq [Convert]::ToBase64String($after)) "$($case.Name)/${operation}: Cancel lost content"
- } elseif ($case.LegacyDrops) { ++$observedLoss }
- Write-Output "Passed $($case.Name)/$operation (prompt=$prompt)"
- } finally {
- if ($script:processHandle -ne [IntPtr]::Zero) { $null = [SavePromptNative]::CloseHandle($script:processHandle) }
- if ($script:app -and -not $script:app.HasExited) { Stop-Process -Id $script:app.Id }
- }
- }
-}
-if ($ExpectLegacyFailure) { Assert ($observedLoss -eq 9) 'Legacy data-loss cases were not reproduced' }
-Write-Output "Save-prompt integration passed. Legacy loss cases: $observedLoss. Fixtures: $testDir"
diff --git a/build/test-themes.ps1 b/build/test-themes.ps1
deleted file mode 100644
index 44113876..00000000
--- a/build/test-themes.ps1
+++ /dev/null
@@ -1,227 +0,0 @@
-[CmdletBinding()]
-param(
- [string]$Executable = "$PSScriptRoot/../bin/VS2022/Release_x64/Notepad2.exe",
- [string]$BaselineExecutable
-)
-
-$ErrorActionPreference = 'Stop'
-Add-Type @'
-using System;
-using System.Text;
-using System.Runtime.InteropServices;
-public static class ThemeTestNative {
- public delegate bool Callback(IntPtr hwnd, IntPtr state);
- [DllImport("user32.dll")] public static extern bool EnumWindows(Callback cb, IntPtr state);
- [DllImport("user32.dll")] public static extern bool EnumChildWindows(IntPtr hwnd, Callback cb, IntPtr state);
- [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
- [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hwnd, StringBuilder text, int size);
- [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l);
- [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint msg, IntPtr w, IntPtr l);
- [DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr hwnd, int id);
- [DllImport("user32.dll", CharSet=CharSet.Unicode, EntryPoint="SendMessageW")] public static extern IntPtr SendText(IntPtr hwnd, uint msg, IntPtr w, string text);
-}
-'@
-
-function Assert($condition, $message) { if (-not $condition) { throw $message } }
-function Sci($message, $w = 0, $l = 0) {
- [ThemeTestNative]::SendMessage($script:editor, $message, [IntPtr]$w, [IntPtr]$l).ToInt64()
-}
-function Select-Theme($dark) {
- $command = if ($dark) { 40981 } else { 40980 }
- $null = [ThemeTestNative]::SendMessage($script:window, 0x111, [IntPtr]$command, [IntPtr]::Zero)
-}
-function Start-Editor($ini, $document, $scheme = 'cpp') {
- $script:app = Start-Process -FilePath $Executable -ArgumentList @('/n', '/f', ('"'+$ini+'"'), '/s', $scheme, ('"'+$document+'"')) -WindowStyle Hidden -PassThru
- # Hosted Windows runners can stall process initialization under scanner/load.
- # Keep assertions strict, but allow a bounded CI startup window.
- Assert ($script:app.WaitForInputIdle(60000)) "Editor startup timed out after 60s: $Executable (PID $($script:app.Id))"
- $script:window = [IntPtr]::Zero
- $script:editor = [IntPtr]::Zero
- $processId = $script:app.Id
- $callback = [ThemeTestNative+Callback]{ param($handle, $state)
- [uint32]$owner = 0
- $null = [ThemeTestNative]::GetWindowThreadProcessId($handle, [ref]$owner)
- $name = New-Object Text.StringBuilder 128
- $null = [ThemeTestNative]::GetClassName($handle, $name, 128)
- if ($owner -eq $processId -and $name.ToString() -like 'Notepad2*') { $script:window = $handle }
- return $true
- }
- $null = [ThemeTestNative]::EnumWindows($callback, [IntPtr]::Zero)
- Assert ($script:window -ne [IntPtr]::Zero) 'Main window missing'
- $child = [ThemeTestNative+Callback]{ param($handle, $state)
- $name = New-Object Text.StringBuilder 128
- $null = [ThemeTestNative]::GetClassName($handle, $name, 128)
- if ($name.ToString() -eq 'Scintilla') { $script:editor = $handle }
- return $true
- }
- $null = [ThemeTestNative]::EnumChildWindows($script:window, $child, [IntPtr]::Zero)
- Assert ($script:editor -ne [IntPtr]::Zero) 'Scintilla window missing'
-}
-function Stop-Editor {
- $null = Sci 2014
- $null = [ThemeTestNative]::PostMessage($script:window, 0x10, [IntPtr]::Zero, [IntPtr]::Zero)
- Assert ($script:app.WaitForExit(10000)) 'Editor did not exit normally'
-}
-
-function Open-Customization {
- $null = [ThemeTestNative]::PostMessage($script:window, 0x111, [IntPtr]40402, [IntPtr]::Zero)
- $script:dialog = [IntPtr]::Zero
- $processId = $script:app.Id
- $callback = [ThemeTestNative+Callback]{ param($handle, $state)
- [uint32]$owner = 0
- $null = [ThemeTestNative]::GetWindowThreadProcessId($handle, [ref]$owner)
- $name = New-Object Text.StringBuilder 128
- $null = [ThemeTestNative]::GetClassName($handle, $name, 128)
- if ($owner -eq $processId -and $name.ToString() -eq '#32770') { $script:dialog = $handle }
- return $true
- }
- for ($attempt = 0; $attempt -lt 50 -and $script:dialog -eq [IntPtr]::Zero; ++$attempt) {
- Start-Sleep -Milliseconds 20
- $null = [ThemeTestNative]::EnumWindows($callback, [IntPtr]::Zero)
- }
- Assert ($script:dialog -ne [IntPtr]::Zero) 'Customize Schemes dialog missing'
- # Window enumeration can see the dialog before WM_INITDIALOG populates it.
- $first = [IntPtr]::Zero
- for ($attempt = 0; $attempt -lt 500 -and $first -eq [IntPtr]::Zero; ++$attempt) {
- $tree = [ThemeTestNative]::GetDlgItem($script:dialog, 100)
- $root = [ThemeTestNative]::SendMessage($tree, 0x110A, [IntPtr]::Zero, [IntPtr]::Zero)
- if ($root -ne [IntPtr]::Zero) { $first = [ThemeTestNative]::SendMessage($tree, 0x110A, [IntPtr]4, $root) }
- if ($first -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds 20 }
- }
- Assert ($first -ne [IntPtr]::Zero) 'Customize Schemes tree was not populated'
- $null = [ThemeTestNative]::SendMessage($tree, 0x110B, [IntPtr]9, $first)
-}
-
-$Executable = (Resolve-Path $Executable).Path
-$testDir = Join-Path ([IO.Path]::GetTempPath()) ('np2-themes-' + [Guid]::NewGuid().ToString('N'))
-$null = New-Item -ItemType Directory -Path $testDir
-$ini = Join-Path $testDir 'settings.ini'
-$document = Join-Path $testDir 'sample.cpp'
-[IO.File]::WriteAllText($document, "// comment`r`nint main() { return 42; }`r`n")
-$settings = "[Settings]`r`nSaveSettings=1`r`n[Default Text]`r`nDefault Style=font:Consolas; size:13; fore:#123456; back:#EEDDCC`r`n"
-[IO.File]::WriteAllText($ini, $settings, [Text.Encoding]::Unicode)
-try {
- Start-Editor $ini $document
- $original = Sci 2482 32
- $fontSize = Sci 2485 32
- $lexer = Sci 4002
- $null = Sci 2645 0 1 # Create a real undo record, with numeric-only cross-process messages.
- $null = Sci 2160 2 7
- $snapshot = @((Sci 2006), (Sci 2008), (Sci 2009), (Sci 2152), (Sci 2174))
- $timer = [Diagnostics.Stopwatch]::StartNew()
- for ($i = 0; $i -lt 20; ++$i) {
- Select-Theme $true
- Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Dark background not applied'
- Assert ((Sci 2485 32) -eq $fontSize) 'Theme changed font size'
- Select-Theme $false
- Assert ((Sci 2482 32) -eq $original) 'Original custom background lost'
- }
- $timer.Stop()
- Assert ((Sci 4002) -eq $lexer) 'Theme changed lexer'
- $after = @((Sci 2006), (Sci 2008), (Sci 2009), (Sci 2152), (Sci 2174))
- Assert (($snapshot -join ',') -eq ($after -join ',')) 'Theme changed document/selection/viewport/undo state'
- Select-Theme $true
- Stop-Editor
- Start-Editor $ini $document
- Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Theme selection did not persist'
- Select-Theme $false
- Assert ((Sci 2482 32) -eq $original) 'Original settings changed after restart'
- Stop-Editor
- [IO.File]::WriteAllText($ini, $settings.Replace('SaveSettings=1','SaveSettings=0'), [Text.Encoding]::Unicode)
- Start-Editor $ini $document
- Select-Theme $true
- Stop-Editor
- Start-Editor $ini $document
- Assert ((Sci 2482 32) -eq $original) 'Disabled settings saving was ignored'
- Stop-Editor
- # Saved Dark colors are independent; typography embedded in a color section is ignored.
- $custom = $settings + "[Styles]`r`nEditorTheme=1`r`n[Theme.Dark.Default Text]`r`nDefault Style=font:Ignored; size:99; fore:#ABCDEF; back:#222222`r`n"
- [IO.File]::WriteAllText($ini, $custom, [Text.Encoding]::Unicode)
- Start-Editor $ini $document
- Assert ((Sci 2482 32) -eq 0x222222) 'Custom Dark color not loaded'
- Assert ((Sci 2485 32) -eq $fontSize) 'Dark color section changed shared typography'
- Select-Theme $false
- Assert ((Sci 2482 32) -eq $original) 'Dark customization overwrote Original'
- Select-Theme $true
- Assert ((Sci 2482 32) -eq 0x222222) 'Dark customization lost on round trip'
- Stop-Editor
- [IO.File]::WriteAllText($ini, $settings + "[Styles]`r`nEditorTheme=99`r`n", [Text.Encoding]::Unicode)
- Start-Editor $ini $document
- Assert ((Sci 2482 32) -eq $original) 'Unknown theme did not fall back to Original'
- Stop-Editor
- [IO.File]::WriteAllText($ini, $settings + "[Styles]`r`nEditorTheme=1`r`n[Theme.Dark.Default Text]`r`nDefault Style=back:invalid`r`n", [Text.Encoding]::Unicode)
- Start-Editor $ini $document
- Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Invalid saved Dark default did not fall back safely'
- Stop-Editor
- $source = [IO.File]::ReadAllText((Join-Path $PSScriptRoot '../src/Styles.c'))
- $lexers = [regex]::Matches($source, 'EDITLEXER\s+\w+\s*=\s*\{[^\r\n]*?L"([^"]+)",\s*L"([^"]*)"')
- Assert ($lexers.Count -eq 38) 'Lexer coverage inventory changed'
- foreach ($entry in $lexers) {
- [IO.File]::WriteAllText($ini, $settings, [Text.Encoding]::Unicode)
- $scheme = $entry.Groups[2].Value.Split(';')[0].Trim()
- Start-Editor $ini $document $scheme
- Select-Theme $true
- Assert ((Sci 2482 32) -eq 0x1E1E1E) "Dark background failed for $scheme"
- Select-Theme $false
- Stop-Editor
- }
- [IO.File]::WriteAllText($ini, $settings, [Text.Encoding]::Unicode)
- Start-Editor $ini $document
- Select-Theme $true
- Open-Customization
- $field = [ThemeTestNative]::GetDlgItem($script:dialog, 102)
- $null = [ThemeTestNative]::SendText($field, 0xC, [IntPtr]::Zero, 'font:Consolas; size:15; fore:#EEEEEE; back:#111111')
- $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]106, [IntPtr]::Zero)
- Assert ((Sci 2482 32) -eq 0x111111) 'Customization preview did not apply'
- $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]107, [IntPtr]::Zero)
- $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]106, [IntPtr]::Zero)
- Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Reset restored light colors in Dark'
- $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]2, [IntPtr]::Zero)
- Assert ((Sci 2482 32) -eq 0x1E1E1E -and (Sci 2485 32) -eq $fontSize) 'Cancel did not restore preview'
- Open-Customization
- $field = [ThemeTestNative]::GetDlgItem($script:dialog, 102)
- $null = [ThemeTestNative]::SendText($field, 0xC, [IntPtr]::Zero, 'font:Consolas; size:15; fore:#EEEEEE; back:#111111')
- $null = [ThemeTestNative]::SendMessage($script:dialog, 0x111, [IntPtr]1, [IntPtr]::Zero)
- Select-Theme $false
- Assert ((Sci 2485 32) -eq 15 -and (Sci 2482 32) -eq $original) 'Customization did not share typography and isolate colors'
- Select-Theme $true
- $null = [ThemeTestNative]::SendMessage($script:window, 0x111, [IntPtr]40401, [IntPtr]::Zero)
- Assert ((Sci 2482 32) -eq 0x1E1E1E) 'Second default was not themed'
- $null = [ThemeTestNative]::SendMessage($script:window, 0x111, [IntPtr]40401, [IntPtr]::Zero)
- Stop-Editor
- Start-Editor $ini $document
- Assert ((Sci 2482 32) -eq 0x111111 -and (Sci 2485 32) -eq 15) 'Customized theme did not persist'
- Stop-Editor
- Write-Output "Theme integration passed. 40 switches: $($timer.ElapsedMilliseconds) ms. Fixtures: $testDir"
- if ($BaselineExecutable) {
- $currentExecutable = $Executable
- foreach ($mode in @('Baseline', 'Original', 'Dark')) {
- $Executable = if ($mode -eq 'Baseline') { (Resolve-Path $BaselineExecutable).Path } else { $currentExecutable }
- $samples = for ($run = 0; $run -lt 7; ++$run) {
- $theme = if ($mode -eq 'Dark') { 1 } else { 0 }
- [IO.File]::WriteAllText($ini, $settings + "[Styles]`r`nEditorTheme=$theme`r`n", [Text.Encoding]::Unicode)
- $startup = [Diagnostics.Stopwatch]::StartNew()
- Start-Editor $ini $document
- $startup.Stop()
- $script:app.Refresh()
- $privateBytes = $script:app.PrivateMemorySize64
- $editing = [Diagnostics.Stopwatch]::StartNew()
- for ($op = 0; $op -lt 100; ++$op) { $null = Sci 2645 0 1; $null = Sci 2176 }
- $editing.Stop()
- $cpuBefore = $script:app.TotalProcessorTime.TotalMilliseconds
- Start-Sleep -Milliseconds 200
- $script:app.Refresh()
- $idle = $script:app.TotalProcessorTime.TotalMilliseconds - $cpuBefore
- Stop-Editor
- [pscustomobject]@{ StartupMs=$startup.Elapsed.TotalMilliseconds; PrivateKiB=$privateBytes/1024; Edit100Ms=$editing.Elapsed.TotalMilliseconds; Idle200Ms=$idle }
- }
- $median = [ordered]@{ Mode=$mode }
- foreach ($metric in @('StartupMs','PrivateKiB','Edit100Ms','Idle200Ms')) {
- $median[$metric] = [Math]::Round(($samples.$metric | Sort-Object)[3], 2)
- }
- [pscustomobject]$median
- }
- }
-} finally {
- if ($script:app -and -not $script:app.HasExited) { Stop-Process -Id $script:app.Id }
-}
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 00000000..0bc51309
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+testpaths = tests
+addopts = -ra
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..2221e57a
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,43 @@
+from contextlib import contextmanager
+from pathlib import Path
+
+import pytest
+
+from windows_app import Editor
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def pytest_addoption(parser):
+ parser.addoption("--executable", default=str(ROOT / "bin/VS2022/Release_x64/Notepad2.exe"))
+ parser.addoption("--architecture", choices=("x86", "x64"), default="x64")
+ parser.addoption("--configuration", choices=("Release", "Debug"), default="Release")
+ parser.addoption("--legacy-save-behavior", action="store_true")
+
+
+@pytest.fixture
+def legacy_save_behavior(request):
+ return request.config.getoption("--legacy-save-behavior")
+
+
+@pytest.fixture
+def editor_factory(request, tmp_path):
+ serial = 0
+
+ @contextmanager
+ def factory(named=False, settings=None, scheme="cpp", content=None):
+ nonlocal serial
+ serial += 1
+ folder = tmp_path / str(serial)
+ folder.mkdir()
+ ini = folder / "settings.ini"
+ ini.write_text(settings or "[Settings]\nSaveSettings=1\n", encoding="utf-16")
+ document = folder / "sample.cpp"
+ document.write_bytes(content if content is not None else b"// comment\r\nint main() { return 42; }\r\n")
+ editor = Editor(Path(request.config.getoption("--executable")).resolve(), ini, document if named else None, scheme)
+ try:
+ yield editor
+ finally:
+ editor.close()
+
+ return factory
diff --git a/tests/requirements.in b/tests/requirements.in
new file mode 100644
index 00000000..b79da906
--- /dev/null
+++ b/tests/requirements.in
@@ -0,0 +1,2 @@
+pytest
+pywinauto
diff --git a/tests/requirements.txt b/tests/requirements.txt
new file mode 100644
index 00000000..b3655a61
--- /dev/null
+++ b/tests/requirements.txt
@@ -0,0 +1,61 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile tests/requirements.in --generate-hashes -o tests/requirements.txt
+colorama==0.4.6 \
+ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
+ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+ # via pytest
+comtypes==1.4.17 \
+ --hash=sha256:3d9c1e92ad8daf7600d371e76ee16161a627a5fb70c3144f6e52e78af6034363 \
+ --hash=sha256:4e0a221dde2c589b82977bed802efd71cd5eb67a380347b732e02735e597f586
+ # via pywinauto
+iniconfig==2.3.1 \
+ --hash=sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960 \
+ --hash=sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7
+ # via pytest
+packaging==26.3 \
+ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
+ --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
+ # via pytest
+pluggy==1.7.0 \
+ --hash=sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec \
+ --hash=sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8
+ # via pytest
+pygments==2.21.0 \
+ --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
+ --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
+ # via pytest
+pytest==9.2.0 \
+ --hash=sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588 \
+ --hash=sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b
+ # via -r tests/requirements.in
+pywin32==312 \
+ --hash=sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc \
+ --hash=sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c \
+ --hash=sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd \
+ --hash=sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831 \
+ --hash=sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed \
+ --hash=sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db \
+ --hash=sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950 \
+ --hash=sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e \
+ --hash=sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c \
+ --hash=sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa \
+ --hash=sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e \
+ --hash=sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b \
+ --hash=sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9 \
+ --hash=sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47 \
+ --hash=sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc \
+ --hash=sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5 \
+ --hash=sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9 \
+ --hash=sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a \
+ --hash=sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5 \
+ --hash=sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b \
+ --hash=sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb
+ # via pywinauto
+pywinauto==0.6.9 \
+ --hash=sha256:5924b3072864a1d730c5546bbeb17cf4063ba518b618dbc5e43c18276c7c9356 \
+ --hash=sha256:94d710bfa796df245250f952ffa65d97233d9807bcd42e052b71b81af469b6de
+ # via -r tests/requirements.in
+six==1.17.0 \
+ --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
+ --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
+ # via pywinauto
diff --git a/tests/test_native_contracts.py b/tests/test_native_contracts.py
new file mode 100644
index 00000000..2e7e8376
--- /dev/null
+++ b/tests/test_native_contracts.py
@@ -0,0 +1,54 @@
+"""Compile production MRU functions and source-linked Unicode/startup contracts."""
+import os
+from pathlib import Path
+import re
+import subprocess
+import xml.etree.ElementTree as ET
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_native_contracts(request, tmp_path):
+ arch = request.config.getoption("--architecture")
+ config = request.config.getoption("--configuration")
+ for source in (ROOT / "src").iterdir():
+ if source.suffix in (".c", ".cpp", ".h"):
+ source.read_bytes().decode("utf-8", errors="strict")
+ ns = {"m": "http://schemas.microsoft.com/developer/msbuild/2003"}
+ project = ET.parse(ROOT / "src/Notepad2.vcxproj")
+ compilers = project.findall("m:ItemDefinitionGroup/m:ClCompile", ns)
+ assert len(compilers) == 4
+ for compiler in compilers:
+ options = compiler.findtext("m:AdditionalOptions", namespaces=ns)
+ assert "/source-charset:utf-8" in options
+ assert "/execution-charset" not in options and "/utf-8" not in options
+ source = (ROOT / "src/Notepad2.c").read_text(encoding="utf-8")
+ elevation = source[source.index("BOOL RelaunchElevated() {"):]
+ show = re.search(r"sei\.nShow\s*=\s*([^;]+);", elevation).group(1)
+ edit = (ROOT / "src/Edit.c").read_text(encoding="utf-8")
+ punctuation = re.search(r'StrChr\((L"[^"]+"),pszTextW\[i\]\)', edit).group(1)
+ (tmp_path / "fork-contracts.h").write_text(
+ f"static int StartupShowState(STARTUPINFO si) {{ return {show}; }}\n"
+ f"static const wchar_t titlePunctuation[] = {punctuation};\n", encoding="utf-8"
+ )
+ vswhere = Path(os.environ["ProgramFiles(x86)"]) / "Microsoft Visual Studio/Installer/vswhere.exe"
+ vs = subprocess.check_output([
+ str(vswhere), "-latest", "-version", "[17.0,18.0)", "-products", "*",
+ "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"
+ ], text=True).strip()
+ assert vs
+ runtime = "/MTd" if config == "Debug" else "/MT"
+ batch = tmp_path / "run.bat"
+ batch.write_text(
+ f'@echo off\nchcp 65001 >nul\ncall "{vs}\\Common7\\Tools\\VsDevCmd.bat" -no_logo -arch={arch}\n'
+ 'if errorlevel 1 exit /b 1\n'
+ f'cd /d "{tmp_path}"\n'
+ f'cl /nologo /TC {runtime} /Gy /DUNICODE /D_UNICODE /D_CRT_SECURE_NO_WARNINGS /source-charset:utf-8 '
+ f'/I"{ROOT / "src"}" /I"{ROOT / "scintilla/include"}" /I"{tmp_path}" '
+ f'"{ROOT / "build/test-fork-helpers.c"}" "{ROOT / "src/Helpers.c"}" /Fe:contracts.exe '
+ '/link /OPT:REF /INCREMENTAL:NO shlwapi.lib shell32.lib user32.lib comctl32.lib ole32.lib comdlg32.lib gdi32.lib advapi32.lib\n'
+ 'if errorlevel 1 exit /b 1\ncontracts.exe\nexit /b %errorlevel%\n',
+ encoding="utf-8", newline="\r\n"
+ )
+ result = subprocess.run(["cmd.exe", "/d", "/c", str(batch)], capture_output=True, timeout=60)
+ assert result.returncode == 0, (result.stdout + result.stderr).decode("utf-8", errors="replace")
diff --git a/tests/test_save_prompts.py b/tests/test_save_prompts.py
new file mode 100644
index 00000000..455d80e1
--- /dev/null
+++ b/tests/test_save_prompts.py
@@ -0,0 +1,69 @@
+"""Exercise save prompting through the native UI, including embedded NUL data."""
+
+import pytest
+from pywinauto.timings import wait_until
+
+
+CASES = [
+ pytest.param(b"\0AB", False, True, id="leading-nul"),
+ pytest.param(b" \t\r\n", False, True, id="whitespace"),
+ pytest.param(b"\0A" + bytes(1020), False, True, id="1022-byte-nul"),
+ pytest.param(b"\0A" + bytes(1021), False, False, id="1023-byte-nul"),
+ pytest.param(b"", False, False, id="empty"),
+ pytest.param(b"\0AB", True, False, id="named-nul"),
+]
+
+
+@pytest.mark.parametrize("payload,named,legacy_drops", CASES)
+@pytest.mark.parametrize("operation", ["new", "open", "close"])
+def test_nonempty_document_prompts_before_discard(
+ editor_factory, legacy_save_behavior, payload, named, legacy_drops, operation
+):
+ with editor_factory(named=named) as editor:
+ editor.add_bytes(payload)
+ before = editor.read_bytes()
+ if operation == "close":
+ editor.post_close()
+ else:
+ editor.post_command(40000 if operation == "new" else 40001)
+
+ result = {}
+
+ def operation_reached_ui():
+ if not editor.is_running():
+ result["state"] = "closed"
+ return True
+ dialog = editor.dialog()
+ if dialog is not None and dialog.is_visible():
+ buttons = {
+ child.control_id()
+ for child in dialog.descendants(class_name="Button")
+ }
+ if {6, 7}.issubset(buttons):
+ result.update(state="save", dialog=dialog)
+ return True
+ if {1, 2}.issubset(buttons):
+ result["state"] = "open-dialog"
+ return True
+ if operation == "new" and editor.sci(2006) == 0:
+ result["state"] = "cleared"
+ return True
+ return False
+
+ wait_until(10, 0.05, operation_reached_ui)
+ should_prompt = bool(before) and not (
+ legacy_save_behavior and legacy_drops
+ )
+ assert (result["state"] == "save") is should_prompt, (
+ f"{operation}: observed {result['state']}; "
+ f"save prompt expected={should_prompt}"
+ )
+ if should_prompt:
+ result["dialog"].child_window(control_id=2).wrapper_object().click()
+ wait_until(5, 0.05, lambda: editor.dialog() is None)
+ assert editor.read_bytes() == before, "Cancel changed document bytes"
+ elif legacy_save_behavior and legacy_drops:
+ expected_state = {
+ "new": "cleared", "open": "open-dialog", "close": "closed"
+ }[operation]
+ assert result["state"] == expected_state
diff --git a/tests/test_themes.py b/tests/test_themes.py
new file mode 100644
index 00000000..78445832
--- /dev/null
+++ b/tests/test_themes.py
@@ -0,0 +1,87 @@
+import re
+from pathlib import Path
+
+import pytest
+
+SETTINGS = "[Settings]\nSaveSettings=1\n[Default Text]\nDefault Style=font:Consolas; size:13; fore:#123456; back:#EEDDCC\n"
+SOURCE = (Path(__file__).resolve().parents[1] / "src/Styles.c").read_text(encoding="utf-8")
+SCHEMES = re.findall(r'EDITLEXER\s+\w+\s*=\s*\{[^\r\n]*?L"([^"]+)",\s*L"([^"]*)"', SOURCE)
+
+
+@pytest.mark.parametrize("name,extensions", SCHEMES, ids=[entry[0] for entry in SCHEMES])
+def test_scheme_round_trip(editor_factory, name, extensions):
+ assert len(SCHEMES) == 38
+ with editor_factory(named=True, settings=SETTINGS, scheme=extensions.split(";")[0].strip()) as e:
+ original = e.sci(2482, 32)
+ e.theme(True)
+ assert e.sci(2482, 32) == 0x1E1E1E
+ e.theme(False)
+ assert e.sci(2482, 32) == original
+
+
+def test_switching_preserves_document_and_preferences(editor_factory):
+ with editor_factory(named=True, settings=SETTINGS) as e:
+ e.sci(2645, 0, 1)
+ e.sci(2160, 2, 7)
+ before = e.read_bytes()
+ state = [e.sci(m) for m in (2008, 2009, 2152, 2174, 4002)]
+ original, font = e.sci(2482, 32), e.sci(2485, 32)
+ for _ in range(20):
+ e.theme(True)
+ assert e.sci(2482, 32) == 0x1E1E1E
+ assert e.sci(2485, 32) == font
+ e.theme(False)
+ assert e.sci(2482, 32) == original
+ assert e.read_bytes() == before
+ assert [e.sci(m) for m in (2008, 2009, 2152, 2174, 4002)] == state
+
+
+@pytest.mark.parametrize("save", [True, False])
+def test_selection_persistence(editor_factory, save):
+ with editor_factory(named=True, settings=SETTINGS.replace("SaveSettings=1", f"SaveSettings={int(save)}")) as e:
+ original = e.sci(2482, 32)
+ e.theme(True)
+ e.exit()
+ e.launch()
+ assert e.sci(2482, 32) == (0x1E1E1E if save else original)
+ e.theme(False)
+ assert e.sci(2482, 32) == original
+
+
+@pytest.mark.parametrize("extra,expected", [
+ ("[Styles]\nEditorTheme=99\n", 0xCCDDEE),
+ ("[Styles]\nEditorTheme=1\n[Theme.Dark.Default Text]\nDefault Style=back:invalid\n", 0x1E1E1E),
+ ("[Styles]\nEditorTheme=1\n[Theme.Dark.Default Text]\nDefault Style=font:Ignored; size:99; fore:#ABCDEF; back:#222222\n", 0x222222),
+])
+def test_saved_palette_defaults(editor_factory, extra, expected):
+ with editor_factory(named=True, settings=SETTINGS + extra) as e:
+ assert e.sci(2482, 32) == expected
+ assert e.sci(2485, 32) == 13
+ e.theme(False)
+ assert e.sci(2482, 32) == 0xCCDDEE
+
+
+def test_customize_preview_cancel_reset_and_save(editor_factory):
+ with editor_factory(named=True, settings=SETTINGS) as e:
+ e.theme(True)
+ dialog = e.customize()
+ dialog.child_window(control_id=102).set_edit_text("font:Consolas; size:15; fore:#EEEEEE; back:#111111")
+ dialog.child_window(control_id=106).click()
+ assert e.sci(2482, 32) == 0x111111
+ dialog.child_window(control_id=107).click()
+ dialog.child_window(control_id=106).click()
+ assert e.sci(2482, 32) == 0x1E1E1E
+ dialog.child_window(control_id=2).click()
+ assert e.sci(2482, 32) == 0x1E1E1E and e.sci(2485, 32) == 13
+ dialog = e.customize()
+ dialog.child_window(control_id=102).set_edit_text("font:Consolas; size:15; fore:#EEEEEE; back:#111111")
+ dialog.child_window(control_id=1).click()
+ e.theme(False)
+ assert e.sci(2485, 32) == 15 and e.sci(2482, 32) == 0xCCDDEE
+ e.theme(True)
+ e.command(40401)
+ assert e.sci(2482, 32) == 0x1E1E1E
+ e.command(40401)
+ e.exit()
+ e.launch()
+ assert e.sci(2482, 32) == 0x111111 and e.sci(2485, 32) == 15
diff --git a/tests/windows_app.py b/tests/windows_app.py
new file mode 100644
index 00000000..620ee96f
--- /dev/null
+++ b/tests/windows_app.py
@@ -0,0 +1,97 @@
+"""pywinauto owns window/control automation; this adapter handles Scintilla messages."""
+import ctypes
+import subprocess
+
+from pywinauto import Application
+from pywinauto.remote_memory_block import RemoteMemoryBlock
+from pywinauto.timings import wait_until
+
+
+class Editor:
+ def __init__(self, executable, ini, document=None, scheme="cpp"):
+ self.executable, self.ini, self.document, self.scheme = executable, ini, document, scheme
+ self.launch()
+
+ def launch(self):
+ args = [str(self.executable), "/n", "/f", str(self.ini), "/s", self.scheme]
+ if self.document:
+ args.append(str(self.document))
+ startup = subprocess.STARTUPINFO()
+ startup.dwFlags = subprocess.STARTF_USESHOWWINDOW
+ startup.wShowWindow = 0
+ self.process = subprocess.Popen(args, startupinfo=startup)
+ try:
+ self.app = Application(backend="win32").connect(process=self.process.pid, timeout=60)
+ self.main = self.app.window(class_name_re="Notepad2.*", visible_only=False)
+ self.main.wait("exists", timeout=60)
+ control = self.main.child_window(class_name="Scintilla")
+ control.wait("exists", timeout=60)
+ self.editor = control.wrapper_object()
+ except BaseException:
+ self.close()
+ raise
+
+ def sci(self, message, w=0, l=0):
+ return self.editor.send_message_timeout(message, w, l, timeout=10)
+
+ def add_bytes(self, data):
+ if data:
+ buffer = ctypes.create_string_buffer(data)
+ remote = RemoteMemoryBlock(self.editor, size=len(buffer))
+ try:
+ remote.Write(buffer)
+ self.sci(2001, len(data), remote.Address())
+ finally:
+ remote.CleanUp()
+
+ def read_bytes(self):
+ size = self.sci(2006)
+ buffer = ctypes.create_string_buffer(size + 1)
+ remote = RemoteMemoryBlock(self.editor, size=size + 1)
+ try:
+ self.sci(2182, size + 1, remote.Address())
+ remote.Read(buffer)
+ return buffer.raw[:size]
+ finally:
+ remote.CleanUp()
+
+ def command(self, command):
+ self.main.send_message_timeout(0x111, command, 0, timeout=10)
+
+ def post_command(self, command):
+ self.main.post_message(0x111, command, 0)
+
+ def post_close(self):
+ self.main.post_message(0x10)
+
+ def dialog(self):
+ if not self.is_running():
+ return None
+ windows = self.app.windows(class_name="#32770", visible_only=False)
+ return self.app.window(handle=windows[0].handle) if windows else None
+
+ def is_running(self):
+ return self.process.poll() is None
+
+ def theme(self, dark):
+ self.command(40981 if dark else 40980)
+
+ def customize(self):
+ self.post_command(40402)
+ wait_until(10, 0.05, lambda: self.dialog() is not None and self.dialog().is_visible())
+ dialog = self.dialog()
+ tree = dialog.child_window(control_id=100).wrapper_object()
+ wait_until(10, 0.05, lambda: tree.item_count() > 38)
+ tree.get_item([0, 0]).select()
+ return dialog
+
+ def exit(self):
+ self.sci(2014) # Tests decide what to save; never leave an unsaved prompt.
+ self.post_close()
+ self.process.wait(timeout=10)
+ assert self.process.returncode == 0
+
+ def close(self):
+ if self.is_running():
+ self.process.terminate()
+ self.process.wait(timeout=10)
From d5b9e605e30d54a3af8cd1a93d9b287bcd1ef89d Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:35:19 +0100
Subject: [PATCH 07/10] test: register pytest CLI options at repository root
---
.editorconfig | 5 +++++
conftest.py | 11 +++++++++++
tests/conftest.py | 14 ++++----------
3 files changed, 20 insertions(+), 10 deletions(-)
create mode 100644 conftest.py
diff --git a/.editorconfig b/.editorconfig
index 5119759f..5d51505c 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -16,6 +16,11 @@ trim_trailing_whitespace = true
[*.ps1]
indent_size = 4
+[*.py]
+charset = utf-8
+indent_size = 4
+end_of_line = lf
+
[tests/**]
charset = utf-8
indent_style = space
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 00000000..d843d637
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,11 @@
+"""Register CLI options before pytest resolves test paths."""
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+
+
+def pytest_addoption(parser):
+ parser.addoption("--executable", default=str(ROOT / "bin/VS2022/Release_x64/Notepad2.exe"))
+ parser.addoption("--architecture", choices=("x86", "x64"), default="x64")
+ parser.addoption("--configuration", choices=("Release", "Debug"), default="Release")
+ parser.addoption("--legacy-save-behavior", action="store_true")
diff --git a/tests/conftest.py b/tests/conftest.py
index 2221e57a..91cdd231 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,20 +1,14 @@
from contextlib import contextmanager
from pathlib import Path
+import sys
+
+# OLE clipboard preservation requires STA; select it before importing pywinauto.
+sys.coinit_flags = 2
import pytest
from windows_app import Editor
-ROOT = Path(__file__).resolve().parents[1]
-
-
-def pytest_addoption(parser):
- parser.addoption("--executable", default=str(ROOT / "bin/VS2022/Release_x64/Notepad2.exe"))
- parser.addoption("--architecture", choices=("x86", "x64"), default="x64")
- parser.addoption("--configuration", choices=("Release", "Debug"), default="Release")
- parser.addoption("--legacy-save-behavior", action="store_true")
-
-
@pytest.fixture
def legacy_save_behavior(request):
return request.config.getoption("--legacy-save-behavior")
From c59e8209abd838309dc89bacf78616cab54e2d8a Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:35:20 +0100
Subject: [PATCH 08/10] feat: copy and cut the current line with standard
shortcuts
---
Readme.md | 9 +++
src/Notepad2.c | 15 +++--
src/Notepad2.rc | Bin 185458 -> 185742 bytes
tests/test_line_shortcuts.py | 127 +++++++++++++++++++++++++++++++++++
4 files changed, 147 insertions(+), 4 deletions(-)
create mode 100644 tests/test_line_shortcuts.py
diff --git a/Readme.md b/Readme.md
index a623f13d..be59e78d 100644
--- a/Readme.md
+++ b/Readme.md
@@ -12,6 +12,9 @@ minimal, focused patches with a clear benefit. This fork will not move to
Notepad3/Notepad4 or adopt their feature stacks; matching newer editors or
dependency versions is not a goal.
+The interface remains single-document. Multi-tab editing and Notepad++ parity
+are out of scope; isolated fixes from other editors may be evaluated surgically.
+
The upstream repository, [XhmikosR/notepad2-mod](https://github.com/XhmikosR/notepad2-mod),
is archived and was last updated in July 2018. Ongoing maintenance for this fork
happens here; upstream history, licensing, and contributor credits are preserved.
@@ -80,6 +83,12 @@ Compatibility with other Windows versions has not been verified.
## Changed keyboard shortcuts compared to Notepad2:
+With no selection, **Ctrl+C / Ctrl+Insert** copy the current line and
+**Ctrl+X / Shift+Delete** cut it. These line operations include a line-ending and
+use Scintilla's existing line-clipboard marker: **Ctrl+V / Shift+Insert** insert
+the line above the caret's current line. With a selection, normal copy/cut/paste
+behavior is retained. Cutting is undoable and respects read-only documents.
+
* Ctrl+Alt+F2 Expand selection to next match.
* Ctrl+Alt+Shift+F2 Expand selection to previous match.
* Ctrl+Shift+Enter New line with toggled auto indent option.
diff --git a/src/Notepad2.c b/src/Notepad2.c
index d5f758b9..9c6916b4 100644
--- a/src/Notepad2.c
+++ b/src/Notepad2.c
@@ -2108,8 +2108,8 @@ void MsgInitMenu(HWND hwnd,WPARAM wParam,LPARAM lParam)
i = (int)SendMessage(hwndEdit,SCI_GETSELECTIONEND,0,0) - (int)SendMessage(hwndEdit,SCI_GETSELECTIONSTART,0,0);
i2 = (int)SendMessage(hwndEdit,SCI_CANPASTE,0,0);
- EnableCmd(hmenu,IDM_EDIT_CUT,i /*&& !bReadOnly*/);
- EnableCmd(hmenu,IDM_EDIT_COPY,i /*&& !bReadOnly*/);
+ EnableCmd(hmenu,IDM_EDIT_CUT,SendMessage(hwndEdit,SCI_GETLENGTH,0,0));
+ EnableCmd(hmenu,IDM_EDIT_COPY,SendMessage(hwndEdit,SCI_GETLENGTH,0,0));
EnableCmd(hmenu,IDM_EDIT_COPYALL,SendMessage(hwndEdit,SCI_GETLENGTH,0,0) /*&& !bReadOnly*/);
EnableCmd(hmenu,IDM_EDIT_COPYADD,i /*&& !bReadOnly*/);
EnableCmd(hmenu,IDM_EDIT_PASTE,i2 /*&& !bReadOnly*/);
@@ -2845,14 +2845,21 @@ LRESULT MsgCommand(HWND hwnd,WPARAM wParam,LPARAM lParam)
case IDM_EDIT_CUT:
if (flagPasteBoard)
bLastCopyFromMe = TRUE;
- SendMessage(hwndEdit,SCI_CUT,0,0);
+ if (SendMessage(hwndEdit,SCI_GETSELECTIONEMPTY,0,0)) {
+ if (!SendMessage(hwndEdit,SCI_GETREADONLY,0,0) && SendMessage(hwndEdit,SCI_GETLENGTH,0,0)) {
+ SendMessage(hwndEdit,SCI_COPYALLOWLINE,0,0);
+ SendMessage(hwndEdit,SCI_LINEDELETE,0,0);
+ }
+ }
+ else
+ SendMessage(hwndEdit,SCI_CUT,0,0);
break;
case IDM_EDIT_COPY:
if (flagPasteBoard)
bLastCopyFromMe = TRUE;
- SendMessage(hwndEdit,SCI_COPY,0,0);
+ SendMessage(hwndEdit,SCI_COPYALLOWLINE,0,0);
UpdateToolbar();
break;
diff --git a/src/Notepad2.rc b/src/Notepad2.rc
index 73d65ecec113f5b4b93690292166b4b85fc05453..999eb5e7b0ba23e537da7c346503c043d18fcf76 100644
GIT binary patch
delta 90
zcmeygg1c`u_l8faCM(=w&GS)$KZ~8Mo`KW^&U60196sg8%>k
delta 26
icmeBM&HZTw_l8faCL64^YW7&u?y-h(yT=+PH%$Pt&kOGW
diff --git a/tests/test_line_shortcuts.py b/tests/test_line_shortcuts.py
new file mode 100644
index 00000000..b71a22a4
--- /dev/null
+++ b/tests/test_line_shortcuts.py
@@ -0,0 +1,127 @@
+"""Current-line shortcuts use existing Scintilla actions, without a new subsystem."""
+import ctypes
+import pythoncom
+import pytest
+import win32clipboard
+from pywinauto.timings import wait_until, wait_until_passes
+
+
+def clipboard_text(value=None):
+ def access():
+ win32clipboard.OpenClipboard()
+ try:
+ if value is None:
+ return win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT)
+ win32clipboard.EmptyClipboard()
+ win32clipboard.SetClipboardText(value, win32clipboard.CF_UNICODETEXT)
+ finally:
+ win32clipboard.CloseClipboard()
+ return wait_until_passes(5, 0.05, access)
+
+
+@pytest.fixture(autouse=True)
+def preserve_clipboard():
+ # OLE retains the original data object, including non-text formats.
+ pythoncom.OleInitialize()
+ original = wait_until_passes(5, 0.05, pythoncom.OleGetClipboard)
+ try:
+ clipboard_text("np2-test-sentinel")
+ yield
+ finally:
+ try:
+ wait_until_passes(5, 0.05, lambda: pythoncom.OleSetClipboard(original))
+ wait_until_passes(5, 0.05, pythoncom.OleFlushClipboard)
+ finally:
+ ctypes.windll.ole32.OleUninitialize()
+
+
+@pytest.mark.parametrize("eol", [b"\r\n", b"\n", b"\r"])
+@pytest.mark.parametrize("last", [False, True])
+@pytest.mark.parametrize("keys", ["^c", "^{INSERT}"])
+def test_ctrl_c_copies_line_without_selection(editor_factory, eol, last, keys):
+ content = b"first" + eol + "βeta".encode()
+ with editor_factory(named=True, content=content) as e:
+ e.sci(2031, {b"\r\n": 0, b"\r": 1, b"\n": 2}[eol])
+ position = len(b"first" + eol) + 2 if last else 2
+ e.sci(2160, position, position)
+ before, undo = e.read_bytes(), e.sci(2174)
+ e.editor.send_keystrokes(keys)
+ expected = ("βeta" if last else "first") + eol.decode()
+ wait_until(5, 0.05, lambda: clipboard_text() == expected)
+ assert e.read_bytes() == before
+ assert e.sci(2008) == e.sci(2009) == position
+ assert e.sci(2174) == undo
+
+
+@pytest.mark.parametrize("selection", [False, True])
+@pytest.mark.parametrize("keys", ["^x", "+{DELETE}"])
+def test_shift_delete_cuts_line_or_selection_and_undoes(editor_factory, selection, keys):
+ original = b"first\r\nsecond\r\nthird"
+ with editor_factory(named=True, content=original) as e:
+ e.sci(2160, 1 if selection else 2, 4 if selection else 2)
+ e.editor.send_keystrokes(keys)
+ expected = b"ft\r\nsecond\r\nthird" if selection else b"second\r\nthird"
+ wait_until(5, 0.05, lambda: e.read_bytes() == expected)
+ assert clipboard_text() == ("irs" if selection else "first\r\n")
+ e.sci(2176)
+ assert e.read_bytes() == original
+
+
+def test_ctrl_c_keeps_selected_text_behavior(editor_factory):
+ with editor_factory(named=True, content=b"first\r\nsecond") as e:
+ e.sci(2160, 1, 4)
+ e.editor.send_keystrokes("^c")
+ wait_until(5, 0.05, lambda: clipboard_text() == "irs")
+ assert e.read_bytes() == b"first\r\nsecond"
+
+
+def test_read_only_cut_preserves_text_and_clipboard(editor_factory):
+ with editor_factory(named=True, content=b"read only\r\n") as e:
+ e.sci(2171, 1)
+ e.command(40302)
+ assert e.read_bytes() == b"read only\r\n"
+ assert clipboard_text() == "np2-test-sentinel"
+
+
+@pytest.mark.parametrize("original,position,remaining,copied", [
+ (b"last", 2, b"", "last\r\n"),
+ (b"first\r\nlast", 9, b"first\r\n", "last\r\n"),
+ (b"first\r\n\r\nthird", 7, b"first\r\nthird", "\r\n"),
+])
+def test_cut_line_edges(editor_factory, original, position, remaining, copied):
+ with editor_factory(named=True, content=original) as e:
+ e.sci(2160, position, position)
+ e.editor.send_keystrokes("+{DELETE}")
+ wait_until(5, 0.05, lambda: e.read_bytes() == remaining)
+ assert clipboard_text() == copied
+ e.sci(2176)
+ assert e.read_bytes() == original
+
+
+def test_empty_cut_does_not_replace_clipboard(editor_factory):
+ with editor_factory() as e:
+ e.command(40302)
+ assert e.read_bytes() == b""
+ assert clipboard_text() == "np2-test-sentinel"
+
+
+@pytest.mark.parametrize("copy_keys", ["^c", "^{INSERT}", "^x", "+{DELETE}"])
+@pytest.mark.parametrize("paste_keys", ["^v", "+{INSERT}"])
+def test_line_clipboard_pastes_above_current_line(editor_factory, copy_keys, paste_keys):
+ original = b"first\r\nsecond\r\nthird"
+ cutting = copy_keys in ("^x", "+{DELETE}")
+ with editor_factory(named=True, content=original) as e:
+ e.sci(2160, 2, 2)
+ e.editor.send_keystrokes(copy_keys)
+ wait_until(5, 0.05, lambda: clipboard_text() == "first\r\n")
+ marker = win32clipboard.RegisterClipboardFormat("MSDEVLineSelect")
+ assert win32clipboard.IsClipboardFormatAvailable(marker)
+ # Paste while the caret is inside 'third', not at column zero.
+ before_paste = b"second\r\nthird" if cutting else original
+ caret = len(before_paste) - 2
+ e.sci(2160, caret, caret)
+ e.editor.send_keystrokes(paste_keys)
+ expected = b"second\r\nfirst\r\nthird" if cutting else b"first\r\nsecond\r\nfirst\r\nthird"
+ wait_until(5, 0.05, lambda: e.read_bytes() == expected)
+ e.sci(2176)
+ assert e.read_bytes() == before_paste
From 6b159683662fb1c144f3c79cbb1dafde35b53a83 Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:35:20 +0100
Subject: [PATCH 09/10] docs: record focused corrections and native regression
coverage
---
ROADMAP.md | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index edecc701..74defd56 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -67,10 +67,13 @@ demonstrates a concrete benefit.
## Fork review backlog
Three independent agent reviews and a maintainer cross-check selected four
-surgical corrections. These are approved implementation candidates, not imported
-code. Reproduce/validate each defect before porting, retain attribution, and use
-one focused commit per fix. No dependency upgrade, new subsystem, shortcut change,
-or cosmetic feature is included. No fork was built or runtime-tested in this review.
+surgical corrections, now implemented locally in separate commits. The unsaved
+buffer defect was reproduced against the pre-fix binary. Native regression tests
+cover prompting/cancellation, production MRU functions, source charset and startup
+show-state selection; actual standard-user UAC interaction remains a manual check.
+The fork review introduced no dependency upgrade or subsystem. A separately
+requested improvement adds selection-aware line copy/cut using existing Scintilla
+actions and clipboard markers. UI regression tests use pytest and pywinauto.
| Priority | Accepted correction | Source |
| --- | --- | --- |
From 75bb8a517f231ecb7055a65804190e4167017fae Mon Sep 17 00:00:00 2001
From: itlezy <24484050+itlezy@users.noreply.github.com>
Date: Mon, 9 Nov 2026 21:49:03 +0100
Subject: [PATCH 10/10] docs(release): describe focused fixes and line
clipboard behavior
---
build/publish-release.ps1 | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/build/publish-release.ps1 b/build/publish-release.ps1
index 496aaeeb..2fb72123 100644
--- a/build/publish-release.ps1
+++ b/build/publish-release.ps1
@@ -31,13 +31,17 @@ $notes = Join-Path $PackageDirectory 'release-notes.txt'
@"
Native Original/Dark editor palettes on Scintilla 3.7.6, with a VS 2022 build.
+Focused corrections protect nonempty untitled documents during save prompting, skip empty recent-file slots, make application source encoding deterministic, and honor elevated-launch show-state flags.
+
+With no selection, Ctrl+C / Ctrl+Insert copy the current line and Ctrl+X / Shift+Delete cut it. Ctrl+V / Shift+Insert paste line-marked clipboard content above the current line. Normal selected-text behavior is retained.
+
Choose View > Editor Theme > Dark. Existing Original colors and font preferences are retained. Application chrome is unchanged.
Download the portable ZIP for your architecture and extract it into a writable folder. Preserve your existing Notepad2.ini when upgrading. These binaries are unsigned. SHA256SUMS.txt provides archive integrity checks.
Source commit: $commit
Executable version: $version
-Validation: Release/Debug x86/x64 builds and native theme integration tests, including all 38 schemes and customization/persistence checks.
+Validation: Release/Debug x86/x64 builds with pytest and pywinauto, including all 38 schemes, customization/persistence, save-prompt data preservation, line shortcuts, and native helper contracts. Actual standard-user UAC interaction remains a manual check.
"@ | Set-Content $notes -Encoding utf8
$existing = & gh release view $Tag --json isDraft 2>$null
if ($LASTEXITCODE -eq 0) {