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) {