Make the suite pass on Windows - #41
Merged
Merged
Conversation
The `windows` job added alongside the audit fixes ran 658 tests and returned 45 failures. One was the library; the rest were the suite and the checkout assuming POSIX. **The library.** `FileInfo::setNameWithExtension()` and `Filename::sanitizeNameWithExtension()` split a client filename with `pathinfo()`, which treats `\` as a path separator on Windows and as an ordinary character on POSIX. The same name split two ways: `a\b.txt` was stored as `a-b.txt` here and `b.txt` there, `..\..\windows\win.ini` as `windows-win.ini` or `win.ini`. `Filename::rewriteCharacters()` rewrites `\` to `-`, so the rule both layers read from `Filename` is that a backslash stays in the name — and a rule cannot depend on which platform applies it. `Filename::splitNameAndExtension()` owns the split now, with `/` the only separator on every platform. It answers what `pathinfo()`'s two fields answered otherwise, trailing slashes and the all-extension dotfile included, which the provider pins case by case. **The assertions.** `upload()` returns `$this->directory . $filename` and the constructor ends the directory with `DIRECTORY_SEPARATOR`, where these tests build their working directory with `/`. Windows resolves both to the same file, so the storage was correct and the string comparison was not. Four sites and the shared `assertStoredAs()` helper now go through `destinationOf()`. **The line endings.** The callback tests echoed `PHP_EOL` against an expected literal `\n`; they are about hook order, so the literal wins. `CatalogueTest` parses `i18n/upload.pot` line by line and a CRLF checkout left `"$` unmatchable, so no msgid was read and every assertion failed vacuously — `.gitattributes` pins that file to LF, which the `i18n` workflow's byte comparison needs regardless. **The subprocess.** `FilenameTest` builds a `php -r` script containing `"a\nb"`. `escapeshellarg()` quotes with `"` on Windows and cannot escape one inside the argument, so the inner quotes were dropped and PHP read `a\nb` as a constant. The script carries no quote character of its own now. **What cannot run there.** NTFS refuses control characters in a filename, so `touch()` cannot create the colliding file two of the collision-message data sets need. Those two are split into their own `@group posix` test, since a data set cannot carry a group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`testAUnicodeSpaceIsKeptInAStoredName` was the one site the first pass missed, and the only Windows failure left: 9 of them, one per unicode space, all the same `/` against `DIRECTORY_SEPARATOR` comparison. The four remaining sites are inside `@group posix` tests, so Windows never reached them, but they carry the same assumption and would surface the day one of those tests stops being excluded. `destinationOf()` is now the only way this suite asserts a locator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Windows under PHP 7.3 reports `ino` as 0 for both `fstat()` and `lstat()`, so `reserveDestination()`'s identity comparison had nothing to compare — and read that as a mismatch. Every reservation was answered `'Destination is a symbolic link'`, which is every upload the default `$overwrite = false` makes: the platform could not store a single file. Nine of the ten remaining Windows 7.3 failures were that one line, the tenth its knock-on. Skipped where the inode is unavailable rather than failed. Nothing has been established either way, a real file has a real inode so POSIX gives up nothing, and the symlink protections in this class were already documented as not load-bearing on Windows, where a symlink needs a privilege an uploading process should not hold. The comment above the check claimed the comparison "degrades to same-drive and detects nothing" there. It detected everything. Corrected. `lstatEntry()` is the seam for asserting this on the platform the suite actually runs on, so the new test stubs the answer that platform gives — no inode, and a `dev` of its own, so it is the missing inode that decides rather than a lucky match on the drive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`releaseReservation()` inferred it from whether `readlink()` failed. That is not the same question on every platform: PHP's Windows `readlink()` answers a *regular file* with its own canonical path instead of failing, so the 0-byte placeholder took the symlink branch, found nothing matching the inode it opened, and was never removed — holding the caller's name against every later upload of it. `is_link()` asks directly. The stat cache is cleared first because `reserveDestination()` has already lstat'd that path. The last of the Windows failures. Both branches are covered on POSIX already — `testAReservationThatCannotBeConfirmedIsNotReportedAsASymlink` for the plain file, `testReservationThroughASymlinkLeavesNothingAtItsTarget` for the link — and the first of those is what the Windows job caught this with, on the platform where `readlink()` behaves that way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both versions pass: 654 tests and 1284 assertions on each, identical to `--exclude-group posix` locally, so the grouping selects the same work on both platforms and what is left is real difference rather than drift. It earned the promotion on its first outing, with two library bugs on the default `$overwrite = false` path that no Linux job could reach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #40, which adds the
windowsjob. Review that one first; this PR's diff is only its own change.The job ran 658 tests on its first outing and returned 45 failures. One was the library. The other 44 were the suite and the checkout assuming POSIX — which is exactly what a platform nobody had ever tested on was going to produce, and why the job went in
continue-on-error.Library finding 1 —
pathinfo()splits on\under WindowsFileInfo::setNameWithExtension()andFilename::sanitizeNameWithExtension()split a client filename withpathinfo(). That treats\as a path separator on Windows and as an ordinary character on POSIX, so the same name split two ways:a\b.txta-b.txtb.txt..\..\windows\win.iniwindows-win.iniwin.inia----t----t----.php\x00.pnga-t-t-php-x00.pngx00.pngFilename::rewriteCharacters()rewrites\to-, so the rule both layers read fromFilenameis that a backslash stays in the name.Filename's own docblock says the two layers "must not disagree about what the rules are" — and a rule that depends on which platform is applying it is not one rule.Filename::splitNameAndExtension()owns the split now, with/the only separator everywhere. It answers whatpathinfo()'sPATHINFO_FILENAMEandPATHINFO_EXTENSIONanswered in every other respect, and the new provider pins that case by case — the trailing slashbasename()drops, the dotfile that is all extension and no name,..and..., repeated slashes. Nothing on POSIX changes; the existing 76 sanitizing data sets pass untouched, which is the evidence for that.On Windows this keeps name content that was previously discarded, so it is noted in
UPGRADE.mdas well asCHANGELOG.md.Library finding 2 — the default configuration could not store a file on Windows/7.3
reserveDestination()confirms its exclusive create by comparingfstat()againstlstat(); that is what catches anxthat followed a symlink. Windows before PHP 7.4 reportsinoas 0 for both, so the comparison had nothing to compare — and read that as a mismatch. Every reservation was answered'Destination is a symbolic link', and a reservation happens whenever$overwrite = false, which is the default. That platform could not store a single file.The comment above the check said the comparison "degrades to same-drive and detects nothing" there. It detected everything. Corrected along with the code.
The comparison is now skipped where the inode is unavailable rather than absence being read as mismatch. A real file has a real inode, so POSIX gives up nothing, and the symlink protections in this class were already documented as not load-bearing on Windows — where a symlink needs a privilege an uploading process should not hold.
testAReservationIsNotRefusedWhereTheInodeIsUnavailable()stubslstatEntry()with what that platform answers — no inode, and adevof its own, so it is the missing inode that decides rather than a lucky match on the drive. Removing the guard reproduces the exact'Destination is a symbolic link'failure; I checked both ways.Library finding 3 — an orphaned placeholder on Windows
releaseReservation()decided whether the destination was a symlink by whetherreadlink()failed. That is not the same question on every platform: PHP's Windowsreadlink()answers a regular file with its own canonical path rather than failing, so the 0-byte placeholder took the symlink branch, found nothing matching the inode it had opened, and was never removed — holding the caller's name against every later upload of it.is_link()asks directly. The stat cache is cleared first, sincereserveDestination()has already lstat'd that path.Both branches already had POSIX tests —
testAReservationThatCannotBeConfirmedIsNotReportedAsASymlinkfor the plain file,testReservationThroughASymlinkLeavesNothingAtItsTargetfor the link. They pass on Linux either way; the first is what caught this, on the platform wherereadlink()behaves that way.The other 44
Path separators in assertions (~35).
upload()returns$this->directory . $filenameand the constructor ends the directory withDIRECTORY_SEPARATOR, where these tests build their working directory with/. Windows resolves both to the same file, so the storage was right and the string comparison was wrong. Four sites and the sharedassertStoredAs()helper now go through adestinationOf()helper.assertFileExists()was always fine — PHP resolves either separator.Line endings (5 sites, 2 tests). The callback tests echo
PHP_EOLagainst an expected literal\n. They are about hook order, so the literal wins and the expectation stays readable.A CRLF checkout (3).
CatalogueTestparsesi18n/upload.potline by line with/^msgid "(.*)"$/m. Under a CRLF checkout the trailing\rmakes"$unmatchable, so no msgid was read and every assertion failed vacuously..gitattributespins that file to LF — which thei18nworkflow needs regardless, since its guarantee is a byte comparison against a generator that writes LF.A subprocess (1).
FilenameTestbuilds aphp -rscript containing"a\nb".escapeshellarg()quotes with"on Windows and cannot escape one inside the argument, so the inner quotes were dropped and PHP reada\nbas a constant. The script carries no quote character of its own now.What genuinely cannot run there (2, errors). NTFS refuses control characters in a filename outright, so
touch()cannot create the colliding file that two of the collision-message data sets need. Split into their own@group posixtest, since a data set cannot carry a group — the same treatment the fourteensymlink()/chmod()/umask()tests get in #40.Verified
phpunit(full,--exclude-group posix,--exclude-group mbstring),lint,phpstan,check-syntax,base64-docs,i18n:pot, the package archive diff, andcomposer validate --strictall pass locally. 647 → 671 tests.Both Windows jobs now pass — 654 tests and 1,284 assertions on 7.3 and on 8.5, identical to
--exclude-group posixlocally, so the grouping selects the same work on both platforms and what remains is real difference rather than selection drift.continue-on-erroris removed and the job is a gate.One correction
While measuring for this branch I found the test counts I quoted on #40 were taken against the wrong baseline.
mainis 628 tests, so #40 is 628 → 647 (the +19 was right; the absolute figures were the CJK-tests branch I had been standing on). #40's description is corrected.🤖 Generated with Claude Code